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 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
+ }
@@ -0,0 +1,6 @@
1
+ /** Shared constants: single home for facts that would otherwise drift across modules. */
2
+
3
+ export const DEFAULT_MAX_RESULTS = 8;
4
+
5
+ /** The abort message: a UX contract (pi renders isError red), one home. */
6
+ export const ABORT_ERROR = 'aborted (user interrupt)';