@hncode/hncode 0.5.0 → 0.5.2

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 (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
package/src/tools/git.js CHANGED
@@ -71,11 +71,17 @@ Use this instead of writing \`git\` shell pipelines. Destructive resets are not
71
71
  a.push('--stat');
72
72
  // paths after --
73
73
  const paths = Array.isArray(args.paths) ? args.paths.filter(Boolean) : [];
74
- const statR = git.runGit(a.concat(paths.length ? ['--', ...paths] : []), cwd);
74
+ // ASYNC, with the turn's signal: a `git diff` on a large repository is slow enough that a
75
+ // synchronous call would freeze the TUI — and with the event loop held, Esc could not even
76
+ // be read, let alone interrupt it.
77
+ const statR = await git.runGitAsync(a.concat(paths.length ? ['--', ...paths] : []), cwd, { signal: ctx && ctx.signal });
75
78
  if (!statR.ok) return `Error: ${statR.stderr.trim() || statR.error}`;
76
79
  if (!statR.stdout.trim()) return args.staged ? 'No staged changes.' : 'No changes.';
77
80
  // Also include the full diff, bounded so a huge change cannot flood context.
78
- const full = git.runGit(['diff'].concat(args.staged ? ['--cached'] : []).concat(paths.length ? ['--', ...paths] : []), cwd);
81
+ const full = await git.runGitAsync(
82
+ ['diff'].concat(args.staged ? ['--cached'] : []).concat(paths.length ? ['--', ...paths] : []),
83
+ cwd, { signal: ctx && ctx.signal },
84
+ );
79
85
  const MAX = 60 * 1024;
80
86
  let body = full.stdout || '';
81
87
  let note = '';
@@ -115,7 +121,13 @@ Use this instead of writing \`git\` shell pipelines. Destructive resets are not
115
121
 
116
122
  case 'log': {
117
123
  const n = Math.max(1, Math.min(200, Number(args.count) || 10));
118
- const r = git.runGit(['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short'], cwd);
124
+ // Async with the signal: `git log` is the slowest call here on a large history, and it is
125
+ // exactly the one a user presses Esc on.
126
+ const r = await git.runGitAsync(
127
+ ['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short'],
128
+ cwd, { signal: ctx && ctx.signal },
129
+ );
130
+ if (r.error === 'interrupted') return 'Interrupted by user.';
119
131
  return r.ok ? (r.stdout.trim() || 'No commits yet.') : `Error: ${r.stderr.trim() || r.error}`;
120
132
  }
121
133
 
@@ -18,6 +18,7 @@ import { spec as agentSpec } from './agent.js';
18
18
  import { spec as agentSwarmSpec } from './agent-swarm.js';
19
19
  import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec } from './tasks.js';
20
20
  import { AgentListSpec, AgentMessageSpec, AgentInterruptSpec, AgentCloseSpec } from './agent-control.js';
21
+ import { TEAM_TOOL_SPECS } from './team.js';
21
22
  import { spec as notebookSpec } from './notebook.js';
22
23
  import { spec as diagnosticsSpec } from './diagnostics.js';
23
24
  import * as memoryMod from './memory.js';
@@ -53,6 +54,7 @@ const builtinTools = [
53
54
  notebookSpec,
54
55
  diagnosticsSpec,
55
56
  memoryMod.spec,
57
+ ...TEAM_TOOL_SPECS,
56
58
  ];
57
59
 
58
60
 
@@ -0,0 +1,398 @@
1
+ // The tools a model uses to work with a team.
2
+ //
3
+ // TeamCreate start a team (the caller becomes its lead)
4
+ // TeamDelete dissolve it
5
+ // SendMessage talk to a member, or to everyone
6
+ // TaskCreate put work on the shared list
7
+ // TaskUpdate change a task's state, and claim or release it
8
+ // TaskList read the list
9
+ //
10
+ // Each one is a THIN wrapper: the rules live in team.js / tasks.js / mailbox.js, so a command,
11
+ // a tool and a test all take the same path. A tool that reimplemented "can I claim this" would
12
+ // be a second place for that rule to be wrong.
13
+ import { createTeam, disbandTeam, addMember, removeTeammate, formatRoster, findMember, leadOf } from '../team/team.js';
14
+ import { readTeamFile, formatAgentId } from '../team/store.js';
15
+ import { writeToMailbox, readMailbox, formatInbox } from '../team/mailbox.js';
16
+ import { createTask, updateTask, readTasks, claimTask, releaseTask, formatTasks, TASK_STATES } from '../team/tasks.js';
17
+ import { currentAgent } from '../team/context.js';
18
+ import { leadTeam as sharedLeadTeam, setLeadTeam, clearLeadTeam as sharedClearLeadTeam } from '../session-core.js';
19
+ import { TEAM_LEAD_NAME, BROADCAST } from '../team/constants.js';
20
+
21
+ // The lead's team lives in session-core.js, because the `/team` command needs it too and a
22
+ // second copy would let the keyboard and the model disagree about which team is active.
23
+
24
+ /** The team the lead is on, or null. Read by the roster view and by the team command. */
25
+ export function leadTeam() { return sharedLeadTeam(); }
26
+
27
+ /** Forget the lead's team. Called when it is dissolved. */
28
+ export function clearLeadTeam() { return sharedClearLeadTeam(); }
29
+
30
+ /**
31
+ * Who is calling, and which team they are on.
32
+ *
33
+ * A teammate's team comes from its AsyncLocalStorage identity; the lead passes the team name
34
+ * explicitly because it has no identity of its own — `currentAgent()` is undefined for the
35
+ * person at the keyboard.
36
+ */
37
+ function caller(args) {
38
+ const me = currentAgent();
39
+ if (me) return { name: me.name, teamName: me.teamName, isTeammate: true };
40
+ return { name: TEAM_LEAD_NAME, teamName: args.team_name || sharedLeadTeam(), isTeammate: false };
41
+ }
42
+
43
+ function requireTeam(teamName) {
44
+ if (!teamName) return 'Error: no team. Pass `team_name`, or create one with TeamCreate.';
45
+ const team = readTeamFile(teamName);
46
+ if (!team) return `Error: no team "${teamName}". Use TeamCreate first.`;
47
+ return null;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // TeamCreate
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export const TeamCreateSpec = {
55
+ name: 'TeamCreate',
56
+ get description() {
57
+ return [
58
+ 'Create a team and become its lead.',
59
+ '',
60
+ 'A team is a named roster of agents that can message each other and share one task list.',
61
+ 'Use it when the work divides into parts that need to talk — an agent that only needs to',
62
+ 'report back does not need a team; the Agent tool is cheaper.',
63
+ '',
64
+ 'After creating one, add members with TeamCreate\'s `members` list, then put work on the',
65
+ 'shared list with TaskCreate.',
66
+ ].join('\n');
67
+ },
68
+ parameters: {
69
+ type: 'object',
70
+ properties: {
71
+ team_name: { type: 'string', description: 'A short name for the team, e.g. "auth-refactor".' },
72
+ description: { type: 'string', description: 'What the team is for. Shown to every member.' },
73
+ members: {
74
+ type: 'array',
75
+ description: 'Members to start with. Each gets its own agent, running in the background.',
76
+ items: {
77
+ type: 'object',
78
+ properties: {
79
+ name: { type: 'string', description: 'A short name, used to address this member.' },
80
+ agent_type: { type: 'string', description: 'Its role, e.g. "coder", "tester".' },
81
+ prompt: { type: 'string', description: 'What it should start doing. Omit to have it ask.' },
82
+ model: { type: 'string', description: 'Optional model override.' },
83
+ },
84
+ required: ['name'],
85
+ },
86
+ },
87
+ },
88
+ required: ['team_name'],
89
+ },
90
+ async execute(args, ctx) {
91
+ const me = caller(args);
92
+ if (me.isTeammate) {
93
+ return { error: 'Error: a teammate cannot create a team. Only the session\'s own agent can.' };
94
+ }
95
+ const r = createTeam({
96
+ teamName: args.team_name,
97
+ description: args.description,
98
+ leadSessionId: (ctx && ctx.session && ctx.session.id) || '',
99
+ cwd: (ctx && ctx.cwd) || process.cwd(),
100
+ });
101
+ if (!r.ok) return { error: `Error: ${r.error}` };
102
+ // Remember it, so every later tool call from the lead knows where it belongs.
103
+ setLeadTeam(r.team.name);
104
+
105
+ const added = [];
106
+ const refused = [];
107
+ for (const m of (Array.isArray(args.members) ? args.members : [])) {
108
+ const a = addMember(args.team_name, {
109
+ name: m.name,
110
+ agentType: m.agent_type || 'general',
111
+ model: m.model || '',
112
+ prompt: m.prompt || '',
113
+ cwd: (ctx && ctx.cwd) || process.cwd(),
114
+ });
115
+ if (a.ok) added.push({ ...a.member, prompt: m.prompt || '' });
116
+ else refused.push(`${m.name}: ${a.error}`);
117
+ }
118
+
119
+ // Spawning is the caller's job: this module has no business starting agents, and the TUI
120
+ // owns the event loop they need. It hands back what to spawn.
121
+ if (ctx && typeof ctx.spawnTeammates === 'function' && added.length) {
122
+ try { await ctx.spawnTeammates(args.team_name, added); } catch { /* reported below */ }
123
+ }
124
+
125
+ const lines = [
126
+ `Team "${r.team.name}" created. You are "${TEAM_LEAD_NAME}".`,
127
+ r.team.description ? `Purpose: ${r.team.description}` : '',
128
+ '',
129
+ formatRoster(readTeamFile(r.team.name)),
130
+ ].filter(Boolean);
131
+ if (refused.length) lines.push('', 'Not added:', ...refused.map((x) => ` ${x}`));
132
+ if (added.length && !(ctx && ctx.spawnTeammates)) {
133
+ lines.push('', 'Note: these members are on the roster but no agent is running for them yet.');
134
+ }
135
+ return { content: lines.join('\n') };
136
+ },
137
+ };
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // TeamDelete
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export const TeamDeleteSpec = {
144
+ name: 'TeamDelete',
145
+ description: 'Dissolve a team: tell its members, then remove the roster, the inboxes and the shared task list.',
146
+ parameters: {
147
+ type: 'object',
148
+ properties: {
149
+ team_name: { type: 'string', description: 'The team to dissolve. Defaults to the caller\'s own team.' },
150
+ reason: { type: 'string', description: 'Sent to each member before the team goes.' },
151
+ },
152
+ },
153
+ async execute(args) {
154
+ const me = caller(args);
155
+ const teamName = me.teamName || args.team_name;
156
+ const err = requireTeam(teamName);
157
+ if (err) return { error: err };
158
+ const r = disbandTeam(teamName, { reason: args.reason || 'the lead ended the team' });
159
+ if (!r.ok) return { error: `Error: ${r.error}` };
160
+ if (sharedLeadTeam() === teamName) sharedClearLeadTeam();
161
+ return { content: `Team "${teamName}" dissolved.${r.told ? ` ${r.told} member(s) were told.` : ''}` };
162
+ },
163
+ };
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // SendMessage
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export const SendMessageSpec = {
170
+ name: 'SendMessage',
171
+ get description() {
172
+ return [
173
+ 'Send a message to a teammate, or to everyone on the team.',
174
+ '',
175
+ '`to` is a member name, or "*" to reach every member except yourself.',
176
+ '',
177
+ 'Messages are delivered to the recipient\'s inbox and read on its next step — so this is',
178
+ 'asynchronous: it does not wait for a reply. Say what you need and move on; the answer',
179
+ 'arrives as a message of its own.',
180
+ '',
181
+ 'Use it to hand over work, report a result, or ask a question only that member can answer.',
182
+ 'Do not use it to narrate progress — a message that says nothing costs the recipient a turn.',
183
+ ].join('\n');
184
+ },
185
+ parameters: {
186
+ type: 'object',
187
+ properties: {
188
+ to: { type: 'string', description: 'A member name, or "*" to message the whole team.' },
189
+ message: { type: 'string', description: 'The text to send.' },
190
+ summary: { type: 'string', description: 'Five to ten words for the recipient\'s list view.' },
191
+ },
192
+ required: ['to', 'message'],
193
+ },
194
+ async execute(args, ctx) {
195
+ const me = caller(args);
196
+ const teamName = me.teamName || args.team_name;
197
+ const err = requireTeam(teamName);
198
+ if (err) return { error: err };
199
+
200
+ const team = readTeamFile(teamName);
201
+ const text = String(args.message == null ? '' : args.message).trim();
202
+ if (!text) return { error: 'Error: `message` is empty.' };
203
+
204
+ const color = (findMember(teamName, me.name) || {}).color;
205
+
206
+ if (args.to === BROADCAST) {
207
+ // Everyone but the sender. The lead is included when a MEMBER broadcasts — "everyone else"
208
+ // means the lead too, and leaving it out was why a member's broadcast reached no one.
209
+ const others = team.members.filter((m) => m.name !== me.name);
210
+ if (!others.length) return { error: 'Error: there is nobody else on the team to broadcast to.' };
211
+ for (const m of others) {
212
+ writeToMailbox(teamName, m.name, { from: me.name, text, summary: args.summary, color });
213
+ }
214
+ return { content: `Broadcast to ${others.length} member(s): ${others.map((m) => m.name).join(', ')}` };
215
+ }
216
+
217
+ // `team-lead` is the USER, and it is a real inbox — not a roster entry.
218
+ //
219
+ // The user is not a member: their session sits outside the roster, and it reads the boxes
220
+ // named `leadOf(team)` AND `TEAM_LEAD_NAME` (see `readLeadInbox`). Their own sends carry the
221
+ // `team-lead` alias, so a teammate answering one addresses `to: 'team-lead'`.
222
+ //
223
+ // Resolving that to `leadOf(team)` was wrong: for a team whose only agent IS the lead, the
224
+ // alias resolved back to the sender, and `SendMessage` refused with "that is you" — so the
225
+ // report it had just written was never delivered. Writing to the alias directly is what
226
+ // reaches the person, and it is why the box exists.
227
+ if (args.to === TEAM_LEAD_NAME) {
228
+ writeToMailbox(teamName, TEAM_LEAD_NAME, { from: me.name, text, summary: args.summary, color });
229
+ return { content: 'Sent to the lead (the user).' };
230
+ }
231
+
232
+ const target = team.members.find((m) => m.name === args.to);
233
+ if (!target) {
234
+ // Naming the candidates turns a dead end into a fix.
235
+ return { error: `Error: no member "${args.to}" on team "${teamName}". Members: ${team.members.map((m) => m.name).join(', ')}` };
236
+ }
237
+ if (target.name === me.name) return { error: 'Error: that is you. Pick another member, or "*".' };
238
+
239
+ writeToMailbox(teamName, target.name, { from: me.name, text, summary: args.summary, color });
240
+ return { content: `Sent to ${target.name}.` };
241
+ },
242
+ };
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // TaskCreate / TaskUpdate / TaskList
246
+ // ---------------------------------------------------------------------------
247
+
248
+ export const TeamTaskCreateSpec = {
249
+ name: 'TeamTaskCreate',
250
+ description: 'Add a task to the team\'s shared list. Every member sees the same list, so this is how work is divided.',
251
+ parameters: {
252
+ type: 'object',
253
+ properties: {
254
+ subject: { type: 'string', description: 'One line: what needs doing.' },
255
+ description: { type: 'string', description: 'Detail a member needs to do it without asking.' },
256
+ owner: { type: 'string', description: 'A member name to assign it to. Omit to leave it for whoever is free.' },
257
+ blocked_by: {
258
+ type: 'array',
259
+ description: 'Task ids that must finish first, e.g. ["#1"].',
260
+ items: { type: 'string' },
261
+ },
262
+ },
263
+ required: ['subject'],
264
+ },
265
+ async execute(args, ctx) {
266
+ const me = caller(args);
267
+ const teamName = me.teamName || args.team_name;
268
+ const err = requireTeam(teamName);
269
+ if (err) return { error: err };
270
+
271
+ if (args.owner) {
272
+ const team = readTeamFile(teamName);
273
+ // The LEAD is addressable by the alias the user's own messages carry, so an owner named
274
+ // `team-lead` resolves to the agent that actually holds the role.
275
+ const owner = args.owner === TEAM_LEAD_NAME ? leadOf(teamName) : args.owner;
276
+ if (!team.members.some((m) => m.name === owner)) {
277
+ return { error: `Error: no member "${args.owner}". Members: ${team.members.map((m) => m.name).join(', ')}` };
278
+ }
279
+ }
280
+ const t = createTask(teamName, {
281
+ subject: args.subject,
282
+ description: args.description,
283
+ owner: args.owner || null,
284
+ blockedBy: args.blocked_by,
285
+ createdBy: me.name,
286
+ });
287
+ if (args.owner) updateTask(teamName, t.id, { status: 'in_progress' });
288
+ return { content: `Created ${t.id}: ${t.subject}${args.owner ? ` (assigned to ${args.owner})` : ''}` };
289
+ },
290
+ };
291
+
292
+ export const TeamTaskUpdateSpec = {
293
+ name: 'TeamTaskUpdate',
294
+ get description() {
295
+ return [
296
+ 'Change a task: claim it, release it, or set its state.',
297
+ '',
298
+ `status is one of: ${TASK_STATES.join(', ')}.`,
299
+ '',
300
+ 'Claiming checks the task is not taken and not blocked, and says which when it refuses —',
301
+ 'so a refusal tells you what to do next rather than just failing.',
302
+ ].join('\n');
303
+ },
304
+ parameters: {
305
+ type: 'object',
306
+ properties: {
307
+ task_id: { type: 'string', description: 'The id, e.g. "#3".' },
308
+ status: { type: 'string', enum: TASK_STATES, description: 'The new state.' },
309
+ claim: { type: 'boolean', description: 'Take ownership. Refused if someone else has it, or it is blocked.' },
310
+ release: { type: 'boolean', description: 'Give it back, so another member can take it.' },
311
+ owner: { type: 'string', description: 'Assign it to a member by name.' },
312
+ result: { type: 'string', description: 'What came of it. Worth setting when completing.' },
313
+ },
314
+ required: ['task_id'],
315
+ },
316
+ async execute(args, ctx) {
317
+ const me = caller(args);
318
+ const teamName = me.teamName || args.team_name;
319
+ const err = requireTeam(teamName);
320
+ if (err) return { error: err };
321
+ const id = String(args.task_id);
322
+
323
+ if (args.claim) {
324
+ const r = claimTask(teamName, id, me.name);
325
+ if (!r.ok) return { error: `Error: cannot claim ${id} — ${r.reason}.` };
326
+ return { content: `Claimed ${id}: ${r.task.subject}` };
327
+ }
328
+ if (args.release) {
329
+ const r = releaseTask(teamName, id, me.name);
330
+ if (!r.ok) return { error: `Error: ${r.reason}.` };
331
+ return { content: `Released ${id}.` };
332
+ }
333
+
334
+ const patch = {};
335
+ if (args.status) patch.status = args.status;
336
+ if (args.owner) patch.owner = args.owner;
337
+ if (args.result !== undefined) patch.result = String(args.result);
338
+ if (!Object.keys(patch).length) return { error: 'Error: nothing to change. Pass status, claim, release, owner or result.' };
339
+
340
+ // Completing without saying who did it leaves the list unable to answer "who finished this".
341
+ if (patch.status === 'completed' && !patch.result && !me.isTeammate) {
342
+ patch.result = '';
343
+ }
344
+ const t = updateTask(teamName, id, patch);
345
+ if (!t) return { error: `Error: no task ${id} on team "${teamName}".` };
346
+ return { content: `Updated ${id}: ${t.status}${t.owner ? ` @${t.owner}` : ''} — ${t.subject}` };
347
+ },
348
+ };
349
+
350
+ export const TeamTaskListSpec = {
351
+ name: 'TeamTaskList',
352
+ description: 'Read the team\'s shared task list: what is unclaimed, what is in flight, what is blocked.',
353
+ parameters: {
354
+ type: 'object',
355
+ properties: {
356
+ owner: { type: 'string', description: 'Only tasks owned by this member.' },
357
+ status: { type: 'string', enum: TASK_STATES, description: 'Only tasks in this state.' },
358
+ },
359
+ },
360
+ async execute(args) {
361
+ const me = caller(args);
362
+ const teamName = me.teamName || args.team_name;
363
+ const err = requireTeam(teamName);
364
+ if (err) return { error: err };
365
+ let list = readTasks(teamName);
366
+ if (args.owner) list = list.filter((t) => t.owner === args.owner);
367
+ if (args.status) list = list.filter((t) => t.status === args.status);
368
+ return { content: formatTasks(list) };
369
+ },
370
+ };
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // ListTeammates — so a member can find out who exists without being told
374
+ // ---------------------------------------------------------------------------
375
+
376
+ export const ListTeammatesSpec = {
377
+ name: 'ListTeammates',
378
+ description: 'The team roster: who is on it, what they are doing, and how much unread mail each has.',
379
+ parameters: { type: 'object', properties: {} },
380
+ async execute(args) {
381
+ const me = caller(args);
382
+ const teamName = me.teamName || args.team_name;
383
+ const err = requireTeam(teamName);
384
+ if (err) return { error: err };
385
+ return { content: formatRoster(readTeamFile(teamName)) };
386
+ },
387
+ };
388
+
389
+ /** Every team tool, for the catalog. */
390
+ export const TEAM_TOOL_SPECS = [
391
+ TeamCreateSpec,
392
+ TeamDeleteSpec,
393
+ SendMessageSpec,
394
+ TeamTaskCreateSpec,
395
+ TeamTaskUpdateSpec,
396
+ TeamTaskListSpec,
397
+ ListTeammatesSpec,
398
+ ];
@@ -1,75 +1,88 @@
1
- // WebSearch tool — search the web via Bing's HTML endpoint (no API key needed).
2
-
3
- import { htmlToText } from './fetch-url.js';
4
-
5
- const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
6
-
7
- function decodeEntities(s) {
8
- return String(s || '')
9
- .replace(/ /gi, ' ')
10
- .replace(/&/gi, '&')
11
- .replace(/&lt;/gi, '<')
12
- .replace(/&gt;/gi, '>')
13
- .replace(/&quot;/gi, '"')
14
- .replace(/&#39;|&apos;/gi, "'")
15
- .replace(/&ensp;/gi, ' ')
16
- .replace(/&emsp;/gi, ' ')
17
- .replace(/&middot;/gi, '·')
18
- .replace(/&hellip;/gi, '…')
19
- .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)));
20
- }
21
-
22
- // Parse Bing's HTML into { title, url, snippet } results.
23
- export function parseBingResults(html, limit = 8) {
24
- const out = [];
25
- const blocks = String(html).match(/<li class="b_algo"[\s\S]*?<\/li>/g) || [];
26
- for (const block of blocks) {
27
- const href = (block.match(/<a[^>]+href="(https?:\/\/[^"]+)"/) || [])[1];
28
- if (!href) continue;
29
- const titleHtml = (block.match(/<h2[^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/) || [])[1] || '';
30
- const title = decodeEntities(titleHtml.replace(/<[^>]+>/g, '')).trim();
31
- // Snippet lives in <p> or a caption div; fall back to stripped block text.
32
- let snippet = (block.match(/<p[^>]*>([\s\S]*?)<\/p>/) || [])[1]
33
- || (block.match(/class="b_caption"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/) || [])[1]
34
- || '';
35
- snippet = decodeEntities(snippet.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
36
- if (!snippet) snippet = htmlToText(block).replace(/\s+/g, ' ').slice(0, 240);
37
- out.push({ title: title || href, url: href, snippet });
38
- if (out.length >= limit) break;
39
- }
40
- return out;
41
- }
42
-
43
- export const spec = {
44
- name: 'WebSearch',
45
- description: 'Search the web. Results are title/URL/snippet; snippets are summaries, so fetch a promising URL with FetchURL. Cite sources.',
46
- parameters: {
47
- type: 'object',
48
- properties: {
49
- query: { type: 'string', description: 'Search query.' },
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 signal = (ctx && ctx.signal) || undefined;
57
- const url = 'https://www.bing.com/search?q=' + encodeURIComponent(query) + '&setlang=en';
58
- try {
59
- const res = await fetch(url, {
60
- headers: { 'User-Agent': UA, 'Accept-Language': 'en-US,en;q=0.9' },
61
- signal,
62
- });
63
- if (!res.ok) return `Error: search failed (${res.status} ${res.statusText}).`;
64
- const html = await res.text();
65
- const results = parseBingResults(html, 8);
66
- if (!results.length) return `No search results found for: ${query}`;
67
- return results
68
- .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`)
69
- .join('\n\n');
70
- } catch (e) {
71
- const msg = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
72
- return `Error searching "${query}": ${msg}`;
73
- }
74
- },
75
- };
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, 知乎, 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
+ };