@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.
@@ -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 { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
4
- import { isAbsolute, resolve, dirname } from "node:path";
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
- const type = ch.type ?? (ch.edits ? "update" : "create");
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
- before = await readFile(p, "utf8");
78
- existed = true;
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
- /* new file */
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 mkdir(dirname(pl.abs), { recursive: true });
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 writeFile(pl.abs, pl.before, "utf8"); // restore an updated/deleted file's prior content
152
+ await atomicWriteText(pl.abs, pl.before); // restore an updated/deleted file's prior content
127
153
  }
128
- catch {
129
- /* best-effort rollback */
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
  });
@@ -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
- registry.set(t.name, t);
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
- return getTools().map((t) => ({
24
- name: t.name,
25
- description: t.description,
26
- input_schema: t.input_schema,
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(/&nbsp;|&#160;/gi, " ")
129
+ .replace(/&amp;/gi, "&")
130
+ .replace(/&lt;/gi, "<")
131
+ .replace(/&gt;/gi, ">")
132
+ .replace(/&quot;/gi, '"')
133
+ .replace(/&#39;|&#x27;/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(/&amp;/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(/&nbsp;|&#160;/gi, " ")
159
+ .replace(/&ensp;|&#8194;/gi, " ")
160
+ .replace(/&emsp;|&#8195;/gi, " ")
161
+ .replace(/&amp;/gi, "&")
162
+ .replace(/&lt;/gi, "<")
163
+ .replace(/&gt;/gi, ">")
164
+ .replace(/&quot;/gi, '"')
165
+ .replace(/&#39;|&#x27;/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(/&amp;/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(/&amp;/gi, "&")
190
+ .replace(/&lt;/gi, "<")
191
+ .replace(/&gt;/gi, ">")
192
+ .replace(/&quot;/gi, '"')
193
+ .replace(/&#39;|&#x27;/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(/&amp;/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 ctrl = new AbortController();
139
- const timer = setTimeout(() => ctrl.abort(), 20_000);
140
- try {
141
- // Reliable path: Tavily (designed for agents, free tier) when a key is configured.
142
- const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
143
- if (key) {
144
- const res = await fetch("https://api.tavily.com/search", {
145
- method: "POST",
146
- signal: ctrl.signal,
147
- headers: { "content-type": "application/json" },
148
- body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
149
- });
150
- if (res.ok) {
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
- const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
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 = "__ask_other__"; // sentinel value for the "type my own" option
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, [