jaitus 0.3.0
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/LICENSE +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- package/src/ui.js +562 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// ─── Searching the web ──────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The old arrangement asked one engine, and if it returned anything at all, that was
|
|
4
|
+
// the answer. Asking Bing for "ink react terminal library" returns a hospital in
|
|
5
|
+
// Kolkata, a Netflix trailer and Flipkart — it drops the qualifying words and answers
|
|
6
|
+
// the first one, with local bias — and because it returned *ten* results the fallbacks
|
|
7
|
+
// were never consulted. A confident wrong answer is worse than an error, and the agent
|
|
8
|
+
// then fetched those pages and reasoned about them.
|
|
9
|
+
//
|
|
10
|
+
// Two changes fix it:
|
|
11
|
+
//
|
|
12
|
+
// • **Ask several engines at once and merge.** Agreement between independent engines
|
|
13
|
+
// is the only relevance signal available without an API key, and it is a good one:
|
|
14
|
+
// a URL two engines both return for a query is almost never a hospital.
|
|
15
|
+
// • **Score the results against the query.** A result whose title and snippet contain
|
|
16
|
+
// none of the distinctive words in the query is dropped, however confidently it was
|
|
17
|
+
// returned. That single filter removes every one of the bad results above.
|
|
18
|
+
//
|
|
19
|
+
// A real search API is better than all of this and is used when one is configured. The
|
|
20
|
+
// scrapers are the free path, not the good one, and the output says which was used so
|
|
21
|
+
// nobody mistakes one for the other.
|
|
22
|
+
|
|
23
|
+
const BROWSER_HEADERS = {
|
|
24
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
25
|
+
'accept': 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
|
|
26
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TIMEOUT = 12_000;
|
|
30
|
+
const stripTags = (s) => String(s ?? '')
|
|
31
|
+
.replace(/<[^>]*>/g, '')
|
|
32
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim();
|
|
36
|
+
|
|
37
|
+
// ─── Relevance ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const STOP = new Set('a an and are as at be but by for from how in is it of on or that the to was what when where which who why with your you i we can do does'.split(' '));
|
|
40
|
+
|
|
41
|
+
/** The words in a query that a relevant result ought to mention. */
|
|
42
|
+
function terms(query) {
|
|
43
|
+
return String(query).toLowerCase().match(/[a-z0-9][a-z0-9.+#-]*/g)?.filter((w) => w.length > 1 && !STOP.has(w)) || [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How well one result answers the query.
|
|
48
|
+
*
|
|
49
|
+
* Two numbers, and COVERAGE is the one that decides. Searching for
|
|
50
|
+
* "ink react terminal library" returns a Wikipedia article about a university library
|
|
51
|
+
* that happens to mention terminals: it scores well on any measure that adds up how
|
|
52
|
+
* often the query's words appear, because it genuinely contains two of them a lot. What
|
|
53
|
+
* it does not contain is "ink" or "react" — and a result missing the distinctive half
|
|
54
|
+
* of the query is not a weak answer, it is a different subject.
|
|
55
|
+
*
|
|
56
|
+
* So coverage — the fraction of query terms present at all — gates the result, and
|
|
57
|
+
* placement only orders the ones that survive. Title and URL count double there,
|
|
58
|
+
* because a page whose title carries the words is about them, whereas a page that
|
|
59
|
+
* mentions them in a snippet may only be adjacent to them.
|
|
60
|
+
*/
|
|
61
|
+
function relevance(result, wanted) {
|
|
62
|
+
if (!wanted.length) return { coverage: 1, placement: 1 };
|
|
63
|
+
const title = String(result.title || '').toLowerCase();
|
|
64
|
+
const snippet = String(result.snippet || '').toLowerCase();
|
|
65
|
+
const url = String(result.url || '').toLowerCase();
|
|
66
|
+
let present = 0;
|
|
67
|
+
let placement = 0;
|
|
68
|
+
for (const term of wanted) {
|
|
69
|
+
if (title.includes(term)) { present += 1; placement += 2; }
|
|
70
|
+
else if (url.includes(term)) { present += 1; placement += 2; }
|
|
71
|
+
else if (snippet.includes(term)) { present += 1; placement += 1; }
|
|
72
|
+
}
|
|
73
|
+
return { coverage: present / wanted.length, placement: placement / (wanted.length * 2) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How much of the query a result has to contain.
|
|
78
|
+
*
|
|
79
|
+
* A one- or two-word query is usually a proper noun and every result will contain it,
|
|
80
|
+
* so the bar is low. The longer and more specific the query, the more of it a genuine
|
|
81
|
+
* answer carries — and the more aggressively a partial match should be dropped.
|
|
82
|
+
*/
|
|
83
|
+
const coverageFloor = (termCount) => (termCount <= 2 ? 0.5 : termCount <= 4 ? 0.6 : 0.5);
|
|
84
|
+
|
|
85
|
+
// ─── Engines ────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async function get(url, extraHeaders) {
|
|
88
|
+
const res = await fetch(url, { headers: { ...BROWSER_HEADERS, ...extraHeaders }, redirect: 'follow', signal: AbortSignal.timeout(TIMEOUT) });
|
|
89
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Bing. `ensearch=1` + an explicit market is what stops the localised answer. */
|
|
94
|
+
async function bing(query) {
|
|
95
|
+
const res = await get(`https://www.bing.com/search?q=${encodeURIComponent(query)}&count=20&setmkt=en-US&setlang=en&ensearch=1`);
|
|
96
|
+
const html = await res.text();
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const block of html.split('<li class="b_algo').slice(1)) {
|
|
99
|
+
const href = /<h2[^>]*><a[^>]+href="([^"]+)"/.exec(block);
|
|
100
|
+
const h2 = /<h2[^>]*>([\s\S]*?)<\/h2>/.exec(block);
|
|
101
|
+
if (!href || !h2) continue;
|
|
102
|
+
const url = unwrapBing(href[1]);
|
|
103
|
+
const title = stripTags(h2[1]);
|
|
104
|
+
const snippet = stripTags((/<p[^>]*>([\s\S]*?)<\/p>/.exec(block) || [])[1] || '');
|
|
105
|
+
if (url && title) out.push({ url, title, snippet });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Bing wraps every link in a ck/a redirect whose `u=a1<base64url>` is the real URL. */
|
|
111
|
+
function unwrapBing(href) {
|
|
112
|
+
const clean = href.replace(/&/g, '&');
|
|
113
|
+
if (!clean.includes('bing.com/ck/')) return clean;
|
|
114
|
+
const u = /[?&]u=a1([^&]+)/.exec(clean);
|
|
115
|
+
if (!u) return clean;
|
|
116
|
+
let b64 = u[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
117
|
+
while (b64.length % 4) b64 += '=';
|
|
118
|
+
try {
|
|
119
|
+
const decoded = Buffer.from(b64, 'base64').toString('utf8');
|
|
120
|
+
return /^https?:\/\//.test(decoded) ? decoded : clean;
|
|
121
|
+
} catch { return clean; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function duckduckgo(query) {
|
|
125
|
+
const res = await get(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`);
|
|
126
|
+
const html = await res.text();
|
|
127
|
+
const out = [];
|
|
128
|
+
const re = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
129
|
+
const snippets = [...html.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => stripTags(m[1]));
|
|
130
|
+
let m;
|
|
131
|
+
let i = 0;
|
|
132
|
+
while ((m = re.exec(html))) {
|
|
133
|
+
let href = m[1].replace(/&/g, '&');
|
|
134
|
+
const uddg = /[?&]uddg=([^&]+)/.exec(href);
|
|
135
|
+
if (uddg) href = decodeURIComponent(uddg[1]);
|
|
136
|
+
if (href.startsWith('//')) href = `https:${href}`;
|
|
137
|
+
const title = stripTags(m[2]);
|
|
138
|
+
if (title && /^https?:\/\//.test(href)) out.push({ url: href, title, snippet: snippets[i] || '' });
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function mojeek(query) {
|
|
145
|
+
const res = await get(`https://www.mojeek.com/search?q=${encodeURIComponent(query)}`);
|
|
146
|
+
const html = await res.text();
|
|
147
|
+
const out = [];
|
|
148
|
+
// Mojeek's markup moves; match the result list rather than one class name that was
|
|
149
|
+
// right the day it was written.
|
|
150
|
+
for (const m of html.matchAll(/<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>\s*<?h2?[^>]*>?([\s\S]{0,180}?)<\/(?:h2|a)>/g)) {
|
|
151
|
+
const title = stripTags(m[2]);
|
|
152
|
+
if (title.length > 3 && !/mojeek/i.test(m[1])) out.push({ url: m[1].replace(/&/g, '&'), title, snippet: '' });
|
|
153
|
+
if (out.length >= 15) break;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function startpage(query) {
|
|
159
|
+
const res = await get(`https://www.startpage.com/sp/search?query=${encodeURIComponent(query)}`);
|
|
160
|
+
const html = await res.text();
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const m of html.matchAll(/<a[^>]+class="[^"]*result-link[^"]*"[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/g)) {
|
|
163
|
+
const title = stripTags(m[2]);
|
|
164
|
+
if (title) out.push({ url: m[1].replace(/&/g, '&'), title, snippet: '' });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── The indexes that actually know the answer ──────────────────────────────────
|
|
170
|
+
//
|
|
171
|
+
// Almost every search a coding agent runs is about a package, a repository, a web API
|
|
172
|
+
// or an error someone else has already hit. Those things all have real, public, keyless
|
|
173
|
+
// search APIs that return ranked, structured, correct results in under a second — which
|
|
174
|
+
// is a different category of thing from scraping a general search engine's HTML and
|
|
175
|
+
// hoping it did not decide the query was about printer ink.
|
|
176
|
+
//
|
|
177
|
+
// They are queried alongside the general engines rather than instead of them: a genuine
|
|
178
|
+
// index that returns nothing costs half a second, and one that returns something has
|
|
179
|
+
// almost certainly returned the right thing.
|
|
180
|
+
|
|
181
|
+
async function npmSearch(query) {
|
|
182
|
+
const res = await get(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=6`, { accept: 'application/json' });
|
|
183
|
+
const body = await res.json();
|
|
184
|
+
return (body?.objects || []).map((o) => ({
|
|
185
|
+
url: o.package?.links?.npm || `https://www.npmjs.com/package/${o.package?.name}`,
|
|
186
|
+
title: `${o.package?.name}${o.package?.version ? ` ${o.package.version}` : ''} (npm)`,
|
|
187
|
+
snippet: [o.package?.description, o.package?.links?.repository].filter(Boolean).join(' — ').slice(0, 300),
|
|
188
|
+
})).filter((r) => r.url);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function githubSearch(query) {
|
|
192
|
+
const res = await get(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=6`, {
|
|
193
|
+
accept: 'application/vnd.github+json',
|
|
194
|
+
});
|
|
195
|
+
const body = await res.json();
|
|
196
|
+
return (body?.items || []).map((o) => ({
|
|
197
|
+
url: o.html_url,
|
|
198
|
+
title: `${o.full_name} · ★${o.stargazers_count} (GitHub)`,
|
|
199
|
+
snippet: String(o.description || '').slice(0, 300),
|
|
200
|
+
// GitHub's best-match ranking will happily put a three-star demo above the library
|
|
201
|
+
// it demonstrates. Popularity is a weak signal but it is the right tiebreak here:
|
|
202
|
+
// when two repos match a query equally, the one everyone uses is the one meant.
|
|
203
|
+
boost: Math.log10(1 + (Number(o.stargazers_count) || 0)) / 2,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function stackoverflow(query) {
|
|
208
|
+
const res = await get(
|
|
209
|
+
`https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${encodeURIComponent(query)}&site=stackoverflow&pagesize=6&filter=default`,
|
|
210
|
+
{ accept: 'application/json' },
|
|
211
|
+
);
|
|
212
|
+
const body = await res.json();
|
|
213
|
+
return (body?.items || []).map((o) => ({
|
|
214
|
+
url: o.link,
|
|
215
|
+
title: `${stripTags(o.title)}${o.is_answered ? ' ✓answered' : ''} (Stack Overflow)`,
|
|
216
|
+
snippet: `score ${o.score}, ${o.answer_count} answer${o.answer_count === 1 ? '' : 's'}`,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function mdn(query) {
|
|
221
|
+
const res = await get(`https://developer.mozilla.org/api/v1/search?q=${encodeURIComponent(query)}`, { accept: 'application/json' });
|
|
222
|
+
const body = await res.json();
|
|
223
|
+
return (body?.documents || []).slice(0, 6).map((d) => ({
|
|
224
|
+
url: `https://developer.mozilla.org${d.mdn_url}`,
|
|
225
|
+
title: `${d.title} (MDN)`,
|
|
226
|
+
snippet: String(d.summary || '').slice(0, 300),
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function crates(query) {
|
|
231
|
+
const res = await get(`https://crates.io/api/v1/crates?q=${encodeURIComponent(query)}&per_page=5`, { accept: 'application/json' });
|
|
232
|
+
const body = await res.json();
|
|
233
|
+
// Named `crate`, not `c`: `c` is the colour helper everywhere else in this codebase
|
|
234
|
+
// and a whole-source check greps for `c.<name>` to catch typo'd colours.
|
|
235
|
+
return (body?.crates || []).map((crate) => ({
|
|
236
|
+
url: `https://crates.io/crates/${crate.name}`,
|
|
237
|
+
title: `${crate.name} ${crate.max_version || ''} (crates.io)`,
|
|
238
|
+
snippet: String(crate.description || '').slice(0, 300),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function pypi(query) {
|
|
243
|
+
// PyPI has no search API any more; the JSON endpoint is exact-name only, which is
|
|
244
|
+
// still useful when the query names a package and useless otherwise.
|
|
245
|
+
const name = String(query).trim().split(/\s+/)[0].replace(/[^a-zA-Z0-9._-]/g, '');
|
|
246
|
+
if (!name) return [];
|
|
247
|
+
const res = await get(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, { accept: 'application/json' });
|
|
248
|
+
const body = await res.json();
|
|
249
|
+
if (!body?.info) return [];
|
|
250
|
+
return [{
|
|
251
|
+
url: body.info.package_url || `https://pypi.org/project/${name}/`,
|
|
252
|
+
title: `${body.info.name} ${body.info.version} (PyPI)`,
|
|
253
|
+
snippet: String(body.info.summary || '').slice(0, 300),
|
|
254
|
+
}];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Which language-specific indexes are worth asking, from what the query mentions. */
|
|
258
|
+
function languageEngines(query) {
|
|
259
|
+
const q = query.toLowerCase();
|
|
260
|
+
const out = [];
|
|
261
|
+
if (/\b(rust|cargo|crate|tokio|serde)\b/.test(q)) out.push(['crates.io', () => crates(query)]);
|
|
262
|
+
if (/\b(python|pip|pypi|django|flask|pandas|numpy)\b/.test(q)) out.push(['pypi', () => pypi(query)]);
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Wikipedia's own search: narrow, but exact, and it answers when the others do not. */
|
|
267
|
+
async function wikipedia(query) {
|
|
268
|
+
const res = await get(
|
|
269
|
+
`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&srlimit=5&format=json&origin=*`,
|
|
270
|
+
{ accept: 'application/json' },
|
|
271
|
+
);
|
|
272
|
+
const body = await res.json();
|
|
273
|
+
return (body?.query?.search || []).map((hit) => ({
|
|
274
|
+
url: `https://en.wikipedia.org/wiki/${encodeURIComponent(String(hit.title).replace(/ /g, '_'))}`,
|
|
275
|
+
title: String(hit.title),
|
|
276
|
+
snippet: stripTags(hit.snippet || ''),
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Google News RSS: dated, news-only, and the right index for "what happened today". */
|
|
281
|
+
async function googleNews(query, { headlines = false } = {}) {
|
|
282
|
+
const endpoint = headlines
|
|
283
|
+
? 'https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en'
|
|
284
|
+
: `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
|
|
285
|
+
const res = await get(endpoint, { accept: 'application/rss+xml, application/xml;q=0.9' });
|
|
286
|
+
const xml = await res.text();
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const item of xml.match(/<item>[\s\S]*?<\/item>/g) || []) {
|
|
289
|
+
const title = xmlText(item, 'title');
|
|
290
|
+
const url = xmlText(item, 'link');
|
|
291
|
+
const date = xmlText(item, 'pubDate');
|
|
292
|
+
if (title && /^https?:\/\//.test(url)) {
|
|
293
|
+
out.push({ url, title, snippet: [date, stripTags(xmlText(item, 'description'))].filter(Boolean).join(' — ').slice(0, 300) });
|
|
294
|
+
}
|
|
295
|
+
if (out.length >= 12) break;
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function xmlText(source, name) {
|
|
301
|
+
const match = new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i').exec(source);
|
|
302
|
+
if (!match) return '';
|
|
303
|
+
return match[1]
|
|
304
|
+
.replace(/^<!\[CDATA\[([\s\S]*)\]\]>$/, '$1')
|
|
305
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
306
|
+
.replace(/"/g, '"').replace(/'/g, "'")
|
|
307
|
+
.trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─── Paid APIs, when one is configured ──────────────────────────────────────────
|
|
311
|
+
//
|
|
312
|
+
// Any of these beats every scraper above, and none of them is required. The key is read
|
|
313
|
+
// from settings or the environment so it never has to be typed into a prompt.
|
|
314
|
+
|
|
315
|
+
async function brave(query, key) {
|
|
316
|
+
const res = await get(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=10`, {
|
|
317
|
+
accept: 'application/json', 'x-subscription-token': key,
|
|
318
|
+
});
|
|
319
|
+
const body = await res.json();
|
|
320
|
+
return (body?.web?.results || []).map((r) => ({ url: r.url, title: r.title, snippet: stripTags(r.description || '') }));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function tavily(query, key) {
|
|
324
|
+
const res = await fetch('https://api.tavily.com/search', {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
327
|
+
body: JSON.stringify({ query, max_results: 10 }),
|
|
328
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
329
|
+
});
|
|
330
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
331
|
+
const body = await res.json();
|
|
332
|
+
return (body?.results || []).map((r) => ({ url: r.url, title: r.title, snippet: String(r.content || '').slice(0, 300) }));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function serper(query, key) {
|
|
336
|
+
const res = await fetch('https://google.serper.dev/search', {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: { 'content-type': 'application/json', 'X-API-KEY': key },
|
|
339
|
+
body: JSON.stringify({ q: query, num: 10 }),
|
|
340
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
341
|
+
});
|
|
342
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
343
|
+
const body = await res.json();
|
|
344
|
+
return (body?.organic || []).map((r) => ({ url: r.link, title: r.title, snippet: r.snippet || '' }));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function configuredApi(settings) {
|
|
348
|
+
const pick = (a, b) => (typeof a === 'string' && a.trim() ? a.trim() : (typeof b === 'string' && b.trim() ? b.trim() : ''));
|
|
349
|
+
const braveKey = pick(settings?.braveSearchKey, process.env.BRAVE_SEARCH_API_KEY);
|
|
350
|
+
if (braveKey) return { name: 'brave', run: (q) => brave(q, braveKey) };
|
|
351
|
+
const tavilyKey = pick(settings?.tavilyKey, process.env.TAVILY_API_KEY);
|
|
352
|
+
if (tavilyKey) return { name: 'tavily', run: (q) => tavily(q, tavilyKey) };
|
|
353
|
+
const serperKey = pick(settings?.serperKey, process.env.SERPER_API_KEY);
|
|
354
|
+
if (serperKey) return { name: 'serper', run: (q) => serper(q, serperKey) };
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── The search ─────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
const isNews = (q) => /\b(news|headlines?|breaking|today|latest|this week|current events?)\b/i.test(q);
|
|
361
|
+
const isHeadlines = (q) => !q.toLowerCase()
|
|
362
|
+
.replace(/\b(top|latest|today|breaking|news|headlines?|current|events?|world|global|updates?|the|me|show|give)\b/g, '')
|
|
363
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
364
|
+
.length;
|
|
365
|
+
|
|
366
|
+
/** Two URLs that differ only in tracking junk or a trailing slash are one URL. */
|
|
367
|
+
function canonical(url) {
|
|
368
|
+
try {
|
|
369
|
+
const u = new URL(url);
|
|
370
|
+
u.hash = '';
|
|
371
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
372
|
+
if (/^(utm_|fbclid|gclid|ref|source|mc_cid|mc_eid)/i.test(key)) u.searchParams.delete(key);
|
|
373
|
+
}
|
|
374
|
+
return `${u.protocol}//${u.host.replace(/^www\./, '')}${u.pathname.replace(/\/$/, '')}${u.search}`.toLowerCase();
|
|
375
|
+
} catch {
|
|
376
|
+
return String(url).toLowerCase();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Search the web.
|
|
382
|
+
*
|
|
383
|
+
* Returns `{ results, engines, notes }`. `engines` names what actually answered, which
|
|
384
|
+
* matters because the quality difference between a configured API and a scrape is
|
|
385
|
+
* large and the caller should be able to say so.
|
|
386
|
+
*/
|
|
387
|
+
export async function searchWeb(query, { settings = null, limit = 8, deadlineMs = 6000 } = {}) {
|
|
388
|
+
const q = String(query || '').trim();
|
|
389
|
+
if (!q) throw new Error('No query given.');
|
|
390
|
+
|
|
391
|
+
const api = configuredApi(settings);
|
|
392
|
+
const wanted = terms(q);
|
|
393
|
+
const notes = [];
|
|
394
|
+
|
|
395
|
+
// A configured API is trusted on its own — it is a ranked answer, not a scrape, and
|
|
396
|
+
// second-guessing it with a keyword filter would throw away good results.
|
|
397
|
+
if (api) {
|
|
398
|
+
try {
|
|
399
|
+
const results = await api.run(q);
|
|
400
|
+
if (results.length) return { results: results.slice(0, limit), engines: [api.name], notes };
|
|
401
|
+
notes.push(`${api.name} returned nothing; falling back to the free engines.`);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
notes.push(`${api.name} failed (${error.message}); falling back to the free engines.`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const engines = isNews(q)
|
|
408
|
+
? [['google-news', () => googleNews(q, { headlines: isHeadlines(q) })], ['bing', () => bing(q)], ['duckduckgo', () => duckduckgo(q)]]
|
|
409
|
+
: [
|
|
410
|
+
['bing', () => bing(q)],
|
|
411
|
+
['duckduckgo', () => duckduckgo(q)],
|
|
412
|
+
['mojeek', () => mojeek(q)],
|
|
413
|
+
['startpage', () => startpage(q)],
|
|
414
|
+
// Real indexes, always asked. They are faster than every scraper here and when
|
|
415
|
+
// they answer at all they answer correctly, which is not something that can be
|
|
416
|
+
// said of a general engine's free HTML endpoint.
|
|
417
|
+
['npm', () => npmSearch(q)],
|
|
418
|
+
['github', () => githubSearch(q)],
|
|
419
|
+
['stackoverflow', () => stackoverflow(q)],
|
|
420
|
+
['mdn', () => mdn(q)],
|
|
421
|
+
...languageEngines(q),
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
// Wikipedia is deliberately NOT in the parallel set. It answers every query with
|
|
425
|
+
// something, because almost any pair of English words appears in some article, and
|
|
426
|
+
// those answers crowd out real ones — a search for a React library returned a
|
|
427
|
+
// university library. It is a fallback for when nothing else answered, not a peer.
|
|
428
|
+
const merged = new Map();
|
|
429
|
+
const answered = [];
|
|
430
|
+
const failures = [];
|
|
431
|
+
const absorb = (name, results) => {
|
|
432
|
+
if (!results?.length) { failures.push(`${name}: no results`); return; }
|
|
433
|
+
answered.push(name);
|
|
434
|
+
results.forEach((result, position) => {
|
|
435
|
+
const key = canonical(result.url);
|
|
436
|
+
const existing = merged.get(key);
|
|
437
|
+
if (existing) {
|
|
438
|
+
existing.engines.add(name);
|
|
439
|
+
existing.best = Math.min(existing.best, position);
|
|
440
|
+
existing.boost = Math.max(existing.boost || 0, result.boost || 0);
|
|
441
|
+
if (!existing.snippet && result.snippet) existing.snippet = result.snippet;
|
|
442
|
+
} else {
|
|
443
|
+
merged.set(key, { ...result, engines: new Set([name]), best: position });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// In parallel, not in sequence — sequentially, the first engine to return anything
|
|
449
|
+
// won, including when what it returned was irrelevant. Bounded by a deadline as well
|
|
450
|
+
// as by each engine's own timeout: one unreachable engine should cost a second of
|
|
451
|
+
// patience, not the full timeout, when two others have already answered.
|
|
452
|
+
let settle = () => {};
|
|
453
|
+
const enough = new Promise((resolve) => { settle = resolve; });
|
|
454
|
+
let answeredCount = 0;
|
|
455
|
+
await Promise.race([
|
|
456
|
+
Promise.allSettled(engines.map(async ([name, run]) => {
|
|
457
|
+
try {
|
|
458
|
+
absorb(name, await run());
|
|
459
|
+
// Once a few independent engines have answered there is enough to rank. Waiting
|
|
460
|
+
// for the slowest — which is usually one that is unreachable from this network
|
|
461
|
+
// and will time out — costs seconds for nothing.
|
|
462
|
+
if (++answeredCount >= 3) { const t = setTimeout(settle, 700); if (t.unref) t.unref(); }
|
|
463
|
+
} catch (error) { failures.push(`${name}: ${error.message}`); }
|
|
464
|
+
})),
|
|
465
|
+
enough,
|
|
466
|
+
new Promise((resolve) => { const t = setTimeout(resolve, deadlineMs); if (t.unref) t.unref(); }),
|
|
467
|
+
]);
|
|
468
|
+
|
|
469
|
+
const floor = coverageFloor(wanted.length);
|
|
470
|
+
const rank = (list) => list.map((r) => {
|
|
471
|
+
const { coverage, placement } = relevance(r, wanted);
|
|
472
|
+
return {
|
|
473
|
+
...r,
|
|
474
|
+
coverage,
|
|
475
|
+
// Agreement between independent engines is the strongest signal available without
|
|
476
|
+
// an API key; position within one engine's list is the weakest.
|
|
477
|
+
score: placement * 3 + coverage * 2 + (r.engines.size - 1) * 1.5 - r.best * 0.05 + (r.boost || 0),
|
|
478
|
+
};
|
|
479
|
+
}).sort((a, b) => b.score - a.score);
|
|
480
|
+
|
|
481
|
+
let scored = rank([...merged.values()]);
|
|
482
|
+
let kept = scored.filter((r) => r.coverage >= floor);
|
|
483
|
+
|
|
484
|
+
if (!kept.length) {
|
|
485
|
+
try {
|
|
486
|
+
absorb('wikipedia', await wikipedia(q));
|
|
487
|
+
scored = rank([...merged.values()]);
|
|
488
|
+
kept = scored.filter((r) => r.coverage >= floor);
|
|
489
|
+
} catch (error) { failures.push(`wikipedia: ${error.message}`); }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!merged.size) {
|
|
493
|
+
const error = new Error(`No engine returned results (${failures.join('; ')}).`);
|
|
494
|
+
error.failures = failures;
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!kept.length) {
|
|
499
|
+
// Everything missed most of the query. Return the closest few anyway — but say so,
|
|
500
|
+
// because the difference between "here are the answers" and "nothing matched, these
|
|
501
|
+
// are the nearest" is the difference between a correct answer and a confident wrong
|
|
502
|
+
// one, and the model cannot tell them apart unless it is told.
|
|
503
|
+
kept = scored.slice(0, Math.min(3, scored.length));
|
|
504
|
+
notes.push(`No result contained enough of the query to be trusted (best covered ${Math.round((scored[0]?.coverage || 0) * 100)}% of its terms). These are the closest and may be about something else — rephrase, or fetch a known URL directly.`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (failures.length) notes.push(`Engines that did not answer: ${failures.join('; ')}.`);
|
|
508
|
+
return { results: kept.slice(0, limit), engines: answered, notes };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Format for the model: numbered, with the URL on its own line so it can be copied. */
|
|
512
|
+
export function formatResults({ results, engines, notes }) {
|
|
513
|
+
const lines = results.map((r, i) => {
|
|
514
|
+
const agreement = r.engines && r.engines.size > 1 ? ` [${r.engines.size} engines agree]` : '';
|
|
515
|
+
return `${i + 1}. ${r.title}${agreement}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}`;
|
|
516
|
+
});
|
|
517
|
+
const footer = [
|
|
518
|
+
`\n(searched via ${engines.join(', ') || 'none'})`,
|
|
519
|
+
...notes.map((n) => `Note: ${n}`),
|
|
520
|
+
].join('\n');
|
|
521
|
+
return `${lines.join('\n')}\n${footer}`;
|
|
522
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ─── Talking to Jaitus ──────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The CLI is the only client of the gateway. Every failure it gets back is turned into
|
|
4
|
+
// a JaitusError here, at the edge, so no caller ever has an HTTP body in its hands to
|
|
5
|
+
// print — see errors.js for why that matters.
|
|
6
|
+
|
|
7
|
+
import { endpoints } from './config.js';
|
|
8
|
+
import { signedIn, withAuth } from './session.js';
|
|
9
|
+
import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
export class NotSignedIn extends Error {}
|
|
12
|
+
|
|
13
|
+
export function createClient() {
|
|
14
|
+
const { baseUrl } = endpoints();
|
|
15
|
+
if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
|
|
16
|
+
|
|
17
|
+
const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli' };
|
|
18
|
+
const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
baseUrl,
|
|
22
|
+
|
|
23
|
+
/** POST /v1/messages. Returns the raw Response so the caller can stream it. */
|
|
24
|
+
async messages(body, { signal } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
return await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: headers(token),
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
signal,
|
|
31
|
+
}));
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
if (cause?.name === 'JaitusError' || cause?.name === 'AbortError') throw cause;
|
|
34
|
+
throw fromNetwork(cause);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/** A dashboard API call, returning parsed JSON. */
|
|
39
|
+
async api(pathname, init = {}) {
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
|
|
43
|
+
...init,
|
|
44
|
+
headers: { ...headers(token), ...(init.headers || {}) },
|
|
45
|
+
}));
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
if (cause?.name === 'JaitusError') throw cause;
|
|
48
|
+
throw fromNetwork(cause);
|
|
49
|
+
}
|
|
50
|
+
const body = await res.json().catch(() => ({}));
|
|
51
|
+
return { ok: res.ok, status: res.status, body };
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Model ids the gateway advertises. */
|
|
55
|
+
async models() {
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token) }));
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
if (cause?.name === 'JaitusError') throw cause;
|
|
61
|
+
throw fromNetwork(cause);
|
|
62
|
+
}
|
|
63
|
+
const body = await res.json().catch(() => ({}));
|
|
64
|
+
if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
|
|
65
|
+
const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
|
|
66
|
+
// An empty list is a service fault, not something the user can fix — and naming
|
|
67
|
+
// the gateway's model map to them would be naming our internals.
|
|
68
|
+
if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
|
|
69
|
+
return models;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// ─── Background shell jobs (§16) ───────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Long-running commands (dev servers, builds, watchers) run detached with
|
|
4
|
+
// output streaming to a per-job log file. The agent keeps working and polls
|
|
5
|
+
// with background_read; /tasks lists jobs with attach/stop. `! cmd &` and
|
|
6
|
+
// Ctrl+B start one from the prompt; background_run does it from the model.
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const jobs = new Map();
|
|
14
|
+
let seq = 0;
|
|
15
|
+
|
|
16
|
+
function logDir() {
|
|
17
|
+
const d = path.join(os.homedir(), '.jaitus', 'background');
|
|
18
|
+
fs.mkdirSync(d, { recursive: true });
|
|
19
|
+
return d;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function startBackground(command, cwd) {
|
|
23
|
+
const id = `bg${++seq}-${Date.now().toString(36)}`;
|
|
24
|
+
const log = path.join(logDir(), `${id}.log`);
|
|
25
|
+
const out = fs.openSync(log, 'w');
|
|
26
|
+
const child = spawn(command, {
|
|
27
|
+
cwd, shell: true, stdio: ['ignore', out, out],
|
|
28
|
+
detached: process.platform !== 'win32',
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
});
|
|
31
|
+
const job = { id, command, pid: child.pid || null, log, startedAt: Date.now(), exit: null };
|
|
32
|
+
child.on('error', (e) => {
|
|
33
|
+
try { fs.appendFileSync(log, `\n[failed to start: ${e.message}]\n`); } catch { /* log gone */ }
|
|
34
|
+
job.exit = 'error';
|
|
35
|
+
});
|
|
36
|
+
child.on('close', (code) => {
|
|
37
|
+
job.exit = code ?? 'signal';
|
|
38
|
+
try { fs.appendFileSync(log, `\n[exit ${job.exit}]\n`); } catch { /* log gone */ }
|
|
39
|
+
});
|
|
40
|
+
try { child.unref(); } catch { /* stay attached */ }
|
|
41
|
+
job.child = child;
|
|
42
|
+
jobs.set(id, job);
|
|
43
|
+
return { id, pid: job.pid, log };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function listBackground() {
|
|
47
|
+
return [...jobs.values()].map((j) => ({
|
|
48
|
+
id: j.id, command: j.command, pid: j.pid,
|
|
49
|
+
running: j.exit === null, startedAt: j.startedAt,
|
|
50
|
+
exit: j.exit === null ? null : j.exit,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findJob(id) {
|
|
55
|
+
const direct = jobs.get(id);
|
|
56
|
+
if (direct) return direct;
|
|
57
|
+
const prefix = [...jobs.values()].filter((j) => j.id.startsWith(id));
|
|
58
|
+
return prefix.length === 1 ? prefix[0] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readBackground(id, tail = 40) {
|
|
62
|
+
const job = findJob(id);
|
|
63
|
+
if (!job) return null;
|
|
64
|
+
let lines = [];
|
|
65
|
+
try {
|
|
66
|
+
lines = fs.readFileSync(job.log, 'utf8').split('\n');
|
|
67
|
+
} catch { /* empty so far */ }
|
|
68
|
+
return {
|
|
69
|
+
id: job.id, command: job.command, running: job.exit === null, exit: job.exit,
|
|
70
|
+
tail: lines.slice(-Math.max(1, tail)).join('\n').slice(-6000) || '(no output yet)',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function stopBackground(id) {
|
|
75
|
+
const job = findJob(id);
|
|
76
|
+
if (!job) return false;
|
|
77
|
+
if (job.exit !== null) return true;
|
|
78
|
+
try {
|
|
79
|
+
job.child?.kill();
|
|
80
|
+
if (job.child?.pid && process.platform !== 'win32') {
|
|
81
|
+
try { process.kill(-job.child.pid); } catch { /* already gone */ }
|
|
82
|
+
}
|
|
83
|
+
} catch { /* already gone */ }
|
|
84
|
+
return true;
|
|
85
|
+
}
|