@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/package.json +38 -38
- package/src/config.js +10 -3
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/tools/edit.js +88 -161
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/read.js +11 -1
- package/src/tools/web-search.js +139 -88
- package/src/tui.js +339 -145
- package/src/web.js +47 -1
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
|
-
|
|
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;
|
package/src/tools/web-search.js
CHANGED
|
@@ -1,88 +1,139 @@
|
|
|
1
|
-
// WebSearch tool —
|
|
2
|
-
//
|
|
3
|
-
// WHY NOT
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
};
|