@nanhara/hara 0.120.0 → 0.121.1
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/CHANGELOG.md +39 -0
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +24 -2
- package/dist/config.js +31 -5
- package/dist/context/mentions.js +17 -7
- package/dist/desk.js +64 -0
- package/dist/feedback.js +2 -7
- package/dist/fs-read.js +157 -0
- package/dist/fs-write.js +105 -0
- package/dist/index.js +103 -3
- package/dist/security/secrets.js +75 -0
- package/dist/serve/server.js +5 -5
- package/dist/serve/sessions.js +5 -1
- package/dist/session/store.js +58 -16
- package/dist/tools/builtin.js +74 -15
- package/dist/tools/edit.js +10 -2
- package/dist/tools/patch.js +45 -15
- package/dist/tools/registry.js +20 -6
- package/dist/tools/result-limit.js +37 -0
- package/dist/tools/web.js +227 -41
- package/dist/tui/App.js +1 -1
- package/dist/tui/InputBox.js +62 -4
- package/dist/tui/input-history.js +167 -0
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +6 -4
- package/package.json +6 -1
package/dist/tools/patch.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
|
|
2
2
|
// computed in memory first; nothing is written unless every change applies cleanly.
|
|
3
|
-
import {
|
|
4
|
-
import { isAbsolute, resolve
|
|
3
|
+
import { lstat, readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { applyEdits } from "./apply-core.js";
|
|
7
7
|
import { emitDiff } from "../diff.js";
|
|
8
8
|
import { recordEdit } from "../undo.js";
|
|
9
|
+
import { atomicWriteText, FileChangedError } from "../fs-write.js";
|
|
10
|
+
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
9
11
|
registerTool({
|
|
10
12
|
name: "apply_patch",
|
|
11
13
|
description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
|
|
@@ -51,13 +53,29 @@ registerTool({
|
|
|
51
53
|
const abs = (pth) => (isAbsolute(pth) ? pth : resolve(ctx.cwd, pth));
|
|
52
54
|
// PHASE 1 — validate + compute every change in memory; bail before writing anything.
|
|
53
55
|
const plans = [];
|
|
56
|
+
const plannedPaths = new Set();
|
|
54
57
|
for (let i = 0; i < changes.length; i++) {
|
|
55
58
|
const ch = changes[i];
|
|
56
59
|
const tag = `change ${i + 1}/${changes.length}`;
|
|
57
60
|
if (typeof ch.path !== "string" || !ch.path)
|
|
58
61
|
return `Error: ${tag} is missing a path. Nothing written.`;
|
|
59
62
|
const p = abs(ch.path);
|
|
60
|
-
|
|
63
|
+
if (plannedPaths.has(p))
|
|
64
|
+
return `Error: ${tag} repeats path ${ch.path}. Combine edits for one file into a single change. Nothing written.`;
|
|
65
|
+
plannedPaths.add(p);
|
|
66
|
+
let type = ch.type ?? (ch.edits ? "update" : "create");
|
|
67
|
+
// Backward-compatible shorthand: {path, content} updates an existing file and creates a missing
|
|
68
|
+
// one. An EXPLICIT type:create is stricter and never clobbers an existing path.
|
|
69
|
+
if (!ch.type && !ch.edits) {
|
|
70
|
+
try {
|
|
71
|
+
await lstat(p);
|
|
72
|
+
type = "update";
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error?.code !== "ENOENT")
|
|
76
|
+
return `Error: ${tag} cannot inspect ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
61
79
|
if (type === "delete") {
|
|
62
80
|
let before;
|
|
63
81
|
try {
|
|
@@ -71,16 +89,15 @@ registerTool({
|
|
|
71
89
|
else if (type === "create") {
|
|
72
90
|
if (typeof ch.content !== "string")
|
|
73
91
|
return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
|
|
74
|
-
let before = "";
|
|
75
|
-
let existed = false;
|
|
76
92
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
await lstat(p);
|
|
94
|
+
return `Error: ${tag} create ${ch.path}: path already exists (use type:update to replace it). Nothing written.`;
|
|
79
95
|
}
|
|
80
|
-
catch {
|
|
81
|
-
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error?.code !== "ENOENT")
|
|
98
|
+
return `Error: ${tag} create ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
|
|
82
99
|
}
|
|
83
|
-
plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed });
|
|
100
|
+
plans.push({ path: ch.path, abs: p, type, before: "", after: ch.content, existed: false });
|
|
84
101
|
}
|
|
85
102
|
else {
|
|
86
103
|
// update
|
|
@@ -108,27 +125,39 @@ registerTool({
|
|
|
108
125
|
try {
|
|
109
126
|
for (const pl of plans) {
|
|
110
127
|
if (pl.type === "delete") {
|
|
128
|
+
let current;
|
|
129
|
+
try {
|
|
130
|
+
current = await readFile(pl.abs, "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new FileChangedError(pl.path);
|
|
134
|
+
}
|
|
135
|
+
if (current !== pl.before)
|
|
136
|
+
throw new FileChangedError(pl.path);
|
|
111
137
|
await unlink(pl.abs);
|
|
112
138
|
}
|
|
113
139
|
else {
|
|
114
|
-
await
|
|
115
|
-
await writeFile(pl.abs, pl.after, "utf8");
|
|
140
|
+
await atomicWriteText(pl.abs, pl.after, { expected: pl.existed ? pl.before : null });
|
|
116
141
|
}
|
|
117
142
|
applied.push(pl);
|
|
118
143
|
}
|
|
119
144
|
}
|
|
120
145
|
catch (e) {
|
|
146
|
+
const rollbackFailures = [];
|
|
121
147
|
for (const pl of applied.reverse()) {
|
|
122
148
|
try {
|
|
123
149
|
if (pl.type === "create" && !pl.existed)
|
|
124
150
|
await unlink(pl.abs); // remove a file we created
|
|
125
151
|
else
|
|
126
|
-
await
|
|
152
|
+
await atomicWriteText(pl.abs, pl.before); // restore an updated/deleted file's prior content
|
|
127
153
|
}
|
|
128
|
-
catch {
|
|
129
|
-
|
|
154
|
+
catch (rollbackError) {
|
|
155
|
+
rollbackFailures.push(`${pl.path}: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
130
156
|
}
|
|
131
157
|
}
|
|
158
|
+
if (rollbackFailures.length) {
|
|
159
|
+
return `Error: apply_patch failed (${e instanceof Error ? e.message : String(e)}); rollback was INCOMPLETE: ${rollbackFailures.join("; ")}. Inspect these files before continuing.`;
|
|
160
|
+
}
|
|
132
161
|
return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
|
|
133
162
|
}
|
|
134
163
|
// All writes succeeded → now show diffs + record the undo snapshot.
|
|
@@ -137,6 +166,7 @@ registerTool({
|
|
|
137
166
|
return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
|
|
138
167
|
});
|
|
139
168
|
recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
|
|
169
|
+
invalidateFileCandidates(ctx.cwd);
|
|
140
170
|
return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
|
|
141
171
|
},
|
|
142
172
|
});
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { limitToolResult } from "./result-limit.js";
|
|
1
2
|
/** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
|
|
2
3
|
* against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
|
|
3
4
|
* path/content mid-stream) — the loop rejects the call with a precise error instead of executing
|
|
@@ -9,8 +10,16 @@ export function missingRequired(tool, input) {
|
|
|
9
10
|
return req.filter((k) => obj[k] === undefined || obj[k] === null);
|
|
10
11
|
}
|
|
11
12
|
const registry = new Map();
|
|
13
|
+
let specsCache = null;
|
|
12
14
|
export function registerTool(t) {
|
|
13
|
-
|
|
15
|
+
const run = t.run;
|
|
16
|
+
registry.set(t.name, {
|
|
17
|
+
...t,
|
|
18
|
+
// Apply the context boundary at registration so every caller (main loop, tests, embedders) gets
|
|
19
|
+
// identical behavior instead of relying on one orchestration path to remember the cap.
|
|
20
|
+
run: async (input, ctx) => limitToolResult(await run(input, ctx)),
|
|
21
|
+
});
|
|
22
|
+
specsCache = null;
|
|
14
23
|
}
|
|
15
24
|
export function getTool(name) {
|
|
16
25
|
return registry.get(name);
|
|
@@ -20,9 +29,14 @@ export function getTools() {
|
|
|
20
29
|
}
|
|
21
30
|
/** Provider-neutral tool specs derived from the registry. */
|
|
22
31
|
export function toolSpecs() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
if (!specsCache) {
|
|
33
|
+
specsCache = getTools().map((t) => ({
|
|
34
|
+
name: t.name,
|
|
35
|
+
description: t.description,
|
|
36
|
+
input_schema: t.input_schema,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
// Callers commonly filter the array for a role. Return a shallow copy so that never mutates the
|
|
40
|
+
// stable cached snapshot shared by subsequent agent rounds.
|
|
41
|
+
return specsCache.slice();
|
|
28
42
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One invariant for every registered tool: no single result may monopolize the model context. Individual
|
|
2
|
+
// tools can use tighter domain-specific limits, but this final boundary also covers plugins/new tools.
|
|
3
|
+
export const MAX_TOOL_RESULT_CHARS = 24_000;
|
|
4
|
+
function safeHead(value, end) {
|
|
5
|
+
let at = Math.max(0, Math.min(value.length, end));
|
|
6
|
+
if (at > 0 && /[\uD800-\uDBFF]/.test(value[at - 1] ?? ""))
|
|
7
|
+
at--;
|
|
8
|
+
return value.slice(0, at);
|
|
9
|
+
}
|
|
10
|
+
function safeTail(value, start) {
|
|
11
|
+
let at = Math.max(0, Math.min(value.length, start));
|
|
12
|
+
if (at < value.length && /[\uDC00-\uDFFF]/.test(value[at] ?? ""))
|
|
13
|
+
at++;
|
|
14
|
+
return value.slice(at);
|
|
15
|
+
}
|
|
16
|
+
/** Keep actionable beginnings and endings while bounding the exact string persisted in history. */
|
|
17
|
+
export function limitToolResult(value, max = MAX_TOOL_RESULT_CHARS) {
|
|
18
|
+
const text = typeof value === "string" ? value : String(value ?? "");
|
|
19
|
+
const cap = Math.max(0, Math.floor(max));
|
|
20
|
+
if (text.length <= cap)
|
|
21
|
+
return text;
|
|
22
|
+
if (cap === 0)
|
|
23
|
+
return "";
|
|
24
|
+
let omitted = text.length - cap;
|
|
25
|
+
let fullMarker = "";
|
|
26
|
+
// The marker consumes part of the budget too. Iterate twice so its count reflects the actual payload
|
|
27
|
+
// removed rather than understating it by roughly the marker's own length.
|
|
28
|
+
for (let i = 0; i < 2; i++) {
|
|
29
|
+
fullMarker = `\n…[hara: ${omitted} chars omitted; narrow the query or continue read_file with offset/limit]…\n`;
|
|
30
|
+
omitted = text.length - Math.max(0, cap - fullMarker.length);
|
|
31
|
+
}
|
|
32
|
+
const marker = fullMarker.length < cap ? fullMarker : "…[truncated]…".slice(0, cap);
|
|
33
|
+
const room = cap - marker.length;
|
|
34
|
+
const headChars = Math.floor(room * 0.6);
|
|
35
|
+
const tailChars = room - headChars;
|
|
36
|
+
return safeHead(text, headChars) + marker + safeTail(text, text.length - tailChars);
|
|
37
|
+
}
|
package/dist/tools/web.js
CHANGED
|
@@ -7,6 +7,8 @@ import { lookup } from "node:dns/promises";
|
|
|
7
7
|
import { isIP } from "node:net";
|
|
8
8
|
import { wrapUntrusted } from "../security/external-content.js";
|
|
9
9
|
const MAX = 60_000;
|
|
10
|
+
const SEARCH_ATTEMPT_MS = 8_000;
|
|
11
|
+
const SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
|
|
10
12
|
/** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
|
|
11
13
|
export function isPrivateIp(ip) {
|
|
12
14
|
const host = ip.replace(/^\[|\]$/g, "");
|
|
@@ -115,6 +117,146 @@ export function parseSearchResults(html, limit) {
|
|
|
115
117
|
}
|
|
116
118
|
return out;
|
|
117
119
|
}
|
|
120
|
+
/** Parse Baidu's server-rendered result headings. Baidu result links are redirects, which is fine:
|
|
121
|
+
* web_fetch follows redirects while re-running its SSRF check on every hop. This gives mainland users a
|
|
122
|
+
* keyless search path that does not depend on an overseas API being reachable. */
|
|
123
|
+
export function parseBaiduSearchResults(html, limit) {
|
|
124
|
+
const strip = (s) => s
|
|
125
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
126
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
127
|
+
.replace(/<[^>]+>/g, " ")
|
|
128
|
+
.replace(/ | /gi, " ")
|
|
129
|
+
.replace(/&/gi, "&")
|
|
130
|
+
.replace(/</gi, "<")
|
|
131
|
+
.replace(/>/gi, ">")
|
|
132
|
+
.replace(/"/gi, '"')
|
|
133
|
+
.replace(/'|'/gi, "'")
|
|
134
|
+
.replace(/\s+/g, " ")
|
|
135
|
+
.trim();
|
|
136
|
+
const headings = [];
|
|
137
|
+
const re = /<h3\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h3>/gi;
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = re.exec(html)) && headings.length < limit) {
|
|
140
|
+
const title = strip(m[3]);
|
|
141
|
+
const url = (m[1] ?? m[2] ?? "").replace(/&/g, "&");
|
|
142
|
+
if (title && /^https?:\/\//i.test(url))
|
|
143
|
+
headings.push({ at: m.index, end: re.lastIndex, title, url });
|
|
144
|
+
}
|
|
145
|
+
return headings.map((h, i) => {
|
|
146
|
+
// The abstract normally sits between this heading and the next result heading. Strip that bounded
|
|
147
|
+
// slice and cap it; if Baidu changes class names the title/link still survive.
|
|
148
|
+
const boundary = headings[i + 1]?.at ?? Math.min(html.length, h.end + 1800);
|
|
149
|
+
const snippet = strip(html.slice(h.end, boundary)).slice(0, 240);
|
|
150
|
+
return { title: h.title, url: h.url, snippet };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** Parse Bing's stable server-rendered result list (`cn.bing.com` is reachable in the mainland network
|
|
154
|
+
* where overseas agent-search APIs commonly fail). */
|
|
155
|
+
export function parseBingSearchResults(html, limit) {
|
|
156
|
+
const strip = (s) => s
|
|
157
|
+
.replace(/<[^>]+>/g, " ")
|
|
158
|
+
.replace(/ | /gi, " ")
|
|
159
|
+
.replace(/ | /gi, " ")
|
|
160
|
+
.replace(/ | /gi, " ")
|
|
161
|
+
.replace(/&/gi, "&")
|
|
162
|
+
.replace(/</gi, "<")
|
|
163
|
+
.replace(/>/gi, ">")
|
|
164
|
+
.replace(/"/gi, '"')
|
|
165
|
+
.replace(/'|'/gi, "'")
|
|
166
|
+
.replace(/\s+/g, " ")
|
|
167
|
+
.trim();
|
|
168
|
+
const out = [];
|
|
169
|
+
const blockRe = /<li\b[^>]*class=(?:"[^"]*\bb_algo\b[^"]*"|'[^']*\bb_algo\b[^']*')[^>]*>([\s\S]*?)<\/li>/gi;
|
|
170
|
+
let block;
|
|
171
|
+
while ((block = blockRe.exec(html)) && out.length < limit) {
|
|
172
|
+
const link = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>/i.exec(block[1]);
|
|
173
|
+
if (!link)
|
|
174
|
+
continue;
|
|
175
|
+
const url = (link[1] ?? link[2] ?? "").replace(/&/g, "&");
|
|
176
|
+
const title = strip(link[3]);
|
|
177
|
+
if (!title || !/^https?:\/\//i.test(url))
|
|
178
|
+
continue;
|
|
179
|
+
const paragraph = /<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(block[1]);
|
|
180
|
+
out.push({ title, url, snippet: strip(paragraph?.[1] ?? "").slice(0, 240) });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** Best-effort parser for Google's classic HTML result shape. Google often serves a redirect/challenge
|
|
185
|
+
* shell in mainland environments, so this is a fallback, not the sole search path. */
|
|
186
|
+
export function parseGoogleSearchResults(html, limit) {
|
|
187
|
+
const strip = (s) => s
|
|
188
|
+
.replace(/<[^>]+>/g, " ")
|
|
189
|
+
.replace(/&/gi, "&")
|
|
190
|
+
.replace(/</gi, "<")
|
|
191
|
+
.replace(/>/gi, ">")
|
|
192
|
+
.replace(/"/gi, '"')
|
|
193
|
+
.replace(/'|'/gi, "'")
|
|
194
|
+
.replace(/\s+/g, " ")
|
|
195
|
+
.trim();
|
|
196
|
+
const out = [];
|
|
197
|
+
const linkRe = /<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>[\s\S]{0,1600}?<h3\b[^>]*>([\s\S]*?)<\/h3>[\s\S]*?<\/a>/gi;
|
|
198
|
+
let m;
|
|
199
|
+
while ((m = linkRe.exec(html)) && out.length < limit) {
|
|
200
|
+
let url = (m[1] ?? m[2] ?? "").replace(/&/g, "&");
|
|
201
|
+
if (url.startsWith("/url?")) {
|
|
202
|
+
try {
|
|
203
|
+
url = new URL(url, "https://www.google.com").searchParams.get("q") ?? "";
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
url = "";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const title = strip(m[3]);
|
|
210
|
+
if (!title || !/^https?:\/\//i.test(url) || /(?:^|\.)google\.[^/]+\//i.test(url))
|
|
211
|
+
continue;
|
|
212
|
+
out.push({ title, url, snippet: "" });
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
/** Detect the common HTML shell returned by SPAs (root element + scripts/loading text, no readable body).
|
|
217
|
+
* web_fetch intentionally does not execute arbitrary page JavaScript, so surfacing the limitation is
|
|
218
|
+
* better than returning a misleading successful "(empty body)". */
|
|
219
|
+
export function looksLikeJsRenderedShell(html, readable) {
|
|
220
|
+
if (readable.trim().length >= 180)
|
|
221
|
+
return false;
|
|
222
|
+
const hasRoot = /<(?:div|main)\b[^>]*(?:id=["'](?:root|app|__next)["']|data-reactroot)/i.test(html);
|
|
223
|
+
const scripts = (html.match(/<script\b/gi) ?? []).length;
|
|
224
|
+
const shellText = /(?:enable javascript|javascript is required|loading[.…]*|正在加载|请启用\s*javascript)/i.test(readable || html);
|
|
225
|
+
return (hasRoot && scripts > 0) || (scripts >= 2 && readable.trim().length < 40) || shellText;
|
|
226
|
+
}
|
|
227
|
+
async function searchFetch(url, init) {
|
|
228
|
+
return fetch(url, { ...init, signal: AbortSignal.timeout(SEARCH_ATTEMPT_MS) });
|
|
229
|
+
}
|
|
230
|
+
async function firstSuccessfulSearch(attempts, failures) {
|
|
231
|
+
if (!attempts.length)
|
|
232
|
+
return null;
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
let remaining = attempts.length;
|
|
235
|
+
let settled = false;
|
|
236
|
+
for (const attempt of attempts) {
|
|
237
|
+
void attempt
|
|
238
|
+
.run()
|
|
239
|
+
.then((results) => {
|
|
240
|
+
if (results.length && !settled) {
|
|
241
|
+
settled = true;
|
|
242
|
+
resolve(results);
|
|
243
|
+
}
|
|
244
|
+
else if (!results.length) {
|
|
245
|
+
failures.push(`${attempt.name} no results`);
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
.catch((e) => {
|
|
249
|
+
const reason = e?.name === "TimeoutError" || e?.name === "AbortError" ? "timeout" : (e?.message ?? String(e));
|
|
250
|
+
failures.push(`${attempt.name} ${reason}`);
|
|
251
|
+
})
|
|
252
|
+
.finally(() => {
|
|
253
|
+
remaining--;
|
|
254
|
+
if (remaining === 0 && !settled)
|
|
255
|
+
resolve(null);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
118
260
|
registerTool({
|
|
119
261
|
name: "web_search",
|
|
120
262
|
description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
|
|
@@ -135,51 +277,90 @@ registerTool({
|
|
|
135
277
|
return "(empty query)";
|
|
136
278
|
const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
|
|
137
279
|
const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
280
|
+
const failures = [];
|
|
281
|
+
// Race the configured agent-search API against a mainland-accessible HTML source. A blocked Tavily
|
|
282
|
+
// endpoint no longer adds an 8-second penalty before Hara starts the domestic fallback.
|
|
283
|
+
const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
|
|
284
|
+
const primaryAttempts = [];
|
|
285
|
+
if (key) {
|
|
286
|
+
primaryAttempts.push({
|
|
287
|
+
name: "Tavily",
|
|
288
|
+
run: async () => {
|
|
289
|
+
const res = await searchFetch("https://api.tavily.com/search", {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "content-type": "application/json" },
|
|
292
|
+
body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
|
|
293
|
+
});
|
|
294
|
+
if (!res.ok)
|
|
295
|
+
throw new Error(`HTTP ${res.status}`);
|
|
151
296
|
const j = (await res.json());
|
|
152
|
-
|
|
153
|
-
if (rs.length)
|
|
154
|
-
return wrapUntrusted(fmt(rs), `web_search: ${q}`);
|
|
155
|
-
}
|
|
156
|
-
// Tavily failed → fall through to the keyless best-effort path.
|
|
157
|
-
}
|
|
158
|
-
// Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
|
|
159
|
-
const res = await fetch("https://html.duckduckgo.com/html/", {
|
|
160
|
-
method: "POST",
|
|
161
|
-
signal: ctrl.signal,
|
|
162
|
-
redirect: "follow",
|
|
163
|
-
headers: {
|
|
164
|
-
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
165
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
166
|
-
accept: "text/html",
|
|
297
|
+
return (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
|
|
167
298
|
},
|
|
168
|
-
body: `q=${encodeURIComponent(q)}`,
|
|
169
299
|
});
|
|
170
|
-
if (!res.ok)
|
|
171
|
-
return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
|
|
172
|
-
const results = parseSearchResults(await res.text(), limit);
|
|
173
|
-
if (!results.length)
|
|
174
|
-
return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
|
|
175
|
-
return wrapUntrusted(fmt(results), `web_search: ${q}`);
|
|
176
|
-
}
|
|
177
|
-
catch (e) {
|
|
178
|
-
return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
|
|
179
|
-
}
|
|
180
|
-
finally {
|
|
181
|
-
clearTimeout(timer);
|
|
182
300
|
}
|
|
301
|
+
primaryAttempts.push({
|
|
302
|
+
name: "Bing CN",
|
|
303
|
+
run: async () => {
|
|
304
|
+
const res = await searchFetch(`https://cn.bing.com/search?q=${encodeURIComponent(q)}&count=${limit}&setlang=zh-Hans`, {
|
|
305
|
+
method: "GET",
|
|
306
|
+
redirect: "follow",
|
|
307
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
308
|
+
});
|
|
309
|
+
if (!res.ok)
|
|
310
|
+
throw new Error(`HTTP ${res.status}`);
|
|
311
|
+
return parseBingSearchResults(await res.text(), limit);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const primary = await firstSuccessfulSearch(primaryAttempts, failures);
|
|
315
|
+
if (primary)
|
|
316
|
+
return wrapUntrusted(fmt(primary), `web_search: ${q}`);
|
|
317
|
+
// Secondary sources run concurrently, keeping total failure latency bounded. Google is included as a
|
|
318
|
+
// user-friendly fallback where it is reachable, but is never the sole path (mainland often gets a shell).
|
|
319
|
+
const secondary = await firstSuccessfulSearch([
|
|
320
|
+
{
|
|
321
|
+
name: "Baidu",
|
|
322
|
+
run: async () => {
|
|
323
|
+
const res = await searchFetch(`https://www.baidu.com/s?wd=${encodeURIComponent(q)}&rn=${limit}`, {
|
|
324
|
+
method: "GET",
|
|
325
|
+
redirect: "follow",
|
|
326
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
327
|
+
});
|
|
328
|
+
if (!res.ok)
|
|
329
|
+
throw new Error(`HTTP ${res.status}`);
|
|
330
|
+
return parseBaiduSearchResults(await res.text(), limit);
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: "Google",
|
|
335
|
+
run: async () => {
|
|
336
|
+
const res = await searchFetch(`https://www.google.com/search?q=${encodeURIComponent(q)}&num=${limit}&hl=zh-CN`, {
|
|
337
|
+
method: "GET",
|
|
338
|
+
redirect: "follow",
|
|
339
|
+
headers: { "user-agent": SEARCH_UA, accept: "text/html" },
|
|
340
|
+
});
|
|
341
|
+
if (!res.ok)
|
|
342
|
+
throw new Error(`HTTP ${res.status}`);
|
|
343
|
+
return parseGoogleSearchResults(await res.text(), limit);
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "DuckDuckGo",
|
|
348
|
+
run: async () => {
|
|
349
|
+
const res = await searchFetch("https://html.duckduckgo.com/html/", {
|
|
350
|
+
method: "POST",
|
|
351
|
+
redirect: "follow",
|
|
352
|
+
headers: { "user-agent": SEARCH_UA, "content-type": "application/x-www-form-urlencoded", accept: "text/html" },
|
|
353
|
+
body: `q=${encodeURIComponent(q)}`,
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok)
|
|
356
|
+
throw new Error(`HTTP ${res.status}`);
|
|
357
|
+
return parseSearchResults(await res.text(), limit);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
], failures);
|
|
361
|
+
if (secondary)
|
|
362
|
+
return wrapUntrusted(fmt(secondary), `web_search: ${q}`);
|
|
363
|
+
return `Search failed across available providers (${failures.join("; ")}). Check connectivity/proxy, configure HARA_SEARCH_API_KEY, or web_fetch a known URL.`;
|
|
183
364
|
},
|
|
184
365
|
});
|
|
185
366
|
registerTool({
|
|
@@ -232,6 +413,11 @@ registerTool({
|
|
|
232
413
|
let text = /html/i.test(ct) ? htmlToText(raw) : raw;
|
|
233
414
|
if (text.length > cap)
|
|
234
415
|
text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
|
|
416
|
+
if (/html/i.test(ct) && looksLikeJsRenderedShell(raw, text)) {
|
|
417
|
+
const hint = "This page appears to be JavaScript-rendered; web_fetch received only the SPA shell and does not execute page scripts. " +
|
|
418
|
+
"Use an available browser/web skill for the rendered page, or the site's authenticated API/connector (for example the Feishu Docs API).";
|
|
419
|
+
return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(`${text || "(empty shell)"}\n\n${hint}`, current.href)}`;
|
|
420
|
+
}
|
|
235
421
|
return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
|
|
236
422
|
}
|
|
237
423
|
catch (e) {
|
package/dist/tui/App.js
CHANGED
|
@@ -516,7 +516,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
516
516
|
const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
|
|
517
517
|
// ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
|
|
518
518
|
// when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
|
|
519
|
-
const OTHER = "
|
|
519
|
+
const OTHER = "\\0__ask_other__"; // escaped sentinel keeps this TypeScript file text-only
|
|
520
520
|
const askFn = async (question, options) => {
|
|
521
521
|
if (options && options.length) {
|
|
522
522
|
const choice = await openPrompt(question, [
|