pi-wsearch 0.1.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 +21 -0
- package/README.md +147 -0
- package/index.ts +8 -0
- package/justfile +31 -0
- package/package.json +63 -0
- package/src/cache.ts +91 -0
- package/src/config.ts +263 -0
- package/src/constants.ts +6 -0
- package/src/engines.ts +384 -0
- package/src/fuse.ts +240 -0
- package/src/keys.ts +104 -0
- package/src/scrape.ts +490 -0
- package/src/tools.ts +527 -0
- package/src/transport.ts +389 -0
- package/src/urls.ts +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 K2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# pi-wsearch: web tools for pi
|
|
2
|
+
|
|
3
|
+
## Not the web tools you've used
|
|
4
|
+
|
|
5
|
+
### web.search
|
|
6
|
+
|
|
7
|
+
**One search, many judges.** Most web tools hand you a single engine's
|
|
8
|
+
ranking. `web.search` runs every engine you've configured in parallel and
|
|
9
|
+
merges their results by reciprocal rank fusion (RRF). A hit only earns its
|
|
10
|
+
place by surviving several independent judgments. The response shows how
|
|
11
|
+
many: `← 3 engines agree` means three separate indexes ranked that page.
|
|
12
|
+
Agreement is the spam defense; generative-SEO can stuff one index, not three.
|
|
13
|
+
|
|
14
|
+
**Pick your engines.** Nine are supported; three or four is plenty. Use
|
|
15
|
+
whatever keys you have, even one, because what follows doesn't depend on how
|
|
16
|
+
many engines answer:
|
|
17
|
+
|
|
18
|
+
- **Rare strings match literally.** `pgvector`, CVE numbers, error codes match
|
|
19
|
+
as exact words, not fuzzy prose.
|
|
20
|
+
- **Junk is downranked, not censored.** Known junk domains and thin snippets
|
|
21
|
+
lose points at scoring time.
|
|
22
|
+
- **Same page, one entry.** Tracking-param variants and near-duplicate titles
|
|
23
|
+
collapse into one row.
|
|
24
|
+
- **Two results per domain, max.** One site's SEO army can't flood the
|
|
25
|
+
answers.
|
|
26
|
+
|
|
27
|
+
**Supported providers:** Serper, Tavily, Exa, Brave, Jina, Kagi, You.com,
|
|
28
|
+
Firecrawl, TinyFish
|
|
29
|
+
|
|
30
|
+
### web.fetch
|
|
31
|
+
|
|
32
|
+
**Fetch reads a map, not a dump.** Most web tools hand you raw HTML and tell
|
|
33
|
+
you to find the signal. `web.fetch` breaks any page into an outline of every
|
|
34
|
+
section and answers by number: `{url, sections: [0, 3]}` fetches just two
|
|
35
|
+
sections of the fetched page. The agent surveys the map, picks what matters,
|
|
36
|
+
and reads only those. No
|
|
37
|
+
full reads, no 40 KB dumps. Truncation is layered: sections over 3,000
|
|
38
|
+
characters are cut with the tail marked unreachable; pages that overflow are
|
|
39
|
+
capped; the final response is clipped. Every cut is marked; the truncated
|
|
40
|
+
part is named, not lost.
|
|
41
|
+
|
|
42
|
+
**Reads are free.** Page parsing is built in, nothing billed per read, no key
|
|
43
|
+
required. Real headings become the map, TOC echoes get dropped, so reads come
|
|
44
|
+
back as clean as a paid one at zero cost. Paid readers only step in for
|
|
45
|
+
JavaScript heavy pages or when the local read fails.
|
|
46
|
+
|
|
47
|
+
**Tracking links cache as one page.** `?utm_source=…` variants of the same URL
|
|
48
|
+
share one 24 hour cache entry. Faster repeats, smaller disk.
|
|
49
|
+
|
|
50
|
+
**Guarded against your localhost.** A fetched page can't aim the agent at
|
|
51
|
+
your own machine: private and loopback addresses (hex encoded, shorthand, and
|
|
52
|
+
mapped IPv6 forms included) are rejected before any request is made.
|
|
53
|
+
|
|
54
|
+
**Distrust is the default.** The agent is prompted to treat fetched content
|
|
55
|
+
as untrusted input: a claim only becomes a verdict after a second source
|
|
56
|
+
corroborates it.
|
|
57
|
+
|
|
58
|
+
**Optional readers:** Firecrawl, Tavily, Exa, Jina, fallback only
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
From npm:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pi install npm:pi-wsearch
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or from git:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
pi install git:github.com/k3-2o/pi-wsearch
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Either way pi picks up the package on next start and registers `web.search`,
|
|
75
|
+
`web.fetch`, and the `/websearch` command. To try it once without installing:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
pi -e npm:pi-wsearch
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Keys
|
|
82
|
+
|
|
83
|
+
Search needs at least one provider key; fetch needs none. Two ways to set one:
|
|
84
|
+
|
|
85
|
+
- **In your shell.** Export it in the terminal session running pi, or park it
|
|
86
|
+
in your shell config. The extension reads `.zshrc`, `.zprofile`, `.bashrc`,
|
|
87
|
+
`.profile`, fish config, and nushell env based on your `$SHELL` at startup,
|
|
88
|
+
so a key in any of those is discovered on its own.
|
|
89
|
+
- **Via `/websearch`.** Pick a provider from the list, paste the key. It's
|
|
90
|
+
written to `~/.pi/agent/wsearch/env` with 0600 permissions.
|
|
91
|
+
|
|
92
|
+
Either place accepts a `!command` value, so the key can come from Keychain or
|
|
93
|
+
1Password at use time and never sit on disk. Session env wins over the file,
|
|
94
|
+
the file over shell config. `/websearch` shows which providers are armed and
|
|
95
|
+
which are dormant. No keys yet? Tavily, Exa, Firecrawl, and TinyFish all have
|
|
96
|
+
free tiers.
|
|
97
|
+
|
|
98
|
+
## Commands
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
One command, five subcommands:
|
|
102
|
+
|
|
103
|
+
| Command | Does |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `/websearch` | Status: what's armed, what's dormant, where each key lives. |
|
|
106
|
+
| `/websearch login [provider]` | Store a key: pick from the provider list, paste it, confirm. Accepts `!command` values. |
|
|
107
|
+
| `/websearch logout [provider]` | Remove a key from `wsearch/env`. Keys set in your shell or env are reported, but can't be removed from here. |
|
|
108
|
+
| `/websearch off` | Hide both tools from the agent until you turn them back on. |
|
|
109
|
+
| `/websearch on` | Bring them back. |
|
|
110
|
+
|
|
111
|
+
> [!NOTE]
|
|
112
|
+
> Two knobs also live in `wsearch/env` (or your shell env, same precedence as
|
|
113
|
+
> keys). `WSEARCH_ENGINES=tavily,exa` makes search use only Tavily and Exa,
|
|
114
|
+
> with Tavily consulted first. `WSEARCH_FETCH_CHAIN=tavily,jina` makes failed
|
|
115
|
+
> reads try Tavily first, then Jina, skipping the rest.
|
|
116
|
+
|
|
117
|
+
## Quick start
|
|
118
|
+
|
|
119
|
+
1. Get a key from any provider: Tavily, Exa, and Firecrawl have free tiers.
|
|
120
|
+
2. Set it one of three ways:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
/websearch login # pick a provider, paste the key
|
|
124
|
+
/websearch login tavily # or name it directly
|
|
125
|
+
export TAVILY_API_KEY=... # or just the terminal session
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Shell config works too (.zshrc, .bashrc, fish config): park the export
|
|
129
|
+
there and the extension finds it on startup.
|
|
130
|
+
3. Verify:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
/websearch
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Your provider should appear under `armed:`.
|
|
137
|
+
4. Ask the agent something current:
|
|
138
|
+
|
|
139
|
+
> What's the current Node.js LTS version?
|
|
140
|
+
|
|
141
|
+
It will `web.search` it, `web.fetch` the answer's page by section, and
|
|
142
|
+
cite where each claim came from.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
147
|
+
|
package/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Entry: registers web.search + web.fetch (wiring lives in tools.ts). */
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { registerWebTools, registerWebCommand } from './src/tools';
|
|
4
|
+
|
|
5
|
+
export default function (pi: ExtensionAPI) {
|
|
6
|
+
registerWebTools(pi);
|
|
7
|
+
registerWebCommand(pi);
|
|
8
|
+
}
|
package/justfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export PATH := "node_modules/.bin:" + env_var_or_default("PATH", "")
|
|
2
|
+
|
|
3
|
+
default: check
|
|
4
|
+
|
|
5
|
+
check: lint format-check typecheck knip test
|
|
6
|
+
|
|
7
|
+
lint:
|
|
8
|
+
oxlint
|
|
9
|
+
|
|
10
|
+
format:
|
|
11
|
+
oxfmt
|
|
12
|
+
|
|
13
|
+
format-check:
|
|
14
|
+
oxfmt --check
|
|
15
|
+
|
|
16
|
+
typecheck:
|
|
17
|
+
tsc --noEmit
|
|
18
|
+
|
|
19
|
+
knip:
|
|
20
|
+
knip
|
|
21
|
+
|
|
22
|
+
coverage:
|
|
23
|
+
bun test --jobs 1 test/ --coverage
|
|
24
|
+
|
|
25
|
+
test:
|
|
26
|
+
bun test --jobs 1 test/
|
|
27
|
+
|
|
28
|
+
install:
|
|
29
|
+
mkdir -p $HOME/.pi/agent/extensions/pi-wsearch/src
|
|
30
|
+
cp index.ts $HOME/.pi/agent/extensions/pi-wsearch/
|
|
31
|
+
cp src/*.ts $HOME/.pi/agent/extensions/pi-wsearch/src/
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-wsearch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: web.search + web.fetch",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"extension",
|
|
11
|
+
"coding-agent",
|
|
12
|
+
"agent",
|
|
13
|
+
"web-search",
|
|
14
|
+
"web-search-api",
|
|
15
|
+
"search-engine",
|
|
16
|
+
"multi-engine",
|
|
17
|
+
"search-api",
|
|
18
|
+
"web-fetch",
|
|
19
|
+
"scraper",
|
|
20
|
+
"reader",
|
|
21
|
+
"rrf",
|
|
22
|
+
"typescript",
|
|
23
|
+
"bun",
|
|
24
|
+
"ai"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/k3-2o/pi-wsearch.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/k3-2o/pi-wsearch#readme",
|
|
31
|
+
"bugs": "https://github.com/k3-2o/pi-wsearch/issues",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"lint": "oxlint",
|
|
34
|
+
"format": "oxfmt",
|
|
35
|
+
"format:check": "oxfmt --check",
|
|
36
|
+
"check": "oxlint && oxfmt --check && tsc --noEmit && knip && bun test --jobs 1 test/",
|
|
37
|
+
"test": "bun test --jobs 1 test/",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"knip": "knip",
|
|
40
|
+
"coverage": "bun test --jobs 1 test/ --coverage"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./index.ts"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "*",
|
|
49
|
+
"@earendil-works/pi-agent-core": "*",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
51
|
+
"@earendil-works/pi-tui": "*",
|
|
52
|
+
"typebox": "*"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/bun": "^1.4.2",
|
|
56
|
+
"fast-check": "^4.10.2",
|
|
57
|
+
"knip": "^6.39.0",
|
|
58
|
+
"oxfmt": "^0.71.0",
|
|
59
|
+
"oxlint": "^1.86.0",
|
|
60
|
+
"typescript": "^7.0.2"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {}
|
|
63
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CAP = 400;
|
|
6
|
+
|
|
7
|
+
interface Entry {
|
|
8
|
+
exp: number;
|
|
9
|
+
value: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Store {
|
|
13
|
+
entries: Record<string, Entry>;
|
|
14
|
+
order: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TtlCache {
|
|
18
|
+
private store: Store = { entries: {}, order: [] };
|
|
19
|
+
constructor(
|
|
20
|
+
private file: string,
|
|
21
|
+
private cap = DEFAULT_CAP,
|
|
22
|
+
) {
|
|
23
|
+
this.load();
|
|
24
|
+
}
|
|
25
|
+
private load() {
|
|
26
|
+
try {
|
|
27
|
+
if (!existsSync(this.file)) return;
|
|
28
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
29
|
+
if (raw && typeof raw === 'object' && typeof raw.entries === 'object') {
|
|
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
|
+
this.store.order = this.store.order.filter((k) => this.store.entries[k] !== undefined);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
this.store = { entries: {}, order: [] };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
private persist() {
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
45
|
+
const tmp = this.file + '.tmp';
|
|
46
|
+
writeFileSync(tmp, JSON.stringify(this.store), 'utf8');
|
|
47
|
+
renameSync(tmp, this.file);
|
|
48
|
+
} catch {
|
|
49
|
+
/* cache writes are best-effort */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
get(key: string): unknown | undefined {
|
|
53
|
+
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
|
+
}
|
|
60
|
+
const i = this.store.order.indexOf(key);
|
|
61
|
+
if (i >= 0) this.store.order.splice(i, 1);
|
|
62
|
+
this.store.order.push(key);
|
|
63
|
+
return e.value;
|
|
64
|
+
}
|
|
65
|
+
set(key: string, value: unknown, ttlMs: number) {
|
|
66
|
+
this.store.entries[key] = { exp: Date.now() + ttlMs, value };
|
|
67
|
+
const i = this.store.order.indexOf(key);
|
|
68
|
+
if (i >= 0) this.store.order.splice(i, 1);
|
|
69
|
+
this.store.order.push(key);
|
|
70
|
+
while (this.store.order.length > this.cap) {
|
|
71
|
+
const old = this.store.order.shift()!;
|
|
72
|
+
delete this.store.entries[old];
|
|
73
|
+
}
|
|
74
|
+
this.persist();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function cacheKey(parts: string[]): string {
|
|
79
|
+
return createHash('sha256').update(parts.join('|')).digest('hex');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Search results are NOT cached: exact-repeat reuse is rare; staleness risk is not worth it. */
|
|
83
|
+
export const CACHE_TTL_HOURS = 24;
|
|
84
|
+
|
|
85
|
+
function defaultCacheDir(): string {
|
|
86
|
+
return join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.pi', 'agent', 'wsearch');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function openCache(file?: string): TtlCache {
|
|
90
|
+
return new TtlCache(file ?? join(defaultCacheDir(), 'cache.json'));
|
|
91
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key sources, in order (first hit wins): process.env → the dedicated
|
|
3
|
+
* ~/.pi/agent/wsearch/env file → the user's detected shell config. Any value
|
|
4
|
+
* may be `!command` (runs once per process; stdout = key). Values are never
|
|
5
|
+
* logged, returned, or serialized.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
export interface WebConfig {
|
|
13
|
+
serperKey?: string;
|
|
14
|
+
tavilyKey?: string;
|
|
15
|
+
exaKey?: string;
|
|
16
|
+
firecrawlKey?: string;
|
|
17
|
+
braveKey?: string;
|
|
18
|
+
jinaKey?: string;
|
|
19
|
+
kagiKey?: string;
|
|
20
|
+
youKey?: string;
|
|
21
|
+
tinyfishKey?: string;
|
|
22
|
+
cacheDir: string;
|
|
23
|
+
allowPrivate?: boolean;
|
|
24
|
+
searchEngines?: string;
|
|
25
|
+
fetchChain?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ProviderKey =
|
|
29
|
+
| 'serperKey'
|
|
30
|
+
| 'tavilyKey'
|
|
31
|
+
| 'exaKey'
|
|
32
|
+
| 'firecrawlKey'
|
|
33
|
+
| 'braveKey'
|
|
34
|
+
| 'jinaKey'
|
|
35
|
+
| 'kagiKey'
|
|
36
|
+
| 'youKey'
|
|
37
|
+
| 'tinyfishKey';
|
|
38
|
+
|
|
39
|
+
interface KeySpec {
|
|
40
|
+
id: string;
|
|
41
|
+
key: ProviderKey;
|
|
42
|
+
envNames: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const SPECS: KeySpec[] = [
|
|
46
|
+
{ id: 'serper', key: 'serperKey', envNames: ['SERPER_API_KEY', 'SERPER_KEY'] },
|
|
47
|
+
{ id: 'tavily', key: 'tavilyKey', envNames: ['TAVILY_API_KEY', 'TAVILY_KEY'] },
|
|
48
|
+
{ id: 'exa', key: 'exaKey', envNames: ['EXA_API_KEY', 'EXA_KEY'] },
|
|
49
|
+
{ id: 'firecrawl', key: 'firecrawlKey', envNames: ['FIRECRAWL_API_KEY', 'FIRECRAWL_KEY'] },
|
|
50
|
+
{ id: 'brave', key: 'braveKey', envNames: ['BRAVE_API_KEY', 'BRAVE_KEY'] },
|
|
51
|
+
{ id: 'jina', key: 'jinaKey', envNames: ['JINA_API_KEY', 'JINA_KEY'] },
|
|
52
|
+
{ id: 'kagi', key: 'kagiKey', envNames: ['KAGI_API_KEY', 'KAGI_KEY'] },
|
|
53
|
+
{ id: 'you', key: 'youKey', envNames: ['YOUCOM_API_KEY', 'YDC_API_KEY'] },
|
|
54
|
+
{ id: 'tinyfish', key: 'tinyfishKey', envNames: ['TINYFISH_API_KEY', 'TINYFISH_KEY'] },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export interface ProviderInfo {
|
|
58
|
+
id: string;
|
|
59
|
+
label: string;
|
|
60
|
+
envName: string;
|
|
61
|
+
aliases: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PROVIDER_LABELS: Record<string, string> = {
|
|
65
|
+
serper: 'Serper (Google)',
|
|
66
|
+
tavily: 'Tavily',
|
|
67
|
+
exa: 'Exa',
|
|
68
|
+
firecrawl: 'Firecrawl',
|
|
69
|
+
brave: 'Brave',
|
|
70
|
+
jina: 'Jina',
|
|
71
|
+
kagi: 'Kagi',
|
|
72
|
+
you: 'You.com',
|
|
73
|
+
tinyfish: 'TinyFish',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const PROVIDERS: ProviderInfo[] = SPECS.map((s) => ({
|
|
77
|
+
id: s.id,
|
|
78
|
+
label: PROVIDER_LABELS[s.id] ?? s.id,
|
|
79
|
+
envName: s.envNames[0],
|
|
80
|
+
aliases: s.envNames.slice(1),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const SHELL_FILES: Record<string, string[]> = {
|
|
84
|
+
zsh: ['.zshrc', '.zprofile'],
|
|
85
|
+
bash: ['.bashrc', '.bash_profile', '.profile'],
|
|
86
|
+
sh: ['.profile'],
|
|
87
|
+
dash: ['.profile'],
|
|
88
|
+
fish: ['.config/fish/config.fish'],
|
|
89
|
+
nu: ['.config/nushell/env.nu'],
|
|
90
|
+
nushell: ['.config/nushell/env.nu'],
|
|
91
|
+
};
|
|
92
|
+
const DEFAULT_SHELL_FILES = SHELL_FILES.bash;
|
|
93
|
+
|
|
94
|
+
export function shellConfigFiles(shell = process.env.SHELL): string[] {
|
|
95
|
+
if (!shell) return DEFAULT_SHELL_FILES;
|
|
96
|
+
const base = shell.split('/').pop()?.toLowerCase() ?? '';
|
|
97
|
+
return SHELL_FILES[base] ?? DEFAULT_SHELL_FILES;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A '#' starts a comment only at a word boundary, outside quotes: 'K=a#b'
|
|
101
|
+
// keeps the hash; 'K=a # comment' drops it (runs before unquoting).
|
|
102
|
+
function stripComment(v: string): string {
|
|
103
|
+
let inS = false;
|
|
104
|
+
let inD = false;
|
|
105
|
+
for (let i = 0; i < v.length; i++) {
|
|
106
|
+
const c = v[i];
|
|
107
|
+
if (c === '\\' && (inS || inD)) i++;
|
|
108
|
+
else if (c === "'" && !inD) inS = !inS;
|
|
109
|
+
else if (c === '"' && !inS) inD = !inD;
|
|
110
|
+
else if (c === '#' && !inS && !inD && (i === 0 || /\s/.test(v[i - 1] as string))) return v.slice(0, i);
|
|
111
|
+
}
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function unquote(v: string): string {
|
|
116
|
+
let s = stripComment(v).trim();
|
|
117
|
+
if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
|
|
118
|
+
s = s.slice(1, -1);
|
|
119
|
+
}
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseEnvLine(line: string): [string, string] | null {
|
|
124
|
+
const t = line.trim();
|
|
125
|
+
if (!t || t.startsWith('#')) return null;
|
|
126
|
+
let m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(t);
|
|
127
|
+
if (m) return [m[1], unquote(m[2])];
|
|
128
|
+
m = /^\s*set\s+(?:-gx|-x)\s+([A-Za-z_][A-Za-z0-9_]*)\s+(.*)$/.exec(t);
|
|
129
|
+
if (m) return [m[1], unquote(m[2])];
|
|
130
|
+
m = /^\s*\$env\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(t);
|
|
131
|
+
if (m) return [m[1], unquote(m[2])];
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseEnvText(text: string): Record<string, string> {
|
|
136
|
+
const out: Record<string, string> = {};
|
|
137
|
+
for (const raw of text.split('\n')) {
|
|
138
|
+
const pair = parseEnvLine(raw);
|
|
139
|
+
if (pair && !(pair[0] in out)) out[pair[0]] = pair[1];
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readEnvText(path: string): Record<string, string> {
|
|
145
|
+
try {
|
|
146
|
+
if (!existsSync(path)) return {};
|
|
147
|
+
return parseEnvText(readFileSync(path, 'utf8'));
|
|
148
|
+
} catch {
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function resolveStoredValue(raw: string): string | undefined {
|
|
154
|
+
const v = raw.trim();
|
|
155
|
+
if (!v.startsWith('!')) return v.length > 4 ? v : undefined;
|
|
156
|
+
const cmd = v.slice(1).trim();
|
|
157
|
+
if (!cmd) return undefined;
|
|
158
|
+
try {
|
|
159
|
+
const r = spawnSync('sh', ['-c', cmd], {
|
|
160
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
161
|
+
timeout: 5_000,
|
|
162
|
+
killSignal: 'SIGTERM',
|
|
163
|
+
});
|
|
164
|
+
if (r.status !== 0 || r.signal || !r.stdout) return undefined;
|
|
165
|
+
const out = r.stdout.toString().trim();
|
|
166
|
+
return out.length > 4 ? out : undefined;
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function keyFilePath(): string {
|
|
173
|
+
return join(wsearchDir(), 'env');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function disabledFlagPath(): string {
|
|
177
|
+
return join(wsearchDir(), 'off');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function wsearchDir(): string {
|
|
181
|
+
return process.env.WSEARCH_CACHE_DIR ?? join(homedir(), '.pi', 'agent', 'wsearch');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let cached: WebConfig | undefined;
|
|
185
|
+
let origins: Record<string, 'env' | 'wsearch' | 'shell'> = {};
|
|
186
|
+
export function resetConfigCache(): void {
|
|
187
|
+
cached = undefined;
|
|
188
|
+
origins = {};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// !command resolution is synchronous (spawnSync, 5s each, once per process).
|
|
192
|
+
export function getConfig(): WebConfig {
|
|
193
|
+
if (cached) return cached;
|
|
194
|
+
const env = process.env;
|
|
195
|
+
|
|
196
|
+
const keysFile = readEnvText(keyFilePath());
|
|
197
|
+
const shell = {} as Record<string, string>;
|
|
198
|
+
for (const f of shellConfigFiles()) {
|
|
199
|
+
const map = readEnvText(join(homedir(), f));
|
|
200
|
+
for (const [k, v] of Object.entries(map)) if (!(k in shell)) shell[k] = v;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const find = (spec: KeySpec): string | undefined => {
|
|
204
|
+
for (const name of spec.envNames) {
|
|
205
|
+
if (env[name] && env[name].length > 4) {
|
|
206
|
+
const v = resolveStoredValue(env[name]);
|
|
207
|
+
if (v) {
|
|
208
|
+
origins[spec.id] = 'env';
|
|
209
|
+
return v;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (keysFile[name]) {
|
|
213
|
+
const v = resolveStoredValue(keysFile[name]);
|
|
214
|
+
if (v) {
|
|
215
|
+
origins[spec.id] = 'wsearch';
|
|
216
|
+
return v;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (shell[name]) {
|
|
220
|
+
const v = resolveStoredValue(shell[name]);
|
|
221
|
+
if (v) {
|
|
222
|
+
origins[spec.id] = 'shell';
|
|
223
|
+
return v;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
cached = {
|
|
231
|
+
cacheDir: process.env.WSEARCH_CACHE_DIR ?? join(homedir(), '.pi', 'agent', 'wsearch'),
|
|
232
|
+
allowPrivate: process.env.WSEARCH_ALLOW_PRIVATE === '1',
|
|
233
|
+
searchEngines: (env.WSEARCH_ENGINES ?? keysFile.WSEARCH_ENGINES ?? shell.WSEARCH_ENGINES)?.trim() || undefined,
|
|
234
|
+
fetchChain:
|
|
235
|
+
(env.WSEARCH_FETCH_CHAIN ?? keysFile.WSEARCH_FETCH_CHAIN ?? shell.WSEARCH_FETCH_CHAIN)?.trim() || undefined,
|
|
236
|
+
};
|
|
237
|
+
// iterate the catalog keyed on spec.key: a SPECS reorder can never mis-map keys
|
|
238
|
+
for (const spec of SPECS) cached[spec.key] = find(spec);
|
|
239
|
+
return cached;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function keyStatus(): Record<string, boolean> {
|
|
243
|
+
const c = getConfig();
|
|
244
|
+
const out: Record<string, boolean> = {};
|
|
245
|
+
for (const spec of SPECS) out[spec.id] = !!c[spec.key];
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function keyOrigins(): Record<string, 'env' | 'wsearch' | 'shell'> {
|
|
250
|
+
getConfig();
|
|
251
|
+
return { ...origins };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function sanitizeError(e: unknown): string {
|
|
255
|
+
let msg = e instanceof Error ? e.message : String(e);
|
|
256
|
+
const cfg = getConfig();
|
|
257
|
+
for (const spec of SPECS) {
|
|
258
|
+
const secret = cfg[spec.key];
|
|
259
|
+
if (secret && secret.length > 8) msg = msg.split(secret).join('***');
|
|
260
|
+
}
|
|
261
|
+
msg = msg.replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|Bearer\s+[A-Za-z0-9._-]{12,}|X-API-KEY[^\n]*)\b/gi, '***');
|
|
262
|
+
return msg.slice(0, 240);
|
|
263
|
+
}
|
package/src/constants.ts
ADDED