@apmantza/greedysearch-pi 2.1.2 → 2.1.4

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.
@@ -0,0 +1,65 @@
1
+ # Runtime and Chrome
2
+
3
+ GreedySearch uses a dedicated Chrome profile and debug port. It must not attach
4
+ to your normal Chrome profile.
5
+
6
+ - Profile: OS temp directory `greedysearch-chrome-profile`
7
+ - Port: `9222`
8
+ - Default mode: headless
9
+
10
+ ## Pi Commands
11
+
12
+ ```text
13
+ /greedy-visible # launch visible Chrome for captcha/login/cookie setup
14
+ /greedy-status # show GreedySearch Chrome status
15
+ /greedy-kill # stop GreedySearch Chrome
16
+ /set-greedy-locale # set default result language
17
+ ```
18
+
19
+ ## Environment Variables
20
+
21
+ - `GREEDY_SEARCH_VISIBLE` — set `1` to show Chrome instead of headless.
22
+ - `GREEDY_SEARCH_ALWAYS_VISIBLE` — set `1` to force visible mode for all runs.
23
+ - `GREEDY_SEARCH_IDLE_TIMEOUT_MINUTES` — headless idle cleanup timeout;
24
+ default `5`.
25
+ - `GREEDY_SEARCH_LOCALE` — default result language; default `en`.
26
+ - `CHROME_PATH` — Chrome/Chromium executable path; auto-detected by default.
27
+
28
+ ## Runtime Helpers
29
+
30
+ Git install path:
31
+
32
+ ```bash
33
+ GS=~/.pi/agent/git/github.com/apmantza/GreedySearch-pi
34
+ node "$GS/bin/launch.mjs" --status
35
+ node "$GS/bin/visible.mjs"
36
+ node "$GS/bin/visible.mjs" --kill
37
+ node "$GS/bin/kill-visible.mjs"
38
+ node "$GS/bin/cdp-visible.mjs" list
39
+ node "$GS/bin/cdp-headless.mjs" list
40
+ node "$GS/bin/cdp-greedy.mjs" list
41
+ ```
42
+
43
+ npm global install path:
44
+
45
+ ```bash
46
+ GS="$(npm root -g)/@apmantza/greedysearch-pi"
47
+ node "$GS/bin/launch.mjs" --status
48
+ node "$GS/bin/visible.mjs"
49
+ node "$GS/bin/visible.mjs" --kill
50
+ node "$GS/bin/kill-visible.mjs"
51
+ node "$GS/bin/cdp-visible.mjs" list
52
+ node "$GS/bin/cdp-headless.mjs" list
53
+ node "$GS/bin/cdp-greedy.mjs" list
54
+ ```
55
+
56
+ ## CDP Safety
57
+
58
+ Use only the safe wrappers for manual debugging:
59
+
60
+ - `bin/cdp-visible.mjs` — refuses unless GreedySearch Chrome is visible.
61
+ - `bin/cdp-headless.mjs` — refuses unless GreedySearch Chrome is headless.
62
+ - `bin/cdp-greedy.mjs` — attaches only to the GreedySearch Chrome profile.
63
+
64
+ Avoid raw `bin/cdp.mjs` unless `CDP_PROFILE_DIR` explicitly points at the
65
+ GreedySearch profile.
@@ -0,0 +1,33 @@
1
+ # Source Fetching
2
+
3
+ For `engine: "all"`, GreedySearch ranks discovered sources and fetches top source
4
+ content by default. `synthesize: true` then asks the configured synthesizer to
5
+ combine engine answers with fetched evidence.
6
+
7
+ ## Supported Sources
8
+
9
+ - **PDFs** — direct PDF links are parsed into markdown text.
10
+ - **Semantic Scholar** — academic paper URLs and direct PDFs are preferred when
11
+ the engine is used directly or opted into the fan-out.
12
+ - **Reddit** — public `.json` API for posts and comments.
13
+ - **GitHub** — REST API for repos, READMEs, file trees, and raw file content.
14
+ - **General web** — Readability extraction with browser fallback.
15
+
16
+ ## Metadata
17
+
18
+ Fetched sources include the best available:
19
+
20
+ - title;
21
+ - final URL;
22
+ - status/content type;
23
+ - byline and site name;
24
+ - publish or modified date;
25
+ - language;
26
+ - excerpt/snippet;
27
+ - trimmed markdown/plain text content.
28
+
29
+ ## Security
30
+
31
+ Fetchers reject private/internal URLs and re-check the final redirected URL to
32
+ avoid SSRF bypasses. GitHub and Reddit URLs use dedicated fetchers where
33
+ possible to avoid fragile page scraping.
package/docs/usage.md ADDED
@@ -0,0 +1,69 @@
1
+ # Usage Guide
2
+
3
+ GreedySearch registers one Pi tool: `greedy_search`.
4
+
5
+ ## Common Calls
6
+
7
+ ```js
8
+ greedy_search({ query: "React 19 changes" });
9
+ greedy_search({ query: "React 19 changes", synthesize: true });
10
+ greedy_search({ query: "Prisma vs Drizzle", engine: "perplexity" });
11
+ greedy_search({ query: "What is Redis?", depth: "research", maxSources: 5 });
12
+ ```
13
+
14
+ ## Parameters
15
+
16
+ ### Common
17
+
18
+ - `query` — required search/research question.
19
+ - `fullAnswer` — return complete engine output instead of a preview.
20
+ - `headless` — defaults to `true`; set to `false` to show Chrome.
21
+ - `visible` / `alwaysVisible` — force visible Chrome for this run.
22
+
23
+ ### Normal Search
24
+
25
+ - `engine` — `all` by default. Supported individual engines:
26
+ `perplexity`, `google`, `chatgpt`, `gemini`, `semantic-scholar`,
27
+ `logically`, and `bing`.
28
+ - `synthesize` — for `engine: "all"`, synthesize fetched sources with the
29
+ configured synthesizer.
30
+ - `synthesizer` — `gemini` or `chatgpt`.
31
+ - `depth` — legacy `fast` / `standard` / `deep` aliases are still accepted;
32
+ prefer `synthesize` for normal search.
33
+
34
+ ### Research
35
+
36
+ - `depth: "research"` — run the iterative research workflow.
37
+ - `breadth` — actions per round, 1-5.
38
+ - `iterations` — rounds, 1-3.
39
+ - `maxSources` — fetched source cap, 3-12.
40
+ - `researchOutDir` — custom bundle directory.
41
+ - `writeResearchBundle` — write the bundle to disk; default `true`.
42
+
43
+ ## Search Modes
44
+
45
+ - **Individual engine** — calls one engine and returns its answer/sources.
46
+ - **Grounded all-engine search** — fans out to configured engines, ranks
47
+ sources, fetches top source content, and returns confidence metadata.
48
+ - **All + synthesis** — adds a synthesis pass over engine answers and fetched
49
+ source evidence.
50
+ - **Research** — runs planning, searches, source fetching, learning extraction,
51
+ citation audit, and bundle writing.
52
+
53
+ ## Configuration
54
+
55
+ Configure all-engine fan-out and synthesis in `~/.pi/greedyconfig`:
56
+
57
+ ```json
58
+ {
59
+ "engines": ["perplexity", "google", "chatgpt", "gemini"],
60
+ "synthesizer": "gemini"
61
+ }
62
+ ```
63
+
64
+ `semantic-scholar` and `logically` are opt-in research engines. They remain
65
+ available as individual engines and can be added to `engines` when you want them
66
+ in normal `engine: "all"` fan-out.
67
+
68
+ Deep research child searches reuse the configured `engines` list and pass query
69
+ text through stdin to avoid leaking prompts in process arguments.
@@ -25,7 +25,6 @@ import {
25
25
  prepareArgs,
26
26
  validateQuery,
27
27
  waitForSelector,
28
- waitForStreamComplete,
29
28
  } from "./common.mjs";
30
29
  import { dismissConsent, handleVerification } from "./consent.mjs";
31
30
 
@@ -131,14 +130,96 @@ const CHATGPT_RESPONSE_SELECTOR = String.raw`(() => {
131
130
  * and resolve quickly because the entire response
132
131
  * actually has finished.
133
132
  */
134
- async function waitForResponse(tab, timeoutMs = 20000) {
135
- return waitForStreamComplete(tab, {
136
- timeout: timeoutMs,
137
- interval: 600,
138
- stableRounds: 6,
139
- minLength: 1,
140
- selector: CHATGPT_RESPONSE_SELECTOR,
141
- });
133
+ async function bringToFront(tab, { required = false } = {}) {
134
+ try {
135
+ await cdp(["evalraw", tab, "Page.bringToFront", "{}"], 5000);
136
+ return true;
137
+ } catch (error) {
138
+ console.error(`[chatgpt] bringToFront failed: ${error.message}`);
139
+ if (required) throw error;
140
+ return false;
141
+ }
142
+ }
143
+
144
+ async function waitForResponse(tab, timeoutMs = 35000) {
145
+ await bringToFront(tab, { required: true });
146
+ let foregroundAttempts = 0;
147
+ let foregroundInFlight = false;
148
+ const keepForeground = setInterval(() => {
149
+ if (foregroundInFlight) return;
150
+ if (foregroundAttempts >= 8) {
151
+ console.error("[chatgpt] stopping foreground keepalive after 8 attempts");
152
+ clearInterval(keepForeground);
153
+ return;
154
+ }
155
+ foregroundAttempts++;
156
+ foregroundInFlight = true;
157
+ bringToFront(tab).finally(() => {
158
+ foregroundInFlight = false;
159
+ });
160
+ }, 4000);
161
+ try {
162
+ const code = String.raw`
163
+ new Promise((resolve, reject) => {
164
+ const _deadline = Date.now() + ${timeoutMs};
165
+ const _baseInterval = 700;
166
+ const _stableRounds = 5;
167
+ let _lastLen = -1;
168
+ let _stableCount = 0;
169
+
170
+ function _jitter(ms) {
171
+ return Math.max(50, ms + (Math.random() * ms * 0.4 - ms * 0.2));
172
+ }
173
+
174
+ function _assistantAfterLastUser() {
175
+ const all = document.querySelectorAll('[data-message-author-role]');
176
+ let lastUserIdx = -1;
177
+ for (let i = 0; i < all.length; i++) {
178
+ if (all[i].getAttribute('data-message-author-role') === 'user') lastUserIdx = i;
179
+ }
180
+ if (lastUserIdx < 0) return null;
181
+ let assistant = null;
182
+ for (let i = lastUserIdx + 1; i < all.length; i++) {
183
+ if (all[i].getAttribute('data-message-author-role') === 'assistant') assistant = all[i];
184
+ }
185
+ return assistant;
186
+ }
187
+
188
+ function _poll() {
189
+ try {
190
+ const el = _assistantAfterLastUser();
191
+ const text = (el?.innerText || '').trim();
192
+ const len = text.length;
193
+ const streaming = !!el?.querySelector('.streaming-animation,[data-is-streaming="true"]') ||
194
+ !!document.querySelector('button[data-testid="stop-button"], button[aria-label="Stop generating"], button[aria-label*="Stop"]');
195
+ if (len >= 1 && !streaming) {
196
+ if (len === _lastLen) {
197
+ _stableCount++;
198
+ if (_stableCount >= _stableRounds) { resolve(len); return; }
199
+ } else {
200
+ _lastLen = len;
201
+ _stableCount = 0;
202
+ }
203
+ } else if (len !== _lastLen) {
204
+ _lastLen = len;
205
+ _stableCount = 0;
206
+ }
207
+ if (Date.now() < _deadline) setTimeout(_poll, _jitter(_baseInterval));
208
+ else if (_lastLen >= 1 && !streaming) resolve(_lastLen);
209
+ else reject(new Error('ChatGPT response did not finish streaming within ${timeoutMs}ms'));
210
+ } catch (e) { reject(e); }
211
+ }
212
+ _poll();
213
+ })`;
214
+ const lenStr = await cdp(["eval", tab, code], timeoutMs + 10000);
215
+ const len = parseInt(lenStr, 10) || 0;
216
+ if (len >= 1) return len;
217
+ throw new Error(
218
+ `ChatGPT response did not finish streaming within ${timeoutMs}ms`,
219
+ );
220
+ } finally {
221
+ clearInterval(keepForeground);
222
+ }
142
223
  }
143
224
 
144
225
  /**
@@ -211,6 +292,8 @@ async function extractAnswerFromDom(tab) {
211
292
  skipped: 'no-assistant-response',
212
293
  });
213
294
  }
295
+ const streaming = !!assistant.querySelector('.streaming-animation,[data-is-streaming="true"]') ||
296
+ !!document.querySelector('button[data-testid="stop-button"], button[aria-label="Stop generating"], button[aria-label*="Stop"]');
214
297
  const answer = (assistant.innerText || assistant.textContent || '').trim();
215
298
  const seen = new Set();
216
299
  const sources = [];
@@ -222,13 +305,16 @@ async function extractAnswerFromDom(tab) {
222
305
  sources.push({ title, url });
223
306
  if (sources.length >= 10) break;
224
307
  }
225
- return JSON.stringify({ answer, sources });
308
+ return JSON.stringify({ answer, sources, streaming });
226
309
  })()
227
310
  `,
228
311
  ]);
229
312
  try {
230
313
  return JSON.parse(raw);
231
- } catch {
314
+ } catch (error) {
315
+ console.error(
316
+ `[chatgpt] DOM fallback JSON parse failed (${raw.length} chars): ${error.message}`,
317
+ );
232
318
  return { answer: "", sources: [], skipped: "parse-error" };
233
319
  }
234
320
  }
@@ -307,6 +393,9 @@ async function extractAnswer(tab, env) {
307
393
  let domFallback = null;
308
394
  if (!answer) {
309
395
  domFallback = await extractAnswerFromDom(tab);
396
+ if (domFallback.streaming) {
397
+ return { answer: "", sources: [], skipped: "still-streaming" };
398
+ }
310
399
  answer = domFallback.answer;
311
400
  env.fallbackUsed = answer ? "dom" : null;
312
401
  }
@@ -335,15 +424,25 @@ async function extractAnswer(tab, env) {
335
424
  trimmed.length <= 50 &&
336
425
  /\s|[.,!?;:]/.test(trimmed);
337
426
  const looksLikeLongAnswer = trimmed.length > 50;
338
- if (!looksLikeShortAnswer && !looksLikeLongAnswer) {
427
+ const words = trimmed.split(/\s+/).filter(Boolean);
428
+ const domainRepeats = (
429
+ trimmed.match(/\b[a-z0-9-]+\.(?:com|org|net|dev|io)\b/gi) || []
430
+ ).length;
431
+ const looksLikeCitationStub =
432
+ domainRepeats >= 4 &&
433
+ domainRepeats >= Math.max(3, Math.floor(words.length / 3));
434
+ if (
435
+ (!looksLikeShortAnswer && !looksLikeLongAnswer) ||
436
+ looksLikeCitationStub
437
+ ) {
339
438
  console.error(
340
- `[chatgpt] DOM fallback answer suspiciously short (${trimmed.length} chars: ${JSON.stringify(trimmed.slice(0, 80))}) — returning empty for caller to retry`,
439
+ `[chatgpt] DOM fallback answer suspicious (${trimmed.length} chars, domainRepeats=${domainRepeats}: ${JSON.stringify(trimmed.slice(0, 80))}) — returning empty for caller to retry`,
341
440
  );
342
441
  env.fallbackUsed = null;
343
442
  return {
344
443
  answer: "",
345
444
  sources: [],
346
- skipped: "header-stub",
445
+ skipped: looksLikeCitationStub ? "citation-stub" : "header-stub",
347
446
  };
348
447
  }
349
448
  }
@@ -518,15 +617,20 @@ async function main() {
518
617
  if (!answer) {
519
618
  env.blockedBy = "no-response";
520
619
  env.skipped = skipped || null;
521
- throw new Error(
522
- skipped === "no-user-message"
523
- ? "ChatGPT still on homepage — query was not submitted"
524
- : skipped === "no-assistant-response"
525
- ? "ChatGPT did not return an assistant response after submit"
526
- : skipped === "header-stub"
527
- ? "ChatGPT response appeared to be a header stub after 3 retries — assistant never rendered the body"
528
- : "ChatGPT returned no answer — assistant never responded",
529
- );
620
+ let message = "ChatGPT returned no answer — assistant never responded";
621
+ if (skipped === "no-user-message") {
622
+ message = "ChatGPT still on homepage — query was not submitted";
623
+ } else if (skipped === "no-assistant-response") {
624
+ message = "ChatGPT did not return an assistant response after submit";
625
+ } else if (
626
+ skipped === "header-stub" ||
627
+ skipped === "citation-stub" ||
628
+ skipped === "still-streaming"
629
+ ) {
630
+ message =
631
+ "ChatGPT response did not finish rendering after 3 retries — assistant never rendered the body";
632
+ }
633
+ throw new Error(message);
530
634
  }
531
635
  logStage(env, "done", startTime);
532
636
 
@@ -66,6 +66,17 @@ async function typeIntoGemini(tab, text) {
66
66
  }
67
67
  }
68
68
 
69
+ async function bringToFront(tab, { required = false } = {}) {
70
+ try {
71
+ await cdp(["evalraw", tab, "Page.bringToFront", "{}"], 5000);
72
+ return true;
73
+ } catch (error) {
74
+ console.error(`[gemini] bringToFront failed: ${error.message}`);
75
+ if (required) throw error;
76
+ return false;
77
+ }
78
+ }
79
+
69
80
  async function scrollToBottom(tab) {
70
81
  await cdp([
71
82
  "eval",
@@ -93,7 +104,8 @@ async function extractAnswerFromDom(tab) {
93
104
  new Promise((resolve) => {
94
105
  const _deadline = Date.now() + 6000;
95
106
  function _tryExtract() {
96
- const resp = document.querySelector('model-response');
107
+ const responses = Array.from(document.querySelectorAll('model-response'));
108
+ const resp = responses[responses.length - 1];
97
109
  if (resp) {
98
110
  const text = (resp.innerText || resp.textContent || '').trim();
99
111
  const idx = text.indexOf('\n');
@@ -126,11 +138,32 @@ async function extractAnswerFromDom(tab) {
126
138
  );
127
139
  try {
128
140
  return JSON.parse(raw);
129
- } catch {
141
+ } catch (error) {
142
+ console.error(
143
+ `[gemini] DOM fallback JSON parse failed (${raw.length} chars): ${error.message}`,
144
+ );
130
145
  return { answer: "", sources: [] };
131
146
  }
132
147
  }
133
148
 
149
+ async function clickLatestModelResponseCopy(tab) {
150
+ return cdp([
151
+ "eval",
152
+ tab,
153
+ `(() => {
154
+ const responses = Array.from(document.querySelectorAll('model-response'));
155
+ const resp = responses[responses.length - 1];
156
+ if (!resp) return 'no-model-response';
157
+ const buttons = Array.from(resp.querySelectorAll('${S.copyButton}'));
158
+ const nonCodeButtons = buttons.filter((btn) => !btn.closest('pre, code, code-block, .code-block, .code-container'));
159
+ const btn = nonCodeButtons[nonCodeButtons.length - 1] || buttons[buttons.length - 1];
160
+ if (!btn) return 'no-copy-button';
161
+ btn.click();
162
+ return 'clicked';
163
+ })()`,
164
+ ]);
165
+ }
166
+
134
167
  async function extractAnswer(tab, query = "") {
135
168
  const queryNorm = query.toLowerCase().trim();
136
169
 
@@ -151,7 +184,8 @@ async function extractAnswer(tab, query = "") {
151
184
  "eval",
152
185
  tab,
153
186
  String.raw`(() => {
154
- const r = document.querySelector('model-response');
187
+ const responses = Array.from(document.querySelectorAll('model-response'));
188
+ const r = responses[responses.length - 1];
155
189
  if (!r) return false;
156
190
  const t = (r.innerText || '').trim();
157
191
  // Must have content beyond the locale-specific label
@@ -174,18 +208,7 @@ async function extractAnswer(tab, query = "") {
174
208
  // not the absolute last copy button on the page. The page has many
175
209
  // copy icons (copy link, copy code, etc.) and the last one is not
176
210
  // always the assistant's response copy button.
177
- await cdp([
178
- "eval",
179
- tab,
180
- `(() => {
181
- const resp = document.querySelector('model-response');
182
- if (!resp) return 'no-model-response';
183
- const btn = resp.querySelector('${S.copyButton}');
184
- if (!btn) return 'no-copy-button';
185
- btn.click();
186
- return 'clicked';
187
- })()`,
188
- ]);
211
+ await clickLatestModelResponseCopy(tab);
189
212
  await new Promise((r) => setTimeout(r, 600));
190
213
 
191
214
  let answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
@@ -200,35 +223,32 @@ async function extractAnswer(tab, query = "") {
200
223
  ) {
201
224
  console.error("[gemini] Clipboard echoed query, retrying in 2s...");
202
225
  await new Promise((r) => setTimeout(r, 2000));
203
- await cdp([
204
- "eval",
205
- tab,
206
- `(() => {
207
- const resp = document.querySelector('model-response');
208
- if (!resp) return 'no-model-response';
209
- const btn = resp.querySelector('${S.copyButton}');
210
- if (!btn) return 'no-copy-button';
211
- btn.click();
212
- return 'clicked';
213
- })()`,
214
- ]);
226
+ await clickLatestModelResponseCopy(tab);
215
227
  await new Promise((r) => setTimeout(r, 600));
216
228
  answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
217
229
  }
218
230
 
219
- // DOM fallback: if the clipboard is empty or still echoes the query,
220
- // read the model-response innerText directly. This handles the case
221
- // where the copy button never rendered (response never appeared) or
222
- // the click didn't fire.
231
+ // DOM fallback: if clipboard is empty, echoes the query, or looks like a
232
+ // short snippet, read the model-response innerText directly. Skip the
233
+ // fallback when native copy already returned a substantial full answer.
223
234
  let domFallback = null;
224
- if (
225
- !answer ||
226
- (queryNorm &&
227
- (answer.toLowerCase().trim() === queryNorm ||
228
- answer.trim().length < queryNorm.length))
229
- ) {
235
+ const answerText = answer?.trim() || "";
236
+ const clipboardLooksEchoed =
237
+ queryNorm &&
238
+ (answerText.toLowerCase() === queryNorm ||
239
+ answerText.length < queryNorm.length);
240
+ const clipboardLooksComplete =
241
+ answerText.length > 500 && !clipboardLooksEchoed;
242
+ if (!clipboardLooksComplete) {
230
243
  domFallback = await extractAnswerFromDom(tab);
231
- if (domFallback.answer) {
244
+ const domExtendsClipboard =
245
+ answerText &&
246
+ domFallback.answer.includes(answerText) &&
247
+ domFallback.answer.length > answerText.length * 2;
248
+ if (
249
+ domFallback.answer &&
250
+ (!answerText || clipboardLooksEchoed || domExtendsClipboard)
251
+ ) {
232
252
  answer = domFallback.answer;
233
253
  }
234
254
  }
@@ -304,6 +324,7 @@ async function main() {
304
324
  await waitForSelector(tab, S.input, 8000, TIMING.inputPoll);
305
325
  await new Promise((r) => setTimeout(r, jitter(TIMING.postClick)));
306
326
 
327
+ await bringToFront(tab, { required: true });
307
328
  await injectClipboardInterceptor(tab, GLOBAL_VAR);
308
329
  await typeIntoGemini(tab, query);
309
330
  await new Promise((r) => setTimeout(r, jitter(TIMING.postType)));
@@ -316,8 +337,10 @@ async function main() {
316
337
 
317
338
  // Wait for Gemini's response to finish streaming before extracting.
318
339
  // Periodic scrolling keeps lazy-loaded content triggered in the viewport.
340
+ await bringToFront(tab);
319
341
  let pollTick = 0;
320
342
  const scrollInterval = setInterval(() => {
343
+ bringToFront(tab).catch(() => null);
321
344
  if (++pollTick % 10 === 0) scrollToBottom(tab).catch(() => null);
322
345
  }, 6000);
323
346
  try {
@@ -325,6 +348,8 @@ async function main() {
325
348
  timeout: 45000,
326
349
  stableRounds: 5,
327
350
  minLength: 60,
351
+ selector:
352
+ "(() => { const responses = Array.from(document.querySelectorAll('model-response')); return responses[responses.length - 1]; })()",
328
353
  });
329
354
  } finally {
330
355
  clearInterval(scrollInterval);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apmantza/greedysearch-pi",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "description": "Headless multi-engine AI search (Perplexity, Google AI, ChatGPT, Gemini) via browser automation. NO API KEYS needed. Grounded all-engine search fetches sources by default, with optional configurable synthesis and deep research.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -22,7 +22,11 @@
22
22
  "test:bash:quick": "./test.sh quick",
23
23
  "test:bash:smoke": "./test.sh smoke",
24
24
  "lint": "node scripts/lint.mjs",
25
- "check:lockfile": "node scripts/check-lockfile.mjs"
25
+ "check:lockfile": "node scripts/check-lockfile.mjs",
26
+ "changelog:check": "node scripts/changelog-release.mjs --check",
27
+ "changelog:release": "node scripts/changelog-release.mjs",
28
+ "changelog:extract": "node scripts/changelog-extract.mjs",
29
+ "release:backfill-notes": "node scripts/backfill-github-releases.mjs"
26
30
  },
27
31
  "engines": {
28
32
  "node": ">=20.11.0"
@@ -32,29 +36,26 @@
32
36
  "test.mjs",
33
37
  "bin/",
34
38
  "src/",
35
- "skills/",
36
39
  "extractors/",
40
+ "scripts/",
37
41
  "CHANGELOG.md",
38
42
  "README.md",
39
- "docs/banner.png"
43
+ "docs/"
40
44
  ],
41
45
  "pi": {
42
46
  "extensions": [
43
47
  "./index.ts"
44
- ],
45
- "skills": [
46
- "./skills"
47
48
  ]
48
49
  },
49
50
  "dependencies": {
50
51
  "@mozilla/readability": "^0.6.0",
51
- "@sinclair/typebox": "^0.34.48",
52
+ "@sinclair/typebox": "^0.34.49",
52
53
  "jsdom": "^29.1.1",
53
54
  "pdf-parse": "^2.4.5",
54
55
  "turndown": "^7.1.2"
55
56
  },
56
57
  "peerDependencies": {
57
- "@earendil-works/pi-coding-agent": "*"
58
+ "@earendil-works/pi-coding-agent": "^0.79.0"
58
59
  },
59
60
  "peerDependenciesMeta": {
60
61
  "@earendil-works/pi-coding-agent": {