@hncode/hncode 0.5.4 → 0.5.6

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/src/tools/read.js CHANGED
@@ -72,7 +72,17 @@ export const spec = {
72
72
  // staleness hashes recorded below because they were computed from the
73
73
  // TRUNCATED text rather than the file on disk.
74
74
  const text = buf.toString('utf8');
75
- const lines = normalizeText(text).split('\n');
75
+ // A FILE ENDING IN A NEWLINE HAS ONE FEWER LINE THAN `split` REPORTS.
76
+ //
77
+ // `"a\nb\n".split("\n")` is `["a", "b", ""]` — the trailing newline leaves an empty element, and
78
+ // counting it made a ten-line file report `lines 1-11 of 11`. The numbers on each line were right;
79
+ // the TOTAL was one too high, so a caller planning an edit at the end of the file aimed at a row that
80
+ // does not exist.
81
+ //
82
+ // Only the element the trailing newline produced is dropped: a file that genuinely ends with a blank
83
+ // line (`"a\n\n"`) keeps it, because that is real content.
84
+ const lines = normalizeText(text).split("\n");
85
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
76
86
  const total = lines.length;
77
87
  // Default: every line. An explicit n_lines still narrows the window.
78
88
  const nLines = args.n_lines ? args.n_lines : total;
@@ -1,88 +1,139 @@
1
- // WebSearch tool — search the web through puri.li's JSON API.
2
- //
3
- // WHY NOT A SCRAPED SEARCH PAGE
4
- // -----------------------------
5
- // This used to fetch Bing's HTML and parse `<li class="b_algo">` blocks. That worked until it
6
- // silently stopped: the request still returned 200 with the query echoed in `<title>`, but the
7
- // page contained no results — the two or three `b_algo` blocks it did have were
8
- // `translate.google.cn` and similar chrome, unrelated to the query. Measured over five identical
9
- // requests for one query: five different first results (Yandex, GitHub, a Q&A site, PixelOS, Nature),
10
- // none of them related. Adding a market parameter, warming up cookies, and sending a full set of
11
- // browser headers all changed nothing.
12
- //
13
- // The failure mode is what made this urgent. A scraper that returns junk does not look broken to
14
- // the model — the results arrive in the normal shape, so they get used and cited. A JSON API that
15
- // answers with `results: []` can at least be reported honestly.
16
-
17
- const ENDPOINT = 'https://puri.li/api/search';
18
- const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
19
-
20
- /**
21
- * The API's payload, as a list of results.
22
- *
23
- * Tolerant on purpose: this is a third-party service with no published schema, so a missing or
24
- * renamed field must degrade to "fewer results" rather than throw. `description` is the snippet;
25
- * an entry without a URL is dropped, because a result the model cannot open is not usable.
26
- */
27
- export function parseSearchResults(json, limit = 8) {
28
- const list = json && Array.isArray(json.results) ? json.results : [];
29
- const out = [];
30
- for (const r of list) {
31
- if (!r || typeof r.url !== 'string' || !r.url) continue;
32
- out.push({
33
- title: String(r.title || r.url).trim(),
34
- url: r.url,
35
- snippet: String(r.description || r.snippet || '').replace(/\s+/g, ' ').trim(),
36
- });
37
- if (out.length >= limit) break;
38
- }
39
- return out;
40
- }
41
-
42
- export const spec = {
43
- name: 'WebSearch',
44
- description: 'Search the web. Results are title/URL/snippet; snippets are summaries, so fetch a promising URL with FetchURL. Cite sources.',
45
- parameters: {
46
- type: 'object',
47
- properties: {
48
- query: { type: 'string', description: 'Search query.' },
49
- page: { type: 'integer', description: 'Page number, 1-based. Defaults to 1.' },
50
- },
51
- required: ['query'],
52
- },
53
- async execute(args, ctx) {
54
- const query = String(args.query || '').trim();
55
- if (!query) return 'Error: query must not be empty.';
56
- const page = Math.max(1, Math.trunc(Number(args.page)) || 1);
57
- const signal = (ctx && ctx.signal) || undefined;
58
- const url = `${ENDPOINT}?q=${encodeURIComponent(query)}&page=${page}`;
59
- try {
60
- const res = await fetch(url, {
61
- headers: { 'User-Agent': UA, Accept: 'application/json' },
62
- signal,
63
- });
64
- if (!res.ok) return `Error: search failed (${res.status} ${res.statusText}).`;
65
- // The endpoint answers with JSON. If it ever does not, say so rather than parsing a page.
66
- const text = await res.text();
67
- let json;
68
- try { json = JSON.parse(text); }
69
- catch { return `Error: search returned a non-JSON response (${text.length} bytes) for "${query}".`; }
70
-
71
- const results = parseSearchResults(json, 8);
72
- if (!results.length) {
73
- // A genuine empty result set, NOT an error. The old scraper could not tell the two apart,
74
- // which is why a broken page looked like a successful search with odd results.
75
- return page > 1
76
- ? `No search results found for: ${query} (page ${page}).`
77
- : `No search results found for: ${query}`;
78
- }
79
- const more = json && json.hasNext ? `\n\nMore results available: call WebSearch again with page=${page + 1}.` : '';
80
- return results
81
- .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`)
82
- .join('\n\n') + more;
83
- } catch (e) {
84
- const msg = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
85
- return `Error searching "${query}": ${msg}`;
86
- }
87
- },
88
- };
1
+ // WebSearch tool — Tavily's keyless search endpoint.
2
+ //
3
+ // WHY NOT THE PREVIOUS BACKEND
4
+ // ----------------------------
5
+ // puri.li/api/search answered, but it is a small crawler index, not a search engine: every hit reported
6
+ // `source: "crawled"`. Measured, `GitHub Actions` returned 677 documents and answered well, while
7
+ // `doutula 表情包` returned 6 — none about doutula — because that site is not in the index at all. A query
8
+ // with no real match still returned a full page of the documents that merely shared a word with it, and
9
+ // those were directory farms and unrelated pages.
10
+ //
11
+ // Tavily's keyless mode answers the same query with fabiaoqing.com, github.com/sunimp/doutula and
12
+ // huaban.com — pages that are actually about the site. That is the difference worth the change.
13
+ //
14
+ // WHY NOT A SCRAPED SEARCH PAGE
15
+ // -----------------------------
16
+ // This started as a Bing HTML scraper. It worked until the page still returned 200 with the query echoed
17
+ // in `<title>` but contained no results — the two or three `b_algo` blocks it did have were
18
+ // `translate.google.cn` and similar chrome. A scraper that returns junk does not look broken to the model:
19
+ // the results arrive in the normal shape, so they get used and cited.
20
+
21
+ const ENDPOINT = 'https://api.tavily.com/search';
22
+ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
23
+
24
+ /**
25
+ * How much of a result's text is worth showing.
26
+ *
27
+ * The API returns the page's opening text — often several hundred characters of navigation and boilerplate
28
+ * before anything useful. Eight results of that size spend a few thousand tokens of context on text the
29
+ * model reads once and discards. A couple of sentences are enough to judge whether the page is about the
30
+ * query, and that judgement is the only thing the snippet is for.
31
+ */
32
+ const SNIPPET_MAX = 200;
33
+
34
+ /**
35
+ * A result's text, shortened and stripped of decoding damage.
36
+ *
37
+ * "MOJIBAKE" is upstream: a page served as UTF-8 and decoded as Latin-1 comes back with its smart quotes
38
+ * as `鈥` and its Chinese as `鍦熻眴`. That is the crawler's bug, not this tool's, but the damage ends up in
39
+ * the model's context, where it looks like the page is about something else. Any snippet carrying this
40
+ * much of it is dropped rather than shown, because a result whose text cannot be read is not a result.
41
+ */
42
+ function cleanSnippet(raw) {
43
+ const text = String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
44
+ // A run of two or more of these characters is the signature of a UTF-8 page read as Latin-1. One could
45
+ // be a legitimate transliteration; a run cannot.
46
+ const damaged = (text.match(/[\u00c0-\u00ff]{2,}/g) || []).length;
47
+ if (damaged >= 3) return '';
48
+ return text.length > SNIPPET_MAX ? text.slice(0, SNIPPET_MAX - 1).trimEnd() + '…' : text;
49
+ }
50
+
51
+ /**
52
+ * The API's payload, as a list of results.
53
+ *
54
+ * Tolerant on purpose: this is a third-party service with no published schema, so a missing or renamed
55
+ * field must degrade to "fewer results" rather than throw. An entry without a URL is dropped, because a
56
+ * result the model cannot open is not usable.
57
+ */
58
+ export function parseSearchResults(json, limit = 8) {
59
+ const list = json && Array.isArray(json.results) ? json.results : [];
60
+ const out = [];
61
+ for (const r of list) {
62
+ if (!r || typeof r.url !== 'string' || !r.url) continue;
63
+ const snippet = cleanSnippet(r.content || r.description || r.snippet || '');
64
+ // A result whose text cannot be read is not a result: the model has nothing to judge it by.
65
+ if (!snippet && r.content) continue;
66
+ out.push({
67
+ title: cleanSnippet(String(r.title || r.url)) || r.url,
68
+ url: r.url,
69
+ snippet,
70
+ });
71
+ if (out.length >= limit) break;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export const spec = {
77
+ name: 'WebSearch',
78
+ description: [
79
+ 'Search the web. Results are title/URL/snippet; snippets are summaries, so fetch a promising URL with',
80
+ 'FetchURL. Cite sources.',
81
+ '',
82
+ 'Use FEW words: two or three that name the thing. Adding generic terms ("下载", "热门", "官网",',
83
+ '"tutorial") does not narrow the search — it matches more unrelated pages, because those are the words',
84
+ 'junk pages stuff into their titles.',
85
+ ].join('\n'),
86
+ parameters: {
87
+ type: 'object',
88
+ properties: {
89
+ query: { type: 'string', description: 'Search query. Two or three words that name the thing.' },
90
+ },
91
+ required: ['query'],
92
+ },
93
+ async execute(args, ctx) {
94
+ const query = String(args.query || '').trim();
95
+ if (!query) return 'Error: query must not be empty.';
96
+ const signal = (ctx && ctx.signal) || undefined;
97
+ try {
98
+ // POST with a JSON body — the keyless mode is not offered on a GET. `X-Tavily-Access-Mode: keyless`
99
+ // is what selects it; without the header the request is rejected for a missing API key.
100
+ const res = await fetch(ENDPOINT, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'X-Tavily-Access-Mode': 'keyless',
105
+ 'User-Agent': UA,
106
+ Accept: 'application/json',
107
+ },
108
+ body: JSON.stringify({ query, max_results: 8 }),
109
+ signal,
110
+ });
111
+ if (!res.ok) {
112
+ const body = await res.text().catch(() => '');
113
+ return `Error: search failed (${res.status} ${res.statusText}).${body ? ' ' + body.slice(0, 200) : ''}`;
114
+ }
115
+ // The endpoint answers with JSON. If it ever does not, say so rather than parsing a page.
116
+ const text = await res.text();
117
+ let json;
118
+ try { json = JSON.parse(text); }
119
+ catch { return `Error: search returned a non-JSON response (${text.length} bytes) for "${query}".`; }
120
+
121
+ const results = parseSearchResults(json, 8);
122
+ if (!results.length) {
123
+ // A genuine empty result set, NOT an error. A broken page must not look like a successful search.
124
+ return `No search results found for: ${query}`;
125
+ }
126
+ // The API also returns a written `answer` for some queries. It is its own summary, so it is labelled
127
+ // as such — the model should cite the sources, not this paragraph.
128
+ const answer = typeof json.answer === 'string' && json.answer.trim()
129
+ ? `Summary (from the search API, not a source):\n${json.answer.trim()}\n\n`
130
+ : '';
131
+ return answer + results
132
+ .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`)
133
+ .join('\n\n');
134
+ } catch (e) {
135
+ const msg = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
136
+ return `Error searching "${query}": ${msg}`;
137
+ }
138
+ },
139
+ };