@superjackfruitlabs/salaam 0.0.4 → 0.0.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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
@@ -0,0 +1,195 @@
1
+ import { BlockList, isIP } from 'node:net';
2
+ import { lookup as dnsLookup } from 'node:dns/promises';
3
+ import { request as httpRequest, type IncomingMessage } from 'node:http';
4
+ import { request as httpsRequest } from 'node:https';
5
+
6
+ /**
7
+ * Outbound HTTP for web search and fetch. These run in the daemon, outside the command sandbox, so
8
+ * every connection is checked at connect time: the URL's literal address, every address its name
9
+ * resolves to (a DNS answer that mixes public and private is refused, so rebinding cannot land on
10
+ * a private one), and every redirect hop. Bodies are capped and requests time out.
11
+ */
12
+ const denied = new BlockList();
13
+ for (const [net, bits] of [
14
+ ['0.0.0.0', 8],
15
+ ['10.0.0.0', 8],
16
+ ['100.64.0.0', 10],
17
+ ['127.0.0.0', 8],
18
+ ['169.254.0.0', 16],
19
+ ['172.16.0.0', 12],
20
+ ['192.0.0.0', 24],
21
+ ['192.0.2.0', 24],
22
+ ['192.168.0.0', 16],
23
+ ['198.18.0.0', 15],
24
+ ['198.51.100.0', 24],
25
+ ['203.0.113.0', 24],
26
+ ['224.0.0.0', 4],
27
+ ['240.0.0.0', 4],
28
+ ] as const)
29
+ denied.addSubnet(net, bits, 'ipv4');
30
+ for (const [net, bits] of [
31
+ ['::', 128],
32
+ ['::1', 128],
33
+ ['64:ff9b::', 96],
34
+ ['100::', 64],
35
+ ['2001:db8::', 32],
36
+ ['fc00::', 7],
37
+ ['fe80::', 10],
38
+ ['ff00::', 8],
39
+ ] as const)
40
+ denied.addSubnet(net, bits, 'ipv6');
41
+
42
+ /** True only for globally routable unicast addresses. */
43
+ export function isPublicAddress(address: string): boolean {
44
+ const bare = address.replace(/^\[|\]$/g, '').split('%')[0]!;
45
+ const family = isIP(bare);
46
+ if (!family) return false;
47
+ if (family === 6) {
48
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
49
+ if (mapped) return isPublicAddress(mapped[1]!);
50
+ if (/^::ffff:/i.test(bare)) return false;
51
+ return !denied.check(bare, 'ipv6');
52
+ }
53
+ return !denied.check(bare, 'ipv4');
54
+ }
55
+
56
+ export interface Resolved {
57
+ address: string;
58
+ family: number;
59
+ }
60
+ export interface GuardOptions {
61
+ method?: 'GET' | 'POST';
62
+ headers?: Record<string, string>;
63
+ body?: string;
64
+ maxBytes?: number;
65
+ timeoutMs?: number;
66
+ maxRedirects?: number;
67
+ /** Injected resolver (tests); defaults to the system resolver, all addresses. */
68
+ lookup?: (host: string) => Promise<Resolved[]>;
69
+ /** Tests only: a loopback test server may be reached. */
70
+ allowLoopbackForTests?: boolean;
71
+ /** Tests only: loopback is allowed on the first hop but not after a redirect. */
72
+ allowLoopbackOnlyFirstHop?: boolean;
73
+ }
74
+ export interface GuardedResponse {
75
+ status: number;
76
+ url: string;
77
+ body: string;
78
+ truncated: boolean;
79
+ contentType: string;
80
+ }
81
+
82
+ const LOOPBACK = new Set(['127.0.0.1', '::1']);
83
+ function allowed(address: string, loopbackOk: boolean): boolean {
84
+ return isPublicAddress(address) || (loopbackOk && LOOPBACK.has(address));
85
+ }
86
+ function refuse(host: string): Error {
87
+ return new Error(`Refused: ${host} is not a public address`);
88
+ }
89
+
90
+ export async function guardedRequest(
91
+ rawUrl: string,
92
+ opts: GuardOptions,
93
+ hop = 0,
94
+ ): Promise<GuardedResponse> {
95
+ let url: URL;
96
+ try {
97
+ url = new URL(rawUrl);
98
+ } catch {
99
+ throw new Error('Not a valid URL');
100
+ }
101
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
102
+ throw new Error('Only http or https URLs can be fetched');
103
+ if (url.username || url.password) throw new Error('URLs with credentials are refused');
104
+ const loopbackOk = !!opts.allowLoopbackForTests && !(opts.allowLoopbackOnlyFirstHop && hop > 0);
105
+ const host = url.hostname.replace(/^\[|\]$/g, '');
106
+ if (isIP(host) && !allowed(host, loopbackOk)) throw refuse(host);
107
+ const resolve = opts.lookup ?? (async (h: string) => dnsLookup(h, { all: true, verbatim: true }));
108
+ // Resolve once here, refuse if any answer is private, then connect only to a checked address.
109
+ const addresses: Resolved[] = isIP(host)
110
+ ? [{ address: host, family: isIP(host) }]
111
+ : await resolve(host);
112
+ if (!addresses.length || addresses.some((a) => !allowed(a.address, loopbackOk)))
113
+ throw refuse(host);
114
+ const pinned = addresses[0]!;
115
+ const maxBytes = opts.maxBytes ?? 2_000_000;
116
+ const timeoutMs = opts.timeoutMs ?? 15_000;
117
+ const res = await new Promise<IncomingMessage>((resolveRes, reject) => {
118
+ const req = (url.protocol === 'https:' ? httpsRequest : httpRequest)(
119
+ url,
120
+ {
121
+ method: opts.method ?? 'GET',
122
+ headers: {
123
+ 'user-agent': 'salaam-web/1 (+https://salaam.sh)',
124
+ ...(opts.body ? { 'content-type': 'application/json' } : {}),
125
+ ...opts.headers,
126
+ },
127
+ // The connection goes to the address checked above, never a fresh DNS answer.
128
+ lookup: (_h, options, cb) =>
129
+ (options as { all?: boolean }).all
130
+ ? (cb as (e: null, a: Resolved[]) => void)(null, [pinned])
131
+ : (cb as (e: null, a: string, f: number) => void)(null, pinned.address, pinned.family),
132
+ timeout: timeoutMs,
133
+ },
134
+ resolveRes,
135
+ );
136
+ const timer = setTimeout(() => req.destroy(new Error('Request timed out')), timeoutMs);
137
+ req.on('timeout', () => req.destroy(new Error('Request timed out')));
138
+ req.on('error', (e) => {
139
+ clearTimeout(timer);
140
+ reject(e);
141
+ });
142
+ req.on('response', () => clearTimeout(timer));
143
+ if (opts.body) req.write(opts.body);
144
+ req.end();
145
+ });
146
+ const status = res.statusCode ?? 0;
147
+ if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
148
+ res.resume();
149
+ if (hop >= (opts.maxRedirects ?? 5)) throw new Error('Too many redirects');
150
+ const next = new URL(res.headers.location, url).toString();
151
+ const keep = status === 307 || status === 308;
152
+ return guardedRequest(
153
+ next,
154
+ { ...opts, ...(keep ? {} : { method: 'GET', body: undefined }) },
155
+ hop + 1,
156
+ );
157
+ }
158
+ const chunks: Buffer[] = [];
159
+ let size = 0;
160
+ let truncated = false;
161
+ await new Promise<void>((done, fail) => {
162
+ const timer = setTimeout(() => res.destroy(new Error('Request timed out')), timeoutMs);
163
+ res.on('data', (c: Buffer) => {
164
+ if (truncated) return;
165
+ const room = maxBytes - size;
166
+ if (c.length >= room) {
167
+ chunks.push(c.subarray(0, room));
168
+ size += room;
169
+ truncated = true;
170
+ clearTimeout(timer);
171
+ res.destroy();
172
+ done();
173
+ return;
174
+ }
175
+ chunks.push(c);
176
+ size += c.length;
177
+ });
178
+ res.on('end', () => {
179
+ clearTimeout(timer);
180
+ done();
181
+ });
182
+ res.on('error', (e) => {
183
+ clearTimeout(timer);
184
+ if (truncated) done();
185
+ else fail(e);
186
+ });
187
+ });
188
+ return {
189
+ status,
190
+ url: url.toString(),
191
+ body: Buffer.concat(chunks).toString('utf8'),
192
+ truncated,
193
+ contentType: String(res.headers['content-type'] ?? ''),
194
+ };
195
+ }
@@ -0,0 +1,402 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { guardedRequest, type GuardedResponse } from './web-guard.js';
3
+ import { decodeEntities, stripTags } from './web-fetch.js';
4
+
5
+ /**
6
+ * Built-in web search for interactive sessions. Operator ruling 2026-10-31: "Official free APIs
7
+ * go as default and then other providers can be configured as per the user requirement. It could
8
+ * searxng or brave or tavily or exa." Keyless official APIs answer by default, routed by kind;
9
+ * a configured provider answers general queries first. Backends are tried in order, falling
10
+ * through on an error or an empty answer, and every attempt is recorded (never a key).
11
+ */
12
+ export type SearchKind = 'general' | 'code' | 'package' | 'paper' | 'wiki';
13
+ export const SEARCH_KINDS: readonly SearchKind[] = ['general', 'code', 'package', 'paper', 'wiki'];
14
+ export interface SearchResult {
15
+ title: string;
16
+ url: string;
17
+ snippet: string;
18
+ source: string;
19
+ }
20
+ export interface Attempt {
21
+ backend: string;
22
+ ok: boolean;
23
+ count?: number;
24
+ ms: number;
25
+ error?: string;
26
+ }
27
+ export interface SearchOutcome {
28
+ query: string;
29
+ kind: SearchKind;
30
+ results: SearchResult[];
31
+ tried: Attempt[];
32
+ cached?: boolean;
33
+ }
34
+ export type Transport = (
35
+ url: string,
36
+ opts?: { method?: 'GET' | 'POST'; headers?: Record<string, string>; body?: string },
37
+ ) => Promise<GuardedResponse>;
38
+ export type Provider =
39
+ { type: 'searxng'; url: string } | { type: 'brave' | 'tavily' | 'exa'; key: string };
40
+ export interface WebSearchOptions {
41
+ transport?: Transport;
42
+ providers?: Provider[];
43
+ githubToken?: string;
44
+ now?: () => number;
45
+ }
46
+
47
+ type Search = (q: string, limit: number) => Promise<SearchResult[]>;
48
+ interface Backend {
49
+ name: string;
50
+ intervalMs: number;
51
+ run: Search;
52
+ }
53
+ const KEYLESS = ['wikipedia', 'stackexchange', 'github', 'npm', 'pypi', 'arxiv', 'hackernews'];
54
+ const ROUTES: Record<Exclude<SearchKind, 'general'>, string[]> = {
55
+ code: ['github', 'stackexchange'],
56
+ package: ['npm', 'pypi'],
57
+ paper: ['arxiv'],
58
+ wiki: ['wikipedia'],
59
+ };
60
+ const GENERAL_KEYLESS = ['wikipedia', 'stackexchange', 'hackernews'];
61
+ const CACHE_TTL_MS = 10 * 60_000;
62
+ const CACHE_MAX = 200;
63
+
64
+ const clean = (s: unknown, max = 400) =>
65
+ decodeEntities(stripTags(String(s ?? '')))
66
+ .replace(/\s+/g, ' ')
67
+ .trim()
68
+ .slice(0, max);
69
+ const q = encodeURIComponent;
70
+
71
+ export class WebSearch {
72
+ private readonly transport: Transport;
73
+ private readonly providers: Provider[];
74
+ private readonly now: () => number;
75
+ private readonly backends = new Map<string, Backend>();
76
+ private readonly last = new Map<string, number>();
77
+ private readonly cache = new Map<string, { at: number; value: SearchOutcome }>();
78
+ constructor(opts: WebSearchOptions = {}) {
79
+ this.transport =
80
+ opts.transport ??
81
+ ((url, o = {}) => guardedRequest(url, { ...o, maxBytes: 1_000_000, timeoutMs: 12_000 }));
82
+ this.providers = opts.providers ?? [];
83
+ this.now = opts.now ?? Date.now;
84
+ const json = async (url: string, o?: Parameters<Transport>[1]) => {
85
+ const r = await this.transport(url, {
86
+ ...o,
87
+ headers: { accept: 'application/json', ...o?.headers },
88
+ });
89
+ if (r.status < 200 || r.status >= 300) throw new Error(`HTTP ${r.status}`);
90
+ return JSON.parse(r.body) as any;
91
+ };
92
+ const add = (name: string, run: Search, intervalMs = 1000) =>
93
+ this.backends.set(name, { name, run, intervalMs });
94
+ add('wikipedia', async (s, n) =>
95
+ (
96
+ (
97
+ await json(
98
+ `https://en.wikipedia.org/w/api.php?action=query&list=search&format=json&utf8=1&srlimit=${n}&srsearch=${q(s)}`,
99
+ )
100
+ ).query?.search ?? []
101
+ ).map((h: any) => ({
102
+ title: clean(h.title, 200),
103
+ url:
104
+ 'https://en.wikipedia.org/wiki/' + encodeURIComponent(String(h.title).replace(/ /g, '_')),
105
+ snippet: clean(h.snippet),
106
+ source: 'wikipedia',
107
+ })),
108
+ );
109
+ add('stackexchange', async (s, n) =>
110
+ (
111
+ (
112
+ await json(
113
+ `https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&site=stackoverflow&pagesize=${n}&q=${q(s)}`,
114
+ )
115
+ ).items ?? []
116
+ ).map((h: any) => ({
117
+ title: clean(h.title, 200),
118
+ url: String(h.link),
119
+ snippet: `score ${h.score ?? 0}${h.is_answered ? ', answered' : ''}`,
120
+ source: 'stackexchange',
121
+ })),
122
+ );
123
+ add(
124
+ 'github',
125
+ async (s, n) =>
126
+ (
127
+ (
128
+ await json(`https://api.github.com/search/repositories?per_page=${n}&q=${q(s)}`, {
129
+ headers: {
130
+ accept: 'application/vnd.github+json',
131
+ ...(opts.githubToken ? { authorization: 'Bearer ' + opts.githubToken } : {}),
132
+ },
133
+ })
134
+ ).items ?? []
135
+ ).map((h: any) => ({
136
+ title: clean(h.full_name, 200),
137
+ url: String(h.html_url),
138
+ snippet: `${clean(h.description)} (${h.stargazers_count ?? 0} stars)`.trim(),
139
+ source: 'github',
140
+ })),
141
+ // Unauthenticated repository search allows 10 requests a minute.
142
+ opts.githubToken ? 2000 : 6000,
143
+ );
144
+ add('npm', async (s, n) =>
145
+ (
146
+ (await json(`https://registry.npmjs.org/-/v1/search?size=${n}&text=${q(s)}`)).objects ?? []
147
+ ).map((o: any) => ({
148
+ title: `${clean(o.package?.name, 200)}@${clean(o.package?.version, 40)}`,
149
+ url: String(o.package?.links?.npm ?? `https://www.npmjs.com/package/${o.package?.name}`),
150
+ snippet: clean(o.package?.description),
151
+ source: 'npm',
152
+ })),
153
+ );
154
+ // PyPI has no search API; its official JSON API answers an exact project name.
155
+ add('pypi', async (s) => {
156
+ const name = s.trim();
157
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) return [];
158
+ const r = await this.transport(`https://pypi.org/pypi/${q(name)}/json`, {
159
+ headers: { accept: 'application/json' },
160
+ });
161
+ if (r.status === 404) return [];
162
+ if (r.status < 200 || r.status >= 300) throw new Error(`HTTP ${r.status}`);
163
+ const info = (JSON.parse(r.body) as any).info ?? {};
164
+ return [
165
+ {
166
+ title: `${clean(info.name, 200)} ${clean(info.version, 40)}`.trim(),
167
+ url: String(info.package_url ?? `https://pypi.org/project/${name}/`),
168
+ snippet: clean(info.summary),
169
+ source: 'pypi',
170
+ },
171
+ ];
172
+ });
173
+ add(
174
+ 'arxiv',
175
+ async (s, n) => {
176
+ const r = await this.transport(
177
+ `https://export.arxiv.org/api/query?max_results=${n}&search_query=all:${q(s)}`,
178
+ { headers: { accept: 'application/atom+xml' } },
179
+ );
180
+ if (r.status < 200 || r.status >= 300) throw new Error(`HTTP ${r.status}`);
181
+ return [...r.body.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => {
182
+ const tag = (t: string) =>
183
+ new RegExp(`<${t}[^>]*>([\\s\\S]*?)</${t}>`).exec(m[1]!)?.[1] ?? '';
184
+ return {
185
+ title: clean(tag('title'), 300),
186
+ url: clean(tag('id'), 300),
187
+ snippet: clean(tag('summary')),
188
+ source: 'arxiv',
189
+ };
190
+ });
191
+ },
192
+ 3000,
193
+ );
194
+ add('hackernews', async (s, n) =>
195
+ (
196
+ (await json(`https://hn.algolia.com/api/v1/search?hitsPerPage=${n}&query=${q(s)}`)).hits ??
197
+ []
198
+ ).map((h: any) => ({
199
+ title: clean(h.title ?? h.story_title, 200),
200
+ url: String(h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`),
201
+ snippet: `${h.points ?? 0} points on Hacker News`,
202
+ source: 'hackernews',
203
+ })),
204
+ );
205
+ this.providers.forEach((p, i) => {
206
+ const name =
207
+ this.providers.findIndex((o) => o.type === p.type) === i ? p.type : `${p.type}-${i}`;
208
+ if (p.type === 'searxng')
209
+ add(name, async (s, n) =>
210
+ ((await json(`${p.url.replace(/\/+$/, '')}/search?format=json&q=${q(s)}`)).results ?? [])
211
+ .slice(0, n)
212
+ .map((h: any) => ({
213
+ title: clean(h.title, 200),
214
+ url: String(h.url),
215
+ snippet: clean(h.content),
216
+ source: p.type,
217
+ })),
218
+ );
219
+ else if (p.type === 'brave')
220
+ add(name, async (s, n) =>
221
+ (
222
+ (
223
+ await json(`https://api.search.brave.com/res/v1/web/search?count=${n}&q=${q(s)}`, {
224
+ headers: { 'x-subscription-token': p.key },
225
+ })
226
+ ).web?.results ?? []
227
+ ).map((h: any) => ({
228
+ title: clean(h.title, 200),
229
+ url: String(h.url),
230
+ snippet: clean(h.description),
231
+ source: p.type,
232
+ })),
233
+ );
234
+ else if (p.type === 'tavily')
235
+ add(name, async (s, n) =>
236
+ (
237
+ (
238
+ await json('https://api.tavily.com/search', {
239
+ method: 'POST',
240
+ headers: { authorization: 'Bearer ' + p.key },
241
+ body: JSON.stringify({ query: s, max_results: n }),
242
+ })
243
+ ).results ?? []
244
+ ).map((h: any) => ({
245
+ title: clean(h.title, 200),
246
+ url: String(h.url),
247
+ snippet: clean(h.content),
248
+ source: p.type,
249
+ })),
250
+ );
251
+ else
252
+ add(name, async (s, n) =>
253
+ (
254
+ (
255
+ await json('https://api.exa.ai/search', {
256
+ method: 'POST',
257
+ headers: { 'x-api-key': p.key },
258
+ body: JSON.stringify({ query: s, numResults: n }),
259
+ })
260
+ ).results ?? []
261
+ ).map((h: any) => ({
262
+ title: clean(h.title, 200),
263
+ url: String(h.url),
264
+ snippet: clean(h.text ?? h.summary),
265
+ source: p.type,
266
+ })),
267
+ );
268
+ });
269
+ }
270
+ /** What is enabled, for health and manifests: provider types only, never keys or URLs' secrets. */
271
+ describe(): { providers: string[]; keyless: string[] } {
272
+ return { providers: this.providers.map((p) => p.type), keyless: [...KEYLESS] };
273
+ }
274
+ private order(kind: SearchKind): string[] {
275
+ if (kind !== 'general') return ROUTES[kind];
276
+ const providerNames = [...this.backends.keys()].filter((n) => !KEYLESS.includes(n));
277
+ return [...providerNames, ...GENERAL_KEYLESS];
278
+ }
279
+ async search(input: {
280
+ query: string;
281
+ kind?: SearchKind;
282
+ limit?: number;
283
+ }): Promise<SearchOutcome> {
284
+ const query = String(input.query ?? '')
285
+ .trim()
286
+ .slice(0, 300);
287
+ if (!query) throw new Error('An empty query cannot be searched');
288
+ const kind = input.kind ?? 'general';
289
+ if (!SEARCH_KINDS.includes(kind)) throw new Error('Unknown search kind');
290
+ const limit = Math.max(1, Math.min(10, Math.floor(input.limit ?? 5)));
291
+ const key = `${kind}\u0000${query.toLowerCase()}\u0000${limit}`;
292
+ const hit = this.cache.get(key);
293
+ if (hit && this.now() - hit.at < CACHE_TTL_MS) return { ...hit.value, cached: true };
294
+ const results: SearchResult[] = [];
295
+ const seen = new Set<string>();
296
+ const tried: Attempt[] = [];
297
+ for (const name of this.order(kind)) {
298
+ if (results.length >= limit) break;
299
+ const b = this.backends.get(name)!;
300
+ const last = this.last.get(name);
301
+ if (last !== undefined && this.now() - last < b.intervalMs) {
302
+ tried.push({ backend: name, ok: false, ms: 0, error: 'rate-limited' });
303
+ continue;
304
+ }
305
+ this.last.set(name, this.now());
306
+ const started = Date.now();
307
+ try {
308
+ const got = await b.run(query, limit - results.length);
309
+ let count = 0;
310
+ for (const r of got) {
311
+ if (!/^https?:\/\//.test(r.url) || seen.has(r.url)) continue;
312
+ seen.add(r.url);
313
+ results.push(r);
314
+ count++;
315
+ if (results.length >= limit) break;
316
+ }
317
+ tried.push({ backend: name, ok: true, count, ms: Date.now() - started });
318
+ } catch (error) {
319
+ tried.push({
320
+ backend: name,
321
+ ok: false,
322
+ ms: Date.now() - started,
323
+ error: String((error as Error).message ?? error).slice(0, 200),
324
+ });
325
+ }
326
+ }
327
+ const value: SearchOutcome = { query, kind, results, tried };
328
+ // Cache only a complete answer: one assembled around a skipped or failing backend would
329
+ // keep that gap for the cache's lifetime.
330
+ if (results.length && tried.every((t) => t.ok)) {
331
+ if (this.cache.size >= CACHE_MAX) this.cache.delete(this.cache.keys().next().value!);
332
+ this.cache.set(key, { at: this.now(), value });
333
+ }
334
+ return value;
335
+ }
336
+ }
337
+
338
+ /** Reads the operator's web search config: providers in order, keys from env or a private file. */
339
+ export function loadWebSearchConfig(
340
+ path: string,
341
+ env: Record<string, string | undefined> = process.env,
342
+ ): { providers: Provider[]; githubToken?: string } {
343
+ let raw: unknown;
344
+ try {
345
+ raw = JSON.parse(readFileSync(path, 'utf8'));
346
+ } catch {
347
+ throw new Error(`Web search config ${path} is not readable JSON`);
348
+ }
349
+ const top = raw as Record<string, unknown>;
350
+ if (!top || typeof top !== 'object' || Array.isArray(top))
351
+ throw new Error('Web search config must be an object');
352
+ for (const k of Object.keys(top))
353
+ if (!['providers', 'github_token_env'].includes(k))
354
+ throw new Error(`Web search config: unknown field ${k}`);
355
+ const secret = (p: Record<string, unknown>, label: string): string => {
356
+ const hasEnv = typeof p.key_env === 'string' && p.key_env;
357
+ const hasFile = typeof p.key_file === 'string' && p.key_file;
358
+ if ('key' in p || (!hasEnv && !hasFile) || (hasEnv && hasFile))
359
+ throw new Error(`${label} needs exactly one of key_env or key_file (never the key itself)`);
360
+ if (hasEnv) {
361
+ const v = env[p.key_env as string];
362
+ if (!v) throw new Error(`${label}: ${p.key_env} is not set`);
363
+ return v.trim();
364
+ }
365
+ const file = p.key_file as string;
366
+ const st = statSync(file);
367
+ if (st.mode & 0o077) throw new Error(`${label}: ${file} is readable by others; chmod 600 it`);
368
+ return readFileSync(file, 'utf8').trim();
369
+ };
370
+ const providers: Provider[] = [];
371
+ const list = top.providers ?? [];
372
+ if (!Array.isArray(list) || list.length > 8)
373
+ throw new Error('providers must be a list of up to 8');
374
+ list.forEach((entry, i) => {
375
+ const p = entry as Record<string, unknown>;
376
+ const label = `providers[${i}]`;
377
+ const type = p?.type;
378
+ if (type === 'searxng') {
379
+ for (const k of Object.keys(p))
380
+ if (!['type', 'url'].includes(k)) throw new Error(`${label}: unknown field ${k}`);
381
+ let u: URL;
382
+ try {
383
+ u = new URL(String(p.url));
384
+ } catch {
385
+ throw new Error(`${label}: url must be http or https`);
386
+ }
387
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
388
+ throw new Error(`${label}: url must be http or https`);
389
+ providers.push({ type, url: u.toString() });
390
+ } else if (type === 'brave' || type === 'tavily' || type === 'exa') {
391
+ for (const k of Object.keys(p))
392
+ if (!['type', 'key_env', 'key_file', 'key'].includes(k))
393
+ throw new Error(`${label}: unknown field ${k}`);
394
+ providers.push({ type, key: secret(p, label) });
395
+ } else throw new Error(`${label}: unknown provider (searxng, brave, tavily or exa)`);
396
+ });
397
+ const ghEnv = top.github_token_env;
398
+ if (ghEnv !== undefined && typeof ghEnv !== 'string')
399
+ throw new Error('github_token_env must be a name');
400
+ const githubToken = ghEnv ? env[ghEnv] : undefined;
401
+ return { providers, ...(githubToken ? { githubToken } : {}) };
402
+ }