@hncode/hncode 0.6.2 → 0.6.3
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/package.json +37 -37
- package/src/agent.js +35 -4
- package/src/config.js +19 -10
- package/src/row-cache.js +31 -2
- package/src/settings-schema.js +17 -2
- package/src/team/team.js +7 -4
- package/src/tools/ask-user-question.js +18 -3
- package/src/tools/edit.js +44 -74
- package/src/tools/web-search.js +205 -139
- package/src/tui.js +849 -108
package/src/tools/web-search.js
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
|