@hncode/hncode 0.6.2 → 0.6.4

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,139 +1,205 @@
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
- };
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
+
98
+ // SEVERAL KEYS, TRIED IN ORDER.
99
+ //
100
+ // One key has one monthly quota, and when it runs out the tool has nothing left to say but "search is
101
+ // broken". A list makes the quota additive: the second key is simply tried when the first is refused, and
102
+ // the answer is the same either way. Empty entries are skipped so a trailing newline in the editor is not
103
+ // an "empty key" attempt.
104
+ const keys = parseKeys(ctx && (ctx.webSearchApiKey || (ctx.cfg && ctx.cfg.webSearchApiKey)));
105
+
106
+ // Keyless is the fallback only when NO key is configured at all — it is rate-limited per IP, so it is worth
107
+ // trying exactly when there is no account to spend instead.
108
+ const attempts = keys.length ? keys.map((k) => ({ key: k })) : [{ key: '' }];
109
+
110
+ let lastFailure = '';
111
+ for (let i = 0; i < attempts.length; i++) {
112
+ const { key } = attempts[i];
113
+ try {
114
+ const headers = {
115
+ 'Content-Type': 'application/json',
116
+ 'User-Agent': UA,
117
+ Accept: 'application/json',
118
+ };
119
+ if (key) headers.Authorization = `Bearer ${key}`;
120
+ else headers['X-Tavily-Access-Mode'] = 'keyless';
121
+ const res = await fetch(ENDPOINT, {
122
+ method: 'POST',
123
+ headers,
124
+ // `search_depth: 'basic'` COSTS ONE CREDIT; the default is `advanced`, which costs two per request.
125
+ // A snippet is all this tool shows, so the deeper crawl buys nothing — it was doubling the price of
126
+ // every search for content that gets truncated to a sentence or two anyway.
127
+ body: JSON.stringify({ query, max_results: 8, search_depth: 'basic' }),
128
+ signal,
129
+ });
130
+ if (!res.ok) {
131
+ const body = await res.text().catch(() => '');
132
+ lastFailure = `${res.status} ${res.statusText}${body ? ' ' + body.slice(0, 160) : ''}`;
133
+ // Move to the next key. A 4xx from THIS key is the reason to try another — 429 is the monthly quota
134
+ // and 401 is a typo — while a 5xx is the service, and the next key would hit the same wall. Only
135
+ // stop early when there IS no next key.
136
+ if (i + 1 < attempts.length) continue;
137
+ break;
138
+ }
139
+ return await renderResults(res, query);
140
+ } catch (e) {
141
+ if (e && e.name === 'AbortError') return `Error searching "${query}": interrupted`;
142
+ lastFailure = e.message || String(e);
143
+ if (i + 1 < attempts.length) continue;
144
+ break;
145
+ }
146
+ }
147
+
148
+ // Every key failed, or the only key did. Say which mode was in play: in keyless that is the IP limit and
149
+ // the fix is a key, with a key it is the key.
150
+ const mode = keys.length
151
+ ? `${keys.length} configured API key${keys.length === 1 ? '' : 's'}`
152
+ : 'keyless mode (rate-limited per IP — set web_search_api_key to lift it)';
153
+ return `Error: search failed with ${mode}: ${lastFailure}`;
154
+ },
155
+ };
156
+
157
+ /**
158
+ * The keys in a configured value, one per line.
159
+ *
160
+ * `web_search_api_key` holds a LIST: one key per line, because that is what a multi-line editor gives and what
161
+ * a user pasting several keys already has. A single-line value is a list of one, so an existing config keeps
162
+ * working unchanged.
163
+ *
164
+ * @param {string|string[]} raw
165
+ * @returns {string[]}
166
+ */
167
+ export function parseKeys(raw) {
168
+ if (Array.isArray(raw)) return raw.map((x) => String(x).trim()).filter(Boolean);
169
+ return String(raw == null ? '' : raw)
170
+ .split(/\r?\n/)
171
+ .map((x) => x.trim())
172
+ .filter(Boolean);
173
+ }
174
+
175
+ /**
176
+ * Turn a successful response into the text the model reads.
177
+ *
178
+ * @param {Response} res
179
+ * @param {string} query
180
+ * @returns {Promise<string>}
181
+ */
182
+ async function renderResults(res, query) {
183
+ // The endpoint answers with JSON. If it ever does not, say so rather than parsing a page.
184
+ const text = await res.text();
185
+ let json;
186
+ try { json = JSON.parse(text); }
187
+ catch { return `Error: search returned a non-JSON response (${text.length} bytes) for "${query}".`; }
188
+
189
+ const results = parseSearchResults(json, 8);
190
+ if (!results.length) {
191
+ // A genuine empty result set, NOT an error. A broken page must not look like a successful search.
192
+ return `No search results found for: ${query}`;
193
+ }
194
+ // The API also returns a written `answer` for some queries. It is its own summary, so it is labelled as
195
+ // such — the model should cite the sources, not this paragraph.
196
+ const answer = typeof json.answer === 'string' && json.answer.trim()
197
+ ? `Summary (from the search API, not a source):\n${json.answer.trim()}\n\n`
198
+ : '';
199
+ // NO CREDIT COUNT. The docs show a `usage.credits` field in their example response, but the live endpoint
200
+ // does not send one — measured on both `basic` and `advanced` depth, `json.usage` is undefined.
201
+ return answer + results
202
+ .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`)
203
+ .join('\n\n');
204
+ }
205
+