pi-wsearch 0.1.0 → 0.2.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/CHANGELOG.md +62 -0
- package/README.md +4 -4
- package/package.json +13 -2
- package/src/cache.ts +10 -11
- package/src/config.ts +2 -0
- package/src/engines.ts +33 -4
- package/src/fuse.ts +33 -0
- package/src/keys.ts +4 -1
- package/src/scrape.ts +395 -38
- package/src/tools.ts +159 -13
- package/src/transport.ts +229 -16
- package/src/urls.ts +20 -1
- package/justfile +0 -31
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Over-cap sections split into addressable `(cont. N)` outline entries; no section tail is ever unreachable.
|
|
10
|
+
- Promo/nag blocks (premium upsells, newsletter signups) filtered from outline and section bodies.
|
|
11
|
+
- Adjacent verbatim-duplicate paragraphs dropped.
|
|
12
|
+
- Headingless pages get a synthesized `(lead)` outline entry.
|
|
13
|
+
- Heading-only stub sections (page chrome like "## Latest commit", "### Stars" with no body) dropped — a real section carries prose under its heading.
|
|
14
|
+
- GitHub repo pages serve the raw README under the original URL (repo pages are ~90% UI chrome); filename candidates tried in order, graceful fall-through to the normal page path on failure; non-root paths (blob/tree/issues) unaffected.
|
|
15
|
+
- Local extraction parity with paid readers: HTML tables → markdown pipe tables (structure preserved instead of flattened to word soup); MathML formulas → embedded TeX when annotated; KaTeX span soup (client-rendered, no TeX source in HTML) dropped instead of linearizing into scrambled fragments.
|
|
16
|
+
- News-page hard-sample cleanup: per-line junk filtering (photo credits like "Chip Somodevilla / Getty Images file", orphan "38d ago" card fragments, IE-browser nags, "Skip to Content" links, "FOR SUBSCRIBERS" nags), ALL-CAPS card kickers, empty `##` heading debris, bullet leader-line collapsing, and `<title>`-echo blocks dropped — NBC/CBS-class hub pages now open directly on their story list.
|
|
17
|
+
- Broader hard-sample sweep (forums, docs SPAs, helpdesks, recipes, e-commerce): newsletter signup modules ("#### Email Newsletter" + email-field trust lines) dropped; access-notice pages (People Inc-style HTTP 402 with an HTML notice) treated as bot-walls — UA retry + chain fallback, and their notice text can never pass as content.
|
|
18
|
+
- Keyless jina reader tier: runs without a key when jina is explicitly named in `WSEARCH_FETCH_CHAIN` (opt-in, rate-limited ~20 req/min); a configured key also enables it.
|
|
19
|
+
- Reader backends for kagi (Universal Summarizer — last in chain: lossy summaries never pre-empt full extraction; covers PDFs/office docs/YouTube), TinyFish (Fetch API, free at any balance), serper (Webpage Scrape) and You.com (Contents API) — key-gated, docs-verified contracts.
|
|
20
|
+
- Junk list covers social-syndication mirrors (twiscan, twstalker, unrollnow, threadreaderapp, nitter, upd.dev); any numeric `status|thread/<id>` path on any host is junk (uniform rule, no favored-host allowlist).
|
|
21
|
+
- `engines_errored` structured field plus `⚠ engines:` diagnostics line on search output; engines with a stable 401/402/403 error are skipped for the rest of the process (memo cleared on `/websearch login|logout`).
|
|
22
|
+
- Reader-chain exhaustion now reports every backend's error, not just the last one.
|
|
23
|
+
- Intrinsic BM25-flavored lexical rescoring in the fuse stage (title hits 2x, IDF-lite rarity via token length, bounded 0.25 multiplier): single-engine hits with strong query overlap reorder near-ties; multi-engine agreement still dominates. Deterministic, model-free.
|
|
24
|
+
- `/websearch` onboarding lists jina's free tier among the cheapest paths.
|
|
25
|
+
- Observe-only fetch-through ledger (`wsearch/fetchtrace.jsonl`): search records each result's rank/engine-agreement; a successful first fetch of a search-surfaced URL appends the tuple (query, rank, engines, url). Pure measurement — zero behavior change — the ground-truth layer for judging future ranking changes (top-3 fetch-decision precision).
|
|
26
|
+
- URL canonicalization depth-up: paths case-folded and duplicate slashes collapsed (server-equivalent spellings no longer split RRF agreement across "different" URLs).
|
|
27
|
+
- Mojibake detection and repair in the byte-decode path: UTF-8-read-as-CP1252 double-encoding (declared charset lies, upstream proxy mangling) is detected by signature density and re-decoded; clean and declared-latin1 pages pass through untouched.
|
|
28
|
+
- Stale-if-error cache reserve: expired entries survive as a reserve instead of being purged; when a previously-readable page's live fetch fails (wall tightened, host down), the expired copy is served with a `[STALE — ~Nh old]` title marker rather than an error. `get()` never serves expired copies; the reserve is replaced on overwrite and capped by the LRU cap.
|
|
29
|
+
- FineWeb/Gopher repetition gates on extracted text: >30% duplicate lines or >20% repeated unique 5-grams marks the output low-quality (catches link-farms and menu-soup that survive every pattern filter; published thresholds, not invented ones).
|
|
30
|
+
- Conditional revalidation (ETag/If-None-Match, Last-Modified/If-Modified-Since): cached copies store their validators; an expired copy revalidates before re-download — a 304 re-marks it fresh for free (local path only; reader-chain results carry no validators).
|
|
31
|
+
- Fetched pages now carry `publishedAt` (JSON-LD datePublished → article:published_time meta → <time datetime>, first match wins) and a `paywalled: true` flag when soft-paywall overlay signatures (fc-ab-root, metering-modal, isAccessibleForFree:false) are present — detection only, never bypass.
|
|
32
|
+
- GitHub blob URLs (single source files) serve the raw file content under the original URL, same contract as the README preference — the repo-page path buried files under navigation UI.
|
|
33
|
+
- Cookie-consent banner boilerplate ("We use cookies to improve…", "By continuing to browse…", "Accept all", "Manage preferences") filtered.
|
|
34
|
+
- Page-wide duplicate-paragraph removal: paragraphs repeated verbatim anywhere on a page are dropped from their later occurrences (adjacent-dedupe caught only back-to-back copies; hub pages re-serve card text across sections — pure token waste).
|
|
35
|
+
- Accept header prefers `text/markdown` over HTML: sites with server-side content negotiation (agent-readability ecosystem) now hand over their clean markdown rendition; non-negotiating servers ignore the header entirely.
|
|
36
|
+
- Zero-result recovery: when a search returns nothing, one automatic retry runs with operators stripped (`site:`, quotes, `-term`) or the rarest term dropped — the agent no longer burns a round-trip to do it manually; the retry is noted in the output.
|
|
37
|
+
- `site:` operator normalization: routed to native domain-filter params where supported (tavily `include_domains`, exa `includeDomains`), passed through verbatim for engines with native operator support (serper, brave, kagi) — no more diluting domain-restricted queries into full-web text search.
|
|
38
|
+
- Search result titles strip the site-name suffix ("Page Title | Site Name") — display noise and title-token pollution.
|
|
39
|
+
- Search results display a normalized date (ISO, from engine date fields incl. relative forms like "3 days ago") when the engine provides one.
|
|
40
|
+
- Archive fallback for dead pages: a 404 (dead host, removed docs) tries archive.org's Wayback availability API and serves the snapshot through the normal local path, title-marked `[ARCHIVED copy <date> — live page is gone]`. Doctrine line: 404/dead-host only, never 403-walls; best-effort (lookup failure → plain error as before).
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Fetch cache key bumped to `v3` (extraction pipeline shape changed).
|
|
45
|
+
- `OUTLINE_CAP` raised from 14 to 20.
|
|
46
|
+
- Reader chain default order: jina (free tier) → tinyfish (free at any balance) → firecrawl → tavily → exa → serper → you → kagi (lossy summarizer last).
|
|
47
|
+
- Permanent local-failure guard narrowed to 404/oversized/empty: 403 bot-walls now advance to the chain (renderers exist to read them); `r.jina.ai`/`s.jina.ai` proxy URLs never enter the chain.
|
|
48
|
+
- Search engines pin `WSEARCH_ENGINES` and readers pin `WSEARCH_FETCH_CHAIN` at runtime as before; chain status display mirrors the new default order.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- `splitSection` infinite loop on space-free runs (base64 blobs, packed table cells) — cut enforces a hard progress floor.
|
|
53
|
+
- Dropped tiny preambles (e.g. leaked `<title>` text) no longer shift heading names off by one.
|
|
54
|
+
- Reader chain was spent on permanent local failures (404/oversized/empty URLs) — now returns the real status instantly instead of burning paid backends.
|
|
55
|
+
- Jina reader gate contradicted its keyless-capable read implementation (blocked keyless reads despite tolerant code) — gate aligned to the explicit opt-in.
|
|
56
|
+
- Dead engines (e.g. firecrawl with exhausted credits) dragged every search fan-out ~7s — skipped after the first stable failure.
|
|
57
|
+
- `/websearch off|on` withdrew/restored tools at registration only, leaving them declared to the model until a manual reload — the active tool set is now synced via `pi.setActiveTools()` so the change applies on the next model request.
|
|
58
|
+
- Fetching `r.jina.ai/<target>` sent paid renderers at a proxy URL (a renderer cannot render a proxy) — now fails fast with the proxy's own status.
|
|
59
|
+
|
|
60
|
+
### Removed
|
|
61
|
+
|
|
62
|
+
- `CAP_BREAK_GRACE` boundary heuristic (subsumed by the progress floor).
|
package/README.md
CHANGED
|
@@ -34,10 +34,10 @@ you to find the signal. `web.fetch` breaks any page into an outline of every
|
|
|
34
34
|
section and answers by number: `{url, sections: [0, 3]}` fetches just two
|
|
35
35
|
sections of the fetched page. The agent surveys the map, picks what matters,
|
|
36
36
|
and reads only those. No
|
|
37
|
-
full reads, no 40 KB dumps.
|
|
38
|
-
|
|
39
|
-
capped; the final response is clipped.
|
|
40
|
-
|
|
37
|
+
full reads, no 40 KB dumps. Sections over 3,000 characters split into
|
|
38
|
+
addressable continuation entries (`Section (cont. 1)`), so no tail is ever
|
|
39
|
+
unreachable; pages that overflow are capped; the final response is clipped.
|
|
40
|
+
Every cut is marked — nothing is silently lost.
|
|
41
41
|
|
|
42
42
|
**Reads are free.** Page parsing is built in, nothing billed per read, no key
|
|
43
43
|
required. Real headings become the map, TOC echoes get dropped, so reads come
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-wsearch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi extension: web.search + web.fetch",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,5 +59,16 @@
|
|
|
59
59
|
"oxlint": "^1.86.0",
|
|
60
60
|
"typescript": "^7.0.2"
|
|
61
61
|
},
|
|
62
|
-
"dependencies": {}
|
|
62
|
+
"dependencies": {},
|
|
63
|
+
"main": "index.ts",
|
|
64
|
+
"files": [
|
|
65
|
+
"index.ts",
|
|
66
|
+
"src/",
|
|
67
|
+
"README.md",
|
|
68
|
+
"LICENSE",
|
|
69
|
+
"CHANGELOG.md"
|
|
70
|
+
],
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public"
|
|
73
|
+
}
|
|
63
74
|
}
|
package/src/cache.ts
CHANGED
|
@@ -28,11 +28,6 @@ export class TtlCache {
|
|
|
28
28
|
const raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
29
29
|
if (raw && typeof raw === 'object' && typeof raw.entries === 'object') {
|
|
30
30
|
this.store = raw;
|
|
31
|
-
const now = Date.now();
|
|
32
|
-
// purge expired entries AND their order slots: dead keys must not grow across restarts
|
|
33
|
-
for (const k of Object.keys(this.store.entries)) {
|
|
34
|
-
if ((this.store.entries[k]?.exp ?? 0) <= now) delete this.store.entries[k];
|
|
35
|
-
}
|
|
36
31
|
this.store.order = this.store.order.filter((k) => this.store.entries[k] !== undefined);
|
|
37
32
|
}
|
|
38
33
|
} catch {
|
|
@@ -51,17 +46,21 @@ export class TtlCache {
|
|
|
51
46
|
}
|
|
52
47
|
get(key: string): unknown | undefined {
|
|
53
48
|
const e = this.store.entries[key];
|
|
54
|
-
if (!e) return undefined;
|
|
55
|
-
if (e.exp < Date.now()) {
|
|
56
|
-
delete this.store.entries[key];
|
|
57
|
-
this.store.order = this.store.order.filter((k) => k !== key);
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
49
|
+
if (!e || e.exp < Date.now()) return undefined;
|
|
60
50
|
const i = this.store.order.indexOf(key);
|
|
61
51
|
if (i >= 0) this.store.order.splice(i, 1);
|
|
62
52
|
this.store.order.push(key);
|
|
63
53
|
return e.value;
|
|
64
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Expired copy WITHOUT deleting it (stale-if-error reserve). */
|
|
57
|
+
peekStale(key: string): { value: unknown; ageHours: number } | undefined {
|
|
58
|
+
const e = this.store.entries[key];
|
|
59
|
+
if (!e) return undefined;
|
|
60
|
+
const ageHours = (Date.now() - e.exp) / 3600_000;
|
|
61
|
+
if (ageHours <= 0) return undefined;
|
|
62
|
+
return { value: e.value, ageHours };
|
|
63
|
+
}
|
|
65
64
|
set(key: string, value: unknown, ttlMs: number) {
|
|
66
65
|
this.store.entries[key] = { exp: Date.now() + ttlMs, value };
|
|
67
66
|
const i = this.store.order.indexOf(key);
|
package/src/config.ts
CHANGED
|
@@ -181,6 +181,8 @@ function wsearchDir(): string {
|
|
|
181
181
|
return process.env.WSEARCH_CACHE_DIR ?? join(homedir(), '.pi', 'agent', 'wsearch');
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
export { wsearchDir };
|
|
185
|
+
|
|
184
186
|
let cached: WebConfig | undefined;
|
|
185
187
|
let origins: Record<string, 'env' | 'wsearch' | 'shell'> = {};
|
|
186
188
|
export function resetConfigCache(): void {
|
package/src/engines.ts
CHANGED
|
@@ -75,6 +75,18 @@ async function request<T>(
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function extractSiteOperator(query: string): { query: string; domain?: string } {
|
|
79
|
+
const m = /(?:^|\s)site:([a-z0-9.-]+[a-z0-9])(?:\s|$)/i.exec(query);
|
|
80
|
+
if (!m) return { query };
|
|
81
|
+
return {
|
|
82
|
+
query: query
|
|
83
|
+
.replace(m[0], ' ')
|
|
84
|
+
.replace(/\s{2,}/g, ' ')
|
|
85
|
+
.trim(),
|
|
86
|
+
domain: m[1].toLowerCase(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
async function serper(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
79
91
|
const t0 = Date.now();
|
|
80
92
|
try {
|
|
@@ -104,12 +116,14 @@ async function serper(key: string, opts: SearchOptions, signal?: AbortSignal): P
|
|
|
104
116
|
async function tavily(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
105
117
|
const t0 = Date.now();
|
|
106
118
|
try {
|
|
119
|
+
const { query, domain } = extractSiteOperator(opts.query);
|
|
107
120
|
const body: Record<string, unknown> = {
|
|
108
|
-
query
|
|
121
|
+
query,
|
|
109
122
|
max_results: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
110
123
|
search_depth: 'basic',
|
|
111
124
|
topic: 'general',
|
|
112
125
|
};
|
|
126
|
+
if (domain) body.include_domains = [domain];
|
|
113
127
|
// Docs: auth = Authorization: Bearer header
|
|
114
128
|
const d = await postJson<{
|
|
115
129
|
results?: { title?: string; url?: string; content?: string; published_date?: string }[];
|
|
@@ -132,11 +146,13 @@ async function tavily(key: string, opts: SearchOptions, signal?: AbortSignal): P
|
|
|
132
146
|
async function exa(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
133
147
|
const t0 = Date.now();
|
|
134
148
|
try {
|
|
149
|
+
const { query, domain } = extractSiteOperator(opts.query);
|
|
135
150
|
const body: Record<string, unknown> = {
|
|
136
|
-
query
|
|
151
|
+
query,
|
|
137
152
|
numResults: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
138
153
|
contents: { highlights: true },
|
|
139
154
|
};
|
|
155
|
+
if (domain) body.includeDomains = [domain];
|
|
140
156
|
const d = await postJson<{
|
|
141
157
|
results?: { title?: string; url?: string; highlights?: string[]; publishedDate?: string }[];
|
|
142
158
|
}>('https://api.exa.ai/search', body, jsonHeaders({ 'x-api-key': key }), signal);
|
|
@@ -360,6 +376,13 @@ function parseEngineList(raw: string | undefined): EngineName[] | undefined {
|
|
|
360
376
|
return out.length ? out : undefined;
|
|
361
377
|
}
|
|
362
378
|
|
|
379
|
+
const deadEngines = new Set<string>();
|
|
380
|
+
const STABLE_ENGINE_ERROR = /HTTP 40[123]/;
|
|
381
|
+
|
|
382
|
+
export function clearDeadEngines(): void {
|
|
383
|
+
deadEngines.clear();
|
|
384
|
+
}
|
|
385
|
+
|
|
363
386
|
function selectEngines(opts: SearchOptions): EngineDef[] {
|
|
364
387
|
const cfg = getConfig();
|
|
365
388
|
const wanted = opts.engines ?? parseEngineList(cfg.searchEngines);
|
|
@@ -372,7 +395,7 @@ function selectEngines(opts: SearchOptions): EngineDef[] {
|
|
|
372
395
|
|
|
373
396
|
export async function runEngines(opts: SearchOptions): Promise<EngineOutcome[]> {
|
|
374
397
|
const cfg = getConfig();
|
|
375
|
-
const chosen = selectEngines(opts);
|
|
398
|
+
const chosen = selectEngines(opts).filter((def) => !deadEngines.has(def.id));
|
|
376
399
|
if (chosen.length === 0) {
|
|
377
400
|
const wanted = opts.engines ?? parseEngineList(getConfig().searchEngines);
|
|
378
401
|
const error = wanted?.length
|
|
@@ -380,5 +403,11 @@ export async function runEngines(opts: SearchOptions): Promise<EngineOutcome[]>
|
|
|
380
403
|
: 'no search engine keys configured; run /websearch to add one';
|
|
381
404
|
return [{ engine: 'none', hits: [], error, latencyMs: 0 }];
|
|
382
405
|
}
|
|
383
|
-
return Promise.all(
|
|
406
|
+
return Promise.all(
|
|
407
|
+
chosen.map(async (def) => {
|
|
408
|
+
const outcome = await def.search(cfg[def.key] as string, opts, opts.signal);
|
|
409
|
+
if (outcome.error && STABLE_ENGINE_ERROR.test(outcome.error)) deadEngines.add(def.id);
|
|
410
|
+
return outcome;
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
384
413
|
}
|
package/src/fuse.ts
CHANGED
|
@@ -148,6 +148,36 @@ function exactMatchBoost(hit: { title: string; snippet: string }, query: string
|
|
|
148
148
|
return 0.3 * Math.min(1, matched / terms.length);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
function bm25Score(hit: { title: string; snippet: string }, terms: string[]): number {
|
|
152
|
+
if (!terms.length) return 0;
|
|
153
|
+
const title = hit.title.toLowerCase();
|
|
154
|
+
const snip = hit.snippet.toLowerCase();
|
|
155
|
+
let score = 0;
|
|
156
|
+
for (const t of terms) {
|
|
157
|
+
const tfTitle = countTerm(t, title);
|
|
158
|
+
const tfSnip = countTerm(t, snip);
|
|
159
|
+
if (!tfTitle && !tfSnip) continue;
|
|
160
|
+
const idf = 1 + Math.min(1, 12 / Math.max(t.length, 1));
|
|
161
|
+
const saturate = (tf: number) => (tf * 2.2) / (tf + 1.2);
|
|
162
|
+
score += idf * (2 * saturate(tfTitle) + saturate(tfSnip));
|
|
163
|
+
}
|
|
164
|
+
return score;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function countTerm(term: string, text: string): number {
|
|
168
|
+
let n = 0;
|
|
169
|
+
let i = 0;
|
|
170
|
+
for (;;) {
|
|
171
|
+
const m = text.indexOf(term, i);
|
|
172
|
+
if (m < 0) break;
|
|
173
|
+
const before = m === 0 ? ' ' : text[m - 1];
|
|
174
|
+
const after = m + term.length >= text.length ? ' ' : text[m + term.length];
|
|
175
|
+
if (!/[a-z0-9]/.test(before) && !/[a-z0-9]/.test(after)) n++;
|
|
176
|
+
i = m + term.length;
|
|
177
|
+
}
|
|
178
|
+
return n;
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
function normTitle(s: string): string {
|
|
152
182
|
return s
|
|
153
183
|
.toLowerCase()
|
|
@@ -188,6 +218,7 @@ function collapseNearDuplicates(fused: FusedHit[]): FusedHit[] {
|
|
|
188
218
|
|
|
189
219
|
export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHit[] {
|
|
190
220
|
const { query } = opts;
|
|
221
|
+
const terms = query ? queryTerms(query) : [];
|
|
191
222
|
const rank: Record<string, { hit: SearchHit; ranks: { engine: string; rank: number }[] }> = {};
|
|
192
223
|
let answering = 0;
|
|
193
224
|
for (const oc of outcomes) {
|
|
@@ -212,6 +243,8 @@ export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHi
|
|
|
212
243
|
const consensus = answering > 0 ? ranks.length / answering : 0;
|
|
213
244
|
let score = rrf + CONSENSUS_TIEBREAK * consensus;
|
|
214
245
|
score *= 1 + exactMatchBoost(hit, query);
|
|
246
|
+
const bm25 = bm25Score(hit, terms);
|
|
247
|
+
if (bm25 > 0) score *= 1 + Math.min(0.25, 0.045 * bm25);
|
|
215
248
|
if (hit.snippet.trim().length < 30) score *= 0.92;
|
|
216
249
|
if (junk) score *= 0.25;
|
|
217
250
|
fused.push({
|
package/src/keys.ts
CHANGED
|
@@ -83,7 +83,9 @@ export function statusText(): string {
|
|
|
83
83
|
armed.length ? ` armed: ${armed.map((id) => `${id} ✓(${origins[id] ?? '?'})`).join(', ')}` : ' armed: (none)',
|
|
84
84
|
);
|
|
85
85
|
lines.push(` dormant: ${dormant.length ? dormant.join(', ') : '(none)'}`);
|
|
86
|
-
const chain = cfg.fetchChain
|
|
86
|
+
const chain = cfg.fetchChain
|
|
87
|
+
? cfg.fetchChain.replace(/,/g, ', ')
|
|
88
|
+
: 'jina, tinyfish, firecrawl, tavily, exa, serper, you, kagi';
|
|
87
89
|
lines.push(` fetch: local → ${chain}`);
|
|
88
90
|
const knobs = [];
|
|
89
91
|
if (cfg.searchEngines) knobs.push(`WSEARCH_ENGINES=${cfg.searchEngines}`);
|
|
@@ -97,6 +99,7 @@ export function statusText(): string {
|
|
|
97
99
|
lines.push(' · exa → exa.ai (1,000 free/mo)');
|
|
98
100
|
lines.push(' · firecrawl → firecrawl.dev (1,000 free credits/mo)');
|
|
99
101
|
lines.push(' · tinyfish → tinyfish.ai (free at any balance)');
|
|
102
|
+
lines.push(' · jina → jina.ai (free tier, search + reader)');
|
|
100
103
|
lines.push(' 2. /websearch login, saved to wsearch/env');
|
|
101
104
|
lines.push(' 3. run /websearch again; it should appear under "armed"');
|
|
102
105
|
}
|