@hncode/hncode 0.5.2 → 0.5.5

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.
Files changed (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
@@ -1,141 +1,214 @@
1
- // TaskList / TaskOutput / TaskStop / TaskWait — manage background tasks spawned
2
- // by Bash (run_in_background) and Agent (run_in_background). The task records
3
- // live in the shared registry (src/agent-task.js), so the CLI tools and the
4
- // /tasks panel read exactly the same data.
5
-
6
- import cp from 'node:child_process';
7
- import { isTerminal, settleTask, STATUS_LABEL } from '../agent-task.js';
8
-
9
- function taskLine(t) {
10
- const extra = t.stopReason ? ` (${t.stopReason})` : '';
11
- const label = STATUS_LABEL[t.status] || t.status;
12
- const what = t.kind === 'process' ? (t.description || t.command) : t.description;
13
- return `#${t.taskId} [${label}${extra}] pid=${t.pid || '-'} ${what}`;
14
- }
15
-
16
- export const TaskListSpec = {
17
- name: 'TaskList',
18
- description: 'List background tasks (from Bash or Agent run_in_background). Reports task id, status, description.',
19
- parameters: { type: 'object', properties: {}, required: [] },
20
- async execute(_args, ctx) {
21
- const tasks = Object.values(ctx.tasks || {}).sort((a, b) => b.startedAt - a.startedAt);
22
- if (tasks.length === 0) return 'No background tasks.';
23
- return tasks.map(taskLine).join('\n');
24
- },
25
- };
26
-
27
- export const TaskOutputSpec = {
28
- name: 'TaskOutput',
29
- description: 'Get the output of a background task by task_id. head_limit caps the lines returned.',
30
- parameters: {
31
- type: 'object',
32
- properties: {
33
- task_id: { type: 'string', description: 'Task id returned when the task was started.' },
34
- head_limit: { type: 'integer', minimum: 0, description: 'Max output lines returned.' },
35
- },
36
- required: ['task_id'],
37
- },
38
- async execute(args, ctx) {
39
- const t = (ctx.tasks || {})[args.task_id];
40
- if (!t) return `No such task: ${args.task_id}`;
41
- const text = t.output || '';
42
- const lines = text ? text.split('\n').filter((l) => l.length > 0) : [];
43
- const capped = args.head_limit ? lines.slice(0, args.head_limit) : lines;
44
- const tail = lines.length > capped.length;
45
- const label = STATUS_LABEL[t.status] || t.status;
46
- const head = `#${t.taskId} [${label}] (pid ${t.pid || '-'})`;
47
- let out = capped.length ? `${head}\n${capped.join('\n')}` : `${head}\n(no output yet)`;
48
- if (tail) out += `\n... [${lines.length - capped.length} more lines; use head_limit]`;
49
- return out;
50
- },
51
- };
52
-
53
- export const TaskStopSpec = {
54
- name: 'TaskStop',
55
- description: 'Stop a running background task by task_id. Use only when the task must be cancelled.',
56
- parameters: {
57
- type: 'object',
58
- properties: {
59
- task_id: { type: 'string', description: 'Background task id to stop.' },
60
- reason: { type: 'string', description: 'Short reason recorded on stop.' },
61
- },
62
- required: ['task_id'],
63
- },
64
- async execute(args, ctx) {
65
- const t = (ctx.tasks || {})[args.task_id];
66
- if (!t) return `No such task: ${args.task_id}`;
67
- if (isTerminal(t.status)) return `Task ${t.taskId} already ${STATUS_LABEL[t.status] || t.status}.`;
68
- const reason = args.reason || 'Stopped by TaskStop';
69
- try {
70
- // An agent task aborts through its controller; a process is killed.
71
- if (t.kind === 'agent' && typeof t._abort === 'function') {
72
- t._abort();
73
- } else if (t.pid) {
74
- if (process.platform === 'win32') {
75
- try { cp.spawnSync('taskkill', ['/pid', String(t.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
76
- } else {
77
- try { process.kill(t.pid, 'SIGTERM'); } catch {}
78
- }
79
- }
80
- } catch (e) {
81
- return `Failed to stop ${t.taskId}: ${e.message}`;
82
- }
83
- // 'killed' — a deliberate stop, distinct from a failure (kimi's vocabulary).
84
- settleTask(t, 'killed', { stopReason: reason });
85
- return `Stopped task ${t.taskId}. reason: ${reason}`;
86
- },
87
- };
88
-
89
- // Wait for background tasks to finish. A timeout is not an error; the result
90
- // lists the tasks still running so the caller can wait again.
91
- export const TaskWaitSpec = {
92
- name: 'TaskWait',
93
- description: 'Wait for background tasks to finish, timeout in seconds (1-600). With task_id, waits for that task; without it, returns when any running task finishes. A timeout is not an error; still-running tasks are listed, so you may call it again.',
94
- parameters: {
95
- type: 'object',
96
- properties: {
97
- timeout: { type: 'integer', minimum: 1, maximum: 600, description: 'Max seconds to wait (1-600).' },
98
- task_id: { type: 'string', description: 'Task id to wait for; omit to wait for any running task.' },
99
- },
100
- required: ['timeout'],
101
- },
102
- async execute(args, ctx) {
103
- const tasks = ctx.tasks || {};
104
- const target = args.task_id ? tasks[args.task_id] : null;
105
- if (args.task_id && !target) return `No such task: ${args.task_id}`;
106
- const secs = Math.max(1, Math.min(600, Number(args.timeout) || 1));
107
- const deadline = Date.now() + secs * 1000;
108
- const signal = ctx && ctx.signal;
109
-
110
- const isDone = (t) => t && isTerminal(t.status);
111
- const anyRunning = () => Object.values(tasks).some((t) => t.status === 'running');
112
-
113
- if (args.task_id) {
114
- if (isDone(target)) return `Task ${target.taskId} already ${STATUS_LABEL[target.status] || target.status}${target.exitCode != null ? ` (exit ${target.exitCode})` : ''}.`;
115
- } else if (!anyRunning()) {
116
- return 'No background tasks are running.';
117
- }
118
-
119
- await new Promise((resolve) => {
120
- const tick = setInterval(() => {
121
- const done = args.task_id ? isDone(target) : !anyRunning();
122
- if (done || Date.now() >= deadline || (signal && signal.aborted)) {
123
- clearInterval(tick);
124
- resolve();
125
- }
126
- }, 100);
127
- });
128
-
129
- if (signal && signal.aborted) return 'Interrupted by user';
130
- const still = Object.values(tasks).filter((t) => t.status === 'running');
131
- if (args.task_id) {
132
- if (isDone(target)) {
133
- return `Task ${target.taskId} finished: ${STATUS_LABEL[target.status] || target.status}${target.exitCode != null ? ` (exit ${target.exitCode})` : ''}${target.stopReason ? ` — ${target.stopReason}` : ''}.`;
134
- }
135
- return `Task ${target.taskId} still running after ${secs}s.`;
136
- }
137
- if (!still.length) return 'All background tasks have finished.';
138
- const list = still.map((t) => `#${t.taskId} ${t.description || t.command || ''}`).join('; ');
139
- return `Timeout after ${secs}s; still running: ${list}`;
140
- },
141
- };
1
+ // TaskList / TaskOutput / TaskStop / TaskWait — manage background tasks spawned
2
+ // by Bash (run_in_background) and Agent (run_in_background). The task records
3
+ // live in the shared registry (src/agent-task.js), so the CLI tools and the
4
+ // /tasks panel read exactly the same data.
5
+
6
+ import cp from 'node:child_process';
7
+ import { isTerminal, settleTask, STATUS_LABEL } from '../agent-task.js';
8
+
9
+ function taskLine(t) {
10
+ const extra = t.stopReason ? ` (${t.stopReason})` : '';
11
+ const label = STATUS_LABEL[t.status] || t.status;
12
+ const what = t.kind === 'process' ? (t.description || t.command) : t.description;
13
+ return `#${t.taskId} [${label}${extra}] pid=${t.pid || '-'} ${what}`;
14
+ }
15
+
16
+ export const TaskListSpec = {
17
+ name: 'TaskList',
18
+ description: 'List background tasks (from Bash or Agent run_in_background). Reports task id, status, description.',
19
+ parameters: { type: 'object', properties: {}, required: [] },
20
+ async execute(_args, ctx) {
21
+ const tasks = Object.values(ctx.tasks || {}).sort((a, b) => b.startedAt - a.startedAt);
22
+ if (tasks.length === 0) return 'No background tasks.';
23
+ return tasks.map(taskLine).join('\n');
24
+ },
25
+ };
26
+
27
+ export const TaskOutputSpec = {
28
+ name: 'TaskOutput',
29
+ description: 'Get the output of a background task by task_id. head_limit caps the lines returned.',
30
+ parameters: {
31
+ type: 'object',
32
+ properties: {
33
+ task_id: { type: 'string', description: 'Task id returned when the task was started.' },
34
+ head_limit: { type: 'integer', minimum: 0, description: 'Max output lines returned.' },
35
+ },
36
+ required: ['task_id'],
37
+ },
38
+ async execute(args, ctx) {
39
+ const t = (ctx.tasks || {})[args.task_id];
40
+ if (!t) return `No such task: ${args.task_id}`;
41
+ const text = t.output || '';
42
+ const lines = text ? text.split('\n').filter((l) => l.length > 0) : [];
43
+ const capped = args.head_limit ? lines.slice(0, args.head_limit) : lines;
44
+ const tail = lines.length > capped.length;
45
+ const label = STATUS_LABEL[t.status] || t.status;
46
+ const head = `#${t.taskId} [${label}] (pid ${t.pid || '-'})`;
47
+ let out = capped.length ? `${head}\n${capped.join('\n')}` : `${head}\n(no output yet)`;
48
+ if (tail) out += `\n... [${lines.length - capped.length} more lines; use head_limit]`;
49
+ return out;
50
+ },
51
+ };
52
+
53
+ export const TaskStopSpec = {
54
+ name: 'TaskStop',
55
+ description: 'Stop a running background task by task_id. Use only when the task must be cancelled.',
56
+ parameters: {
57
+ type: 'object',
58
+ properties: {
59
+ task_id: { type: 'string', description: 'Background task id to stop.' },
60
+ reason: { type: 'string', description: 'Short reason recorded on stop.' },
61
+ },
62
+ required: ['task_id'],
63
+ },
64
+ async execute(args, ctx) {
65
+ const t = (ctx.tasks || {})[args.task_id];
66
+ if (!t) return `No such task: ${args.task_id}`;
67
+ if (isTerminal(t.status)) return `Task ${t.taskId} already ${STATUS_LABEL[t.status] || t.status}.`;
68
+ const reason = args.reason || 'Stopped by TaskStop';
69
+ try {
70
+ // An agent OR teammate task aborts through its controller; a process is killed.
71
+ // Keyed on the handle, not the kind — a teammate task carries `_abort` too
72
+ // (see spawnTeammate), and gating on kind==='agent' left teammates unstoppable.
73
+ if (typeof t._abort === 'function') {
74
+ t._abort();
75
+ } else if (t.pid) {
76
+ if (process.platform === 'win32') {
77
+ try { cp.spawnSync('taskkill', ['/pid', String(t.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
78
+ } else {
79
+ try { process.kill(t.pid, 'SIGTERM'); } catch {}
80
+ }
81
+ }
82
+ } catch (e) {
83
+ return `Failed to stop ${t.taskId}: ${e.message}`;
84
+ }
85
+ // 'killed' — a deliberate stop, distinct from a failure (kimi's vocabulary).
86
+ settleTask(t, 'killed', { stopReason: reason });
87
+ return `Stopped task ${t.taskId}. reason: ${reason}`;
88
+ },
89
+ };
90
+
91
+ /**
92
+ * Write to a background task started with `Bash {interactive: true}`.
93
+ *
94
+ * WHY THIS EXISTS
95
+ * ---------------
96
+ * A background command's stdin is /dev/null by default — deliberately, because leaving it as an open pipe
97
+ * made `cat` and `npm init` block until the timeout. That also meant a command that WANTS input could never
98
+ * be given any: a REPL, a prompt-driven generator, or a server reading commands had no way in.
99
+ *
100
+ * `interactive` opts ONE run into a real pipe and this tool is the other end of it. Both are needed: the
101
+ * flag without the tool leaves a pipe nobody can write to, and the tool without the flag has no pipe to
102
+ * find.
103
+ *
104
+ * ENTER is a parameter rather than something baked into `text`, because a prompt-driven program
105
+ * distinguishes "line entered" from "line still being typed", and a caller sending three lines then a bare
106
+ * Enter should not have to remember to write a trailing newline.
107
+ */
108
+ export const TaskInputSpec = {
109
+ name: 'TaskInput',
110
+ description: [
111
+ 'Send text to the stdin of a background task started with `Bash {interactive: true}`.',
112
+ '',
113
+ '`text` is written verbatim. `enter` (default true) appends a newline, which is what a prompt-driven',
114
+ 'program needs to accept the line; pass `enter: false` to type without submitting.',
115
+ '',
116
+ 'The task must still be running: a finished one has no stdin, and this says so rather than pretending',
117
+ 'the write landed. Read the reply with TaskOutput.',
118
+ ].join('\n'),
119
+ parameters: {
120
+ type: 'object',
121
+ properties: {
122
+ task_id: { type: 'string', description: 'Background task id (from Bash with interactive: true).' },
123
+ text: { type: 'string', description: 'Text to write, verbatim.' },
124
+ enter: { type: 'boolean', default: true, description: 'Append a newline so the program accepts the line. False types without submitting.' },
125
+ },
126
+ required: ['task_id'],
127
+ },
128
+ async execute(args, ctx) {
129
+ const t = (ctx.tasks || {})[args.task_id];
130
+ if (!t) return `No such task: ${args.task_id}`;
131
+ if (isTerminal(t.status)) {
132
+ return `Task ${t.taskId} is ${STATUS_LABEL[t.status] || t.status} — it has no stdin to write to.`;
133
+ }
134
+ if (!t.stdin) {
135
+ // The two ways to have no pipe, told apart: the task was never interactive, or its process closed
136
+ // stdin by itself. Naming which one saves a round of guessing.
137
+ return t.interactive
138
+ ? `Task ${t.taskId} closed its stdin — the process is no longer reading input.`
139
+ : `Task ${t.taskId} was not started with \`interactive: true\`, so it has no stdin. Restart it with that flag.`;
140
+ }
141
+
142
+ const text = args.text == null ? '' : String(args.text);
143
+ const payload = args.enter === false ? text : `${text}\n`;
144
+ if (!payload) return 'Nothing to write.';
145
+
146
+ try {
147
+ // Resolve on the WRITE CALLBACK, not on the return value: `write` returns false when the buffer is
148
+ // full, and the callback fires once the bytes are actually out. Awaiting it is the difference
149
+ // between "we called write" and "the write landed".
150
+ await new Promise((resolve, reject) => {
151
+ t.stdin.write(payload, (err) => (err ? reject(err) : resolve()));
152
+ });
153
+ } catch (e) {
154
+ // EPIPE: the process exited between the status check and the write. Not a failure of the tool.
155
+ t.stdin = null;
156
+ return `Task ${t.taskId} stopped reading input: ${e.message}`;
157
+ }
158
+
159
+ return `Wrote ${payload.length} character(s) to ${t.taskId}. Read the reply with TaskOutput.`;
160
+ },
161
+ };
162
+ // Wait for background tasks to finish. A timeout is not an error; the result
163
+ // lists the tasks still running so the caller can wait again.
164
+ export const TaskWaitSpec = {
165
+ name: 'TaskWait',
166
+ description: 'Wait for background tasks to finish, timeout in seconds (1-600). With task_id, waits for that task; without it, returns when any running task finishes. A timeout is not an error; still-running tasks are listed, so you may call it again.',
167
+ parameters: {
168
+ type: 'object',
169
+ properties: {
170
+ timeout: { type: 'integer', minimum: 1, maximum: 600, description: 'Max seconds to wait (1-600).' },
171
+ task_id: { type: 'string', description: 'Task id to wait for; omit to wait for any running task.' },
172
+ },
173
+ required: ['timeout'],
174
+ },
175
+ async execute(args, ctx) {
176
+ const tasks = ctx.tasks || {};
177
+ const target = args.task_id ? tasks[args.task_id] : null;
178
+ if (args.task_id && !target) return `No such task: ${args.task_id}`;
179
+ const secs = Math.max(1, Math.min(600, Number(args.timeout) || 1));
180
+ const deadline = Date.now() + secs * 1000;
181
+ const signal = ctx && ctx.signal;
182
+
183
+ const isDone = (t) => t && isTerminal(t.status);
184
+ const anyRunning = () => Object.values(tasks).some((t) => t.status === 'running');
185
+
186
+ if (args.task_id) {
187
+ if (isDone(target)) return `Task ${target.taskId} already ${STATUS_LABEL[target.status] || target.status}${target.exitCode != null ? ` (exit ${target.exitCode})` : ''}.`;
188
+ } else if (!anyRunning()) {
189
+ return 'No background tasks are running.';
190
+ }
191
+
192
+ await new Promise((resolve) => {
193
+ const tick = setInterval(() => {
194
+ const done = args.task_id ? isDone(target) : !anyRunning();
195
+ if (done || Date.now() >= deadline || (signal && signal.aborted)) {
196
+ clearInterval(tick);
197
+ resolve();
198
+ }
199
+ }, 100);
200
+ });
201
+
202
+ if (signal && signal.aborted) return 'Interrupted by user';
203
+ const still = Object.values(tasks).filter((t) => t.status === 'running');
204
+ if (args.task_id) {
205
+ if (isDone(target)) {
206
+ return `Task ${target.taskId} finished: ${STATUS_LABEL[target.status] || target.status}${target.exitCode != null ? ` (exit ${target.exitCode})` : ''}${target.stopReason ? ` — ${target.stopReason}` : ''}.`;
207
+ }
208
+ return `Task ${target.taskId} still running after ${secs}s.`;
209
+ }
210
+ if (!still.length) return 'All background tasks have finished.';
211
+ const list = still.map((t) => `#${t.taskId} ${t.description || t.command || ''}`).join('; ');
212
+ return `Timeout after ${secs}s; still running: ${list}`;
213
+ },
214
+ };
package/src/tools/team.js CHANGED
@@ -226,6 +226,19 @@ export const SendMessageSpec = {
226
226
  // reaches the person, and it is why the box exists.
227
227
  if (args.to === TEAM_LEAD_NAME) {
228
228
  writeToMailbox(teamName, TEAM_LEAD_NAME, { from: me.name, text, summary: args.summary, color });
229
+ // Tell the running TUI at once, so the message appears in the user's transcript as it is sent
230
+ // rather than waiting for their next turn.
231
+ //
232
+ // Without this the reply was only ever written to a file: `formatLeadInbox` runs at the START of
233
+ // a turn, so a member's answer sat unread until the person happened to type something else. From
234
+ // the user's side that is indistinguishable from the team being dead — they send an instruction,
235
+ // nothing appears, and the answer is already on disk.
236
+ //
237
+ // The callback reaches here through `ctx`, which the Agent builds by spreading the teammate's
238
+ // config, so the spawner only has to put it there.
239
+ if (typeof ctx?.onLeadMail === 'function') {
240
+ try { ctx.onLeadMail({ from: me.name, text, summary: args.summary, color }); } catch { /* never fail a send over a display hook */ }
241
+ }
229
242
  return { content: 'Sent to the lead (the user).' };
230
243
  }
231
244
 
@@ -31,8 +31,13 @@ export function truncateBuf(buf) {
31
31
  export function normalizeInput(p) {
32
32
  let s = String(p);
33
33
  if (s.startsWith('~')) s = path.join(os.homedir(), s.slice(1));
34
- const m = s.match(/^\/([a-zA-Z])\/(.*)$/);
35
- if (m) return path.join(`${m[1].toUpperCase()}:\\`, ...m[2].split('/'));
34
+ // Windows only: under Git Bash an agent writes `/d/hncode/src` for `D:\hncode\src`.
35
+ // Gated on the platform because on Linux `/c/x` is a perfectly ordinary absolute
36
+ // path, and rewriting it to `C:\x` turned it into a relative garbage filename.
37
+ if (process.platform === 'win32') {
38
+ const m = s.match(/^\/([a-zA-Z])\/(.*)$/);
39
+ if (m) return path.join(`${m[1].toUpperCase()}:\\`, ...m[2].split('/'));
40
+ }
36
41
  return s;
37
42
  }
38
43
 
@@ -44,7 +49,11 @@ export function resolvePath(input, ctx) {
44
49
  if (!ctx.allowExternal) {
45
50
  let rel = path.relative(ws, p);
46
51
  if (rel === '') return p;
47
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
52
+ // ESCAPE check, not a prefix check: the first path segment must not be `..`.
53
+ // A plain startsWith('..') also rejected files INSIDE the workspace whose
54
+ // names merely begin with dots — `..notes.md` — while `...` / `..a` are
55
+ // legitimate relative names that stay below the root.
56
+ if (rel.split(path.sep)[0] === '..' || path.isAbsolute(rel)) {
48
57
  throw new Error(`Path outside workspace: ${input}. Run hncode from that directory or set HNCODE_ALLOW_EXTERNAL=1.`);
49
58
  }
50
59
  }
@@ -1,38 +1,72 @@
1
- // WebSearch tool — search the web through puri.li's JSON API.
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.
2
13
  //
3
14
  // WHY NOT A SCRAPED SEARCH PAGE
4
15
  // -----------------------------
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, 知乎, 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
+ // 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.
16
20
 
17
- const ENDPOINT = 'https://puri.li/api/search';
21
+ const ENDPOINT = 'https://api.tavily.com/search';
18
22
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
19
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
+
20
51
  /**
21
52
  * The API's payload, as a list of results.
22
53
  *
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.
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.
26
57
  */
27
58
  export function parseSearchResults(json, limit = 8) {
28
59
  const list = json && Array.isArray(json.results) ? json.results : [];
29
60
  const out = [];
30
61
  for (const r of list) {
31
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;
32
66
  out.push({
33
- title: String(r.title || r.url).trim(),
67
+ title: cleanSnippet(String(r.title || r.url)) || r.url,
34
68
  url: r.url,
35
- snippet: String(r.description || r.snippet || '').replace(/\s+/g, ' ').trim(),
69
+ snippet,
36
70
  });
37
71
  if (out.length >= limit) break;
38
72
  }
@@ -41,27 +75,43 @@ export function parseSearchResults(json, limit = 8) {
41
75
 
42
76
  export const spec = {
43
77
  name: 'WebSearch',
44
- description: 'Search the web. Results are title/URL/snippet; snippets are summaries, so fetch a promising URL with FetchURL. Cite sources.',
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'),
45
86
  parameters: {
46
87
  type: 'object',
47
88
  properties: {
48
- query: { type: 'string', description: 'Search query.' },
49
- page: { type: 'integer', description: 'Page number, 1-based. Defaults to 1.' },
89
+ query: { type: 'string', description: 'Search query. Two or three words that name the thing.' },
50
90
  },
51
91
  required: ['query'],
52
92
  },
53
93
  async execute(args, ctx) {
54
94
  const query = String(args.query || '').trim();
55
95
  if (!query) return 'Error: query must not be empty.';
56
- const page = Math.max(1, Math.trunc(Number(args.page)) || 1);
57
96
  const signal = (ctx && ctx.signal) || undefined;
58
- const url = `${ENDPOINT}?q=${encodeURIComponent(query)}&page=${page}`;
59
97
  try {
60
- const res = await fetch(url, {
61
- headers: { 'User-Agent': UA, Accept: 'application/json' },
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 }),
62
109
  signal,
63
110
  });
64
- if (!res.ok) return `Error: search failed (${res.status} ${res.statusText}).`;
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
+ }
65
115
  // The endpoint answers with JSON. If it ever does not, say so rather than parsing a page.
66
116
  const text = await res.text();
67
117
  let json;
@@ -70,16 +120,17 @@ export const spec = {
70
120
 
71
121
  const results = parseSearchResults(json, 8);
72
122
  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}`;
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}`;
78
125
  }
79
- const more = json && json.hasNext ? `\n\nMore results available: call WebSearch again with page=${page + 1}.` : '';
80
- return results
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
81
132
  .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`)
82
- .join('\n\n') + more;
133
+ .join('\n\n');
83
134
  } catch (e) {
84
135
  const msg = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
85
136
  return `Error searching "${query}": ${msg}`;