pi-wsearch 0.6.0 → 0.7.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/README.md CHANGED
@@ -13,7 +13,7 @@ ranked that page and where each placed it. A hit returned by one engine alone
13
13
  shows just that engine and rank: `⇐ exa#3`. Agreement is the spam defense;
14
14
  generative-SEO can stuff one index, not three.
15
15
 
16
- **Pick your engines.** Nine are supported; three or four is plenty. Use
16
+ **Pick your engines.** Ten are supported; three or four is plenty. Use
17
17
  whatever keys you have, even one:
18
18
 
19
19
  - **Rare strings match literally.** `pgvector`, CVE numbers, error codes match
@@ -26,7 +26,7 @@ whatever keys you have, even one:
26
26
  answers.
27
27
 
28
28
  **Supported providers:** Serper, Tavily, Exa, Brave, Jina, Kagi, You.com,
29
- Firecrawl, TinyFish
29
+ Firecrawl, TinyFish, Parallel, Keenable
30
30
 
31
31
  ### WebFetch
32
32
 
@@ -46,11 +46,8 @@ local budget cuts.
46
46
  **Reads are free.** Page parsing is built in, bills nothing per read, and
47
47
  needs no key. Real headings become the map, TOC echoes get dropped. Paid
48
48
  readers only step in for JavaScript-heavy pages or when the local read fails.
49
- Reader output is laundered: standalone photo
50
- credits (REUTERS/, AP Photo/, Getty), video
51
- durations, carousel markers and relative-time chips are stripped from news
52
- pages (whole-line rules only; prose is untouched), and a reader that returns
53
- a 404-page template is skipped so the chain moves on to the next backend.
49
+ A reader that returns a 404-page template is skipped so the chain moves on to
50
+ the next backend.
54
51
 
55
52
  **Tracking links cache as one page.** `?utm_source=…` variants of the same URL
56
53
  share one 24 hour cache entry. Faster repeats, smaller disk.
@@ -63,7 +60,7 @@ mapped IPv6 forms included) are rejected before any request is made.
63
60
  as untrusted input: a claim only becomes a verdict after a second source
64
61
  corroborates it.
65
62
 
66
- **Optional readers:** Firecrawl, Tavily, Exa, Jina, fallback only
63
+ **Optional readers:** Firecrawl, Tavily, Exa, Jina, Parallel, Keenable, fallback only
67
64
 
68
65
  ## Install
69
66
 
@@ -100,8 +97,8 @@ Search needs at least one provider key; fetch needs none. Two ways to set one:
100
97
  Either place accepts a `!command` value, so the key can come from Keychain or
101
98
  1Password at use time and never sit on disk. Session env wins over the file,
102
99
  the file over shell config. `/websearch` shows which providers are armed and
103
- which are dormant. No keys yet? Tavily, Exa, Firecrawl, and TinyFish all have
104
- free tiers.
100
+ which are dormant. No keys yet? Tavily, Exa, Firecrawl, TinyFish, Parallel, and
101
+ Keenable have free tiers; Keenable's starts at 100K requests/mo.
105
102
 
106
103
  ## Commands
107
104
 
@@ -138,7 +135,7 @@ One command, five subcommands:
138
135
  /websearch
139
136
  ```
140
137
 
141
- Your provider should appear under `armed:`.
138
+ Notifies you on your current configured providers.
142
139
  4. Ask the agent something current:
143
140
 
144
141
  > What's the current Node.js LTS version?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-wsearch",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pi extension: WebSearch + WebFetch",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cache.ts CHANGED
@@ -1,79 +1,111 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { rename, writeFile } from 'node:fs/promises';
3
4
  import { dirname, join } from 'node:path';
4
5
 
5
6
  const DEFAULT_CAP = 400;
7
+ const STALE_RESERVE_MS = 24 * 3600_000;
6
8
 
7
9
  interface Entry {
8
10
  exp: number;
9
11
  value: unknown;
10
12
  }
11
13
 
12
- interface Store {
13
- entries: Record<string, Entry>;
14
- order: string[];
15
- }
16
-
14
+ /** Map iteration order = insertion order, so delete+re-set IS the LRU move;
15
+ * no parallel order array to splice. */
17
16
  export class TtlCache {
18
- private store: Store = { entries: {}, order: [] };
17
+ private entries = new Map<string, Entry>();
18
+ private loaded = false;
19
+ private write = Promise.resolve();
19
20
  constructor(
20
21
  private file: string,
21
22
  private cap = DEFAULT_CAP,
22
- ) {
23
- this.load();
24
- }
25
- private load() {
23
+ ) {}
24
+
25
+ private ensureLoaded() {
26
+ if (this.loaded) return;
27
+ this.loaded = true;
26
28
  try {
27
29
  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
- this.store.order = this.store.order.filter((k) => this.store.entries[k] !== undefined);
30
+ const raw = JSON.parse(readFileSync(this.file, 'utf8')) as {
31
+ entries?: Record<string, Entry>;
32
+ order?: string[];
33
+ } | null;
34
+ if (!raw || typeof raw !== 'object' || typeof raw.entries !== 'object') return;
35
+ const total = Object.keys(raw.entries).length;
36
+ const cutoff = Date.now() - STALE_RESERVE_MS;
37
+ // Legacy files carried an explicit LRU `order` array: seed the Map in
38
+ // that order so eviction under the cap starts at the true oldest.
39
+ const order: string[] = Array.isArray(raw.order)
40
+ ? [...raw.order.filter((k): k is string => typeof k === 'string'), ...Object.keys(raw.entries)]
41
+ : Object.keys(raw.entries);
42
+ for (const k of order) {
43
+ const e = raw.entries[k];
44
+ if (typeof e?.exp !== 'number' || !Number.isFinite(e.exp)) continue; // corrupt: dead
45
+ if (e.exp <= cutoff) continue; // beyond the stale reserve: dead
46
+ this.entries.set(k, e);
32
47
  }
48
+ // Rewrite only when a stale/corrupt entry was actually dropped; a
49
+ // healthy file must never be rewritten on load.
50
+ if (this.entries.size !== total) this.persist();
33
51
  } catch {
34
- this.store = { entries: {}, order: [] };
52
+ this.entries.clear();
35
53
  }
36
54
  }
55
+
37
56
  private persist() {
38
57
  try {
39
58
  mkdirSync(dirname(this.file), { recursive: true });
40
- const tmp = this.file + '.tmp';
41
- writeFileSync(tmp, JSON.stringify(this.store), 'utf8');
42
- renameSync(tmp, this.file);
59
+ const tmp = `${this.file}.tmp`;
60
+ this.write = this.write
61
+ .then(() => JSON.stringify({ entries: Object.fromEntries(this.entries) }))
62
+ .then((data) => writeFile(tmp, data, 'utf8'))
63
+ .then(() => rename(tmp, this.file))
64
+ .catch(() => {});
43
65
  } catch {
44
66
  /* cache writes are best-effort */
45
67
  }
46
68
  }
47
- /** Entry valid while unexpired; non-numeric/NaN exp (corrupt store) must NOT
48
- * count as "never expires" — treat it as expired so a poisoned entry dies. */
69
+
70
+ flush(): Promise<void> {
71
+ return this.write;
72
+ }
73
+
74
+ /** Entry valid while unexpired; corrupt exp (poisoned store) counts as
75
+ * expired, never "never expires". */
49
76
  get(key: string): unknown | undefined {
50
- const e = this.store.entries[key];
51
- if (!e || typeof e.exp !== 'number' || !Number.isFinite(e.exp) || e.exp < Date.now()) return undefined;
52
- const i = this.store.order.indexOf(key);
53
- if (i >= 0) this.store.order.splice(i, 1);
54
- this.store.order.push(key);
77
+ this.ensureLoaded();
78
+ const e = this.entries.get(key);
79
+ if (!e || e.exp < Date.now()) return undefined;
80
+ this.touch(key, e);
55
81
  return e.value;
56
82
  }
57
83
 
58
84
  /** Expired copy WITHOUT deleting it (stale-if-error reserve). */
59
85
  peekStale(key: string): { value: unknown; ageHours: number } | undefined {
60
- const e = this.store.entries[key];
86
+ this.ensureLoaded();
87
+ const e = this.entries.get(key);
61
88
  if (!e || typeof e.exp !== 'number' || !Number.isFinite(e.exp)) return undefined;
62
89
  const ageHours = (Date.now() - e.exp) / 3600_000;
63
90
  if (ageHours <= 0) return undefined;
64
91
  return { value: e.value, ageHours };
65
92
  }
93
+
66
94
  set(key: string, value: unknown, ttlMs: number) {
67
- this.store.entries[key] = { exp: Date.now() + ttlMs, value };
68
- const i = this.store.order.indexOf(key);
69
- if (i >= 0) this.store.order.splice(i, 1);
70
- this.store.order.push(key);
71
- while (this.store.order.length > this.cap) {
72
- const old = this.store.order.shift()!;
73
- delete this.store.entries[old];
95
+ this.ensureLoaded();
96
+ this.touch(key, { exp: Date.now() + ttlMs, value });
97
+ while (this.entries.size > this.cap) {
98
+ const oldest = this.entries.keys().next().value;
99
+ if (oldest === undefined) break;
100
+ this.entries.delete(oldest);
74
101
  }
75
102
  this.persist();
76
103
  }
104
+
105
+ private touch(key: string, e: Entry) {
106
+ this.entries.delete(key);
107
+ this.entries.set(key, e);
108
+ }
77
109
  }
78
110
 
79
111
  export function cacheKey(parts: string[]): string {
package/src/config.ts CHANGED
@@ -9,6 +9,11 @@ import { spawnSync } from 'node:child_process';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
 
12
+ export const DEFAULT_MAX_RESULTS = 8;
13
+
14
+ /** The abort message: a UX contract (pi renders isError red), one home. */
15
+ export const ABORT_ERROR = 'aborted (user interrupt)';
16
+
12
17
  export interface WebConfig {
13
18
  serperKey?: string;
14
19
  tavilyKey?: string;
@@ -19,6 +24,8 @@ export interface WebConfig {
19
24
  kagiKey?: string;
20
25
  youKey?: string;
21
26
  tinyfishKey?: string;
27
+ parallelKey?: string;
28
+ keenableKey?: string;
22
29
  cacheDir: string;
23
30
  allowPrivate?: boolean;
24
31
  searchEngines?: string;
@@ -34,7 +41,9 @@ export type ProviderKey =
34
41
  | 'jinaKey'
35
42
  | 'kagiKey'
36
43
  | 'youKey'
37
- | 'tinyfishKey';
44
+ | 'tinyfishKey'
45
+ | 'parallelKey'
46
+ | 'keenableKey';
38
47
 
39
48
  interface KeySpec {
40
49
  id: string;
@@ -52,6 +61,8 @@ export const SPECS: KeySpec[] = [
52
61
  { id: 'kagi', key: 'kagiKey', envNames: ['KAGI_API_KEY', 'KAGI_KEY'] },
53
62
  { id: 'you', key: 'youKey', envNames: ['YOUCOM_API_KEY', 'YDC_API_KEY'] },
54
63
  { id: 'tinyfish', key: 'tinyfishKey', envNames: ['TINYFISH_API_KEY', 'TINYFISH_KEY'] },
64
+ { id: 'parallel', key: 'parallelKey', envNames: ['PARALLEL_API_KEY', 'PARALLEL_KEY'] },
65
+ { id: 'keenable', key: 'keenableKey', envNames: ['KEENABLE_API_KEY', 'KEENABLE_KEY'] },
55
66
  ];
56
67
 
57
68
  export interface ProviderInfo {
@@ -71,6 +82,8 @@ const PROVIDER_LABELS: Record<string, string> = {
71
82
  kagi: 'Kagi',
72
83
  you: 'You.com',
73
84
  tinyfish: 'TinyFish',
85
+ parallel: 'Parallel',
86
+ keenable: 'Keenable',
74
87
  };
75
88
 
76
89
  export const PROVIDERS: ProviderInfo[] = SPECS.map((s) => ({
package/src/dom.ts CHANGED
@@ -215,15 +215,15 @@ function renderBlock(n: DomNode, out: string[]): void {
215
215
  }
216
216
  }
217
217
 
218
- const DATE_KEYWORDS = ['datePublished', 'dateCreated', 'uploadDate', 'publishedAt'];
219
- const DATE_META_NAMES = [
218
+ const DATE_KEYWORDS = new Set(['datePublished', 'dateCreated', 'uploadDate', 'publishedAt']);
219
+ const DATE_META_NAMES = new Set([
220
220
  'article:published_time',
221
221
  'datePublished',
222
222
  'publish-date',
223
223
  'pubdate',
224
224
  'parsely-pub-date',
225
225
  'date',
226
- ];
226
+ ]);
227
227
 
228
228
  function toIso(v: string | undefined): string | undefined {
229
229
  if (!v) return undefined;
@@ -232,14 +232,15 @@ function toIso(v: string | undefined): string | undefined {
232
232
  return new Date(ms).toISOString().slice(0, 10);
233
233
  }
234
234
 
235
+ const pickIsoDate = (value: unknown): string | undefined => {
236
+ if (typeof value === 'string') {
237
+ const iso = toIso(value);
238
+ if (iso) return iso;
239
+ }
240
+ return undefined;
241
+ };
242
+
235
243
  function jsonLdDate(nodes: DomNode[]): string | undefined {
236
- const pick = (value: unknown): string | undefined => {
237
- if (typeof value === 'string') {
238
- const iso = toIso(value);
239
- if (iso) return iso;
240
- }
241
- return undefined;
242
- };
243
244
  const walk = (value: unknown): string | undefined => {
244
245
  if (Array.isArray(value)) {
245
246
  for (const v of value) {
@@ -250,7 +251,7 @@ function jsonLdDate(nodes: DomNode[]): string | undefined {
250
251
  }
251
252
  if (typeof value === 'object' && value !== null) {
252
253
  for (const key of DATE_KEYWORDS) {
253
- const hit = pick((value as Record<string, unknown>)[key]);
254
+ const hit = pickIsoDate((value as Record<string, unknown>)[key]);
254
255
  if (hit) return hit;
255
256
  }
256
257
  for (const v of Object.values(value as Record<string, unknown>)) {
@@ -317,7 +318,7 @@ export function headScan(html: string): HeadScan {
317
318
  if (name === 'meta') {
318
319
  const key = (attribs['property'] ?? attribs['name'] ?? '').toLowerCase().trim();
319
320
  if (!ogTitle && key === 'og:title') ogTitle = (attribs['content'] ?? '').trim().slice(0, 200);
320
- if (!metaDate && DATE_META_NAMES.includes(key)) metaDate = toIso(attribs['content']);
321
+ if (!metaDate && DATE_META_NAMES.has(key)) metaDate = toIso(attribs['content']);
321
322
  }
322
323
  if (name === 'time' && !timeDate && attribs['datetime']) timeDate = toIso(attribs['datetime']);
323
324
  for (const c of n.children ?? []) visit(c);