@apmantza/greedysearch-pi 1.9.2 → 2.1.2

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +132 -2
  2. package/README.md +82 -47
  3. package/bin/cdp.mjs +1153 -1108
  4. package/bin/launch.mjs +9 -0
  5. package/bin/search.mjs +318 -81
  6. package/extractors/bing-copilot.mjs +48 -18
  7. package/extractors/chatgpt.mjs +553 -0
  8. package/extractors/common.mjs +213 -22
  9. package/extractors/consensus.mjs +655 -0
  10. package/extractors/consent.mjs +182 -18
  11. package/extractors/gemini.mjs +350 -217
  12. package/extractors/google-ai.mjs +129 -128
  13. package/extractors/logically.mjs +629 -0
  14. package/extractors/perplexity.mjs +547 -217
  15. package/extractors/selectors.mjs +3 -2
  16. package/extractors/semantic-scholar.mjs +219 -0
  17. package/package.json +8 -4
  18. package/skills/greedy-search/skill.md +20 -12
  19. package/src/fetcher.mjs +23 -1
  20. package/src/formatters/results.ts +185 -128
  21. package/src/search/browser-lifecycle.mjs +27 -5
  22. package/src/search/challenge-detect.mjs +205 -0
  23. package/src/search/chrome.mjs +653 -590
  24. package/src/search/constants.mjs +155 -39
  25. package/src/search/engines.mjs +114 -76
  26. package/src/search/fetch-source.mjs +566 -451
  27. package/src/search/pdf.mjs +68 -0
  28. package/src/search/progress.mjs +145 -0
  29. package/src/search/recovery.mjs +73 -45
  30. package/src/search/research.mjs +1419 -62
  31. package/src/search/scale-aware.mjs +93 -0
  32. package/src/search/simple-research.mjs +520 -0
  33. package/src/search/sources.mjs +52 -22
  34. package/src/search/synthesis-runner.mjs +105 -26
  35. package/src/search/synthesis.mjs +286 -246
  36. package/src/tools/greedy-search-handler.ts +129 -59
  37. package/src/tools/shared.ts +312 -186
  38. package/src/types.ts +110 -104
  39. package/test.mjs +537 -18
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env node
2
+
3
+ // extractors/logically.mjs
4
+ // Navigate logically.app/research-assistant, submit a question, wait for the
5
+ // answer to stream, then extract the rendered answer HTML and citation popovers.
6
+ //
7
+ // Usage:
8
+ // node extractors/logically.mjs "<query>" [--tab <prefix>]
9
+ //
10
+ // Output (stdout): JSON { query, url, answer, answerHtml, sources }
11
+ // Errors go to stderr only — stdout is always clean JSON for piping.
12
+
13
+ import {
14
+ buildEnvelope,
15
+ cdp,
16
+ formatAnswer,
17
+ getOrOpenTab,
18
+ handleError,
19
+ jitter,
20
+ logStage,
21
+ outputJson,
22
+ parseArgs,
23
+ prepareArgs,
24
+ TIMING,
25
+ validateQuery,
26
+ waitForSelector,
27
+ } from "./common.mjs";
28
+ import { readFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { ensureChrome } from "../src/search/chrome.mjs";
31
+
32
+ const START_URL = "https://logically.app/research-assistant/";
33
+
34
+ const SELECTORS = {
35
+ input:
36
+ 'div.ProseMirror[contenteditable="true"]',
37
+ submitButton:
38
+ '.chat-control button[class*="MuiButton-black"], button[type="submit"]',
39
+ answerContainer: "#last-message .chat-content, [class*=\"chat-content\"]",
40
+ citationSpan: "#last-message .chat-content span[title], [class*=\"chat-content\"] span[title]",
41
+ };
42
+
43
+ async function startNewChat(tab) {
44
+ // Best-effort. A new navigation usually lands on a blank prompt, but if the
45
+ // SPA restores the previous anonymous chat, the sidebar Create button resets it.
46
+ const code = `(() => {
47
+ const buttons = Array.from(document.querySelectorAll('button'));
48
+ const create = buttons.find((b) => (b.innerText || '').trim() === 'Create');
49
+ if (!create) return 'missing';
50
+ create.click();
51
+ return 'clicked';
52
+ })()`;
53
+ return cdp(["eval", tab, code], 5000).catch(() => "error");
54
+ }
55
+
56
+ async function detectLoginWall(tab) {
57
+ const code = `(() => {
58
+ const url = document.location.href || '';
59
+ if (url.includes('/login') || url.includes('/signup') || url.includes('/sign-up')) return true;
60
+ const modalText = Array.from(document.querySelectorAll('[role="dialog"], .MuiModal-root'))
61
+ .map((el) => el.innerText || '')
62
+ .join('\n');
63
+ return /create a free account|continue with google|have an account[?] log in|log in|sign up/i.test(modalText);
64
+ })()`;
65
+ return (await cdp(["eval", tab, code], 5000).catch(() => "false")) === "true";
66
+ }
67
+
68
+ async function activateTab(tab) {
69
+ try {
70
+ await cdp(["list"]);
71
+ const cachePath = `${tmpdir().replaceAll("\\\\", "/")}/cdp-pages.json`;
72
+ const pages = JSON.parse(readFileSync(cachePath, "utf8"));
73
+ const fullTargetId = pages.find((p) =>
74
+ p.targetId?.startsWith(tab),
75
+ )?.targetId;
76
+ if (fullTargetId) {
77
+ await cdp([
78
+ "browse",
79
+ tab,
80
+ "Target.activateTarget",
81
+ JSON.stringify({ targetId: fullTargetId }),
82
+ ]).catch(() => null);
83
+ await new Promise((r) => setTimeout(r, 250));
84
+ }
85
+ } catch {
86
+ // Best-effort only. Headless does not need activation, and visible Chrome
87
+ // still often accepts CDP input without it.
88
+ }
89
+ }
90
+
91
+ async function typeIntoLogically(tab, text) {
92
+ await activateTab(tab);
93
+ const pointRaw = await cdp([
94
+ "eval",
95
+ tab,
96
+ `(() => {
97
+ const inputs = Array.from(document.querySelectorAll('${SELECTORS.input}'));
98
+ const input = inputs.find((el) => {
99
+ const r = el.getBoundingClientRect();
100
+ return r.width > 20 && r.height > 5;
101
+ });
102
+ if (!input) return '';
103
+ input.scrollIntoView({ block: 'center', inline: 'center' });
104
+ const r = input.getBoundingClientRect();
105
+ return JSON.stringify({ x: Math.round(r.left + Math.min(80, r.width / 2)), y: Math.round(r.top + r.height / 2) });
106
+ })()`,
107
+ ]);
108
+ if (!pointRaw) throw new Error("Logically visible input not found");
109
+ const point = JSON.parse(pointRaw);
110
+ // Use both selector click and coordinate click. The ProseMirror editor is
111
+ // sometimes nested in animated MUI layout; selector click can land on a stale
112
+ // box, while coordinate click reliably focuses the visible editor after the
113
+ // in-page scrollIntoView above.
114
+ await cdp(["click", tab, SELECTORS.input]).catch(() => null);
115
+ await new Promise((r) => setTimeout(r, jitter(120)));
116
+ await cdp(["clickxy", tab, String(point.x), String(point.y)]);
117
+ await new Promise((r) => setTimeout(r, jitter(TIMING.postClick)));
118
+ // Use tiptap's commands API to insert text. CDP's Input.insertText
119
+ // targets input/textarea elements and doesn't dispatch the events
120
+ // that ProseMirror/tiptap's editor view listens for. The tiptap
121
+ // editor instance is accessible via ed.editor.commands.
122
+ const typeResult = await cdp(
123
+ [
124
+ "eval",
125
+ tab,
126
+ `(() => {
127
+ const ed = document.querySelector('${SELECTORS.input}');
128
+ if (!ed) return 'no-input';
129
+ ed.focus();
130
+ if (!ed.editor || !ed.editor.commands) {
131
+ // Fallback to execCommand for non-tiptap editors
132
+ const ok = document.execCommand('insertText', false, ${JSON.stringify(text)});
133
+ return ok ? 'ok' : 'exec-failed';
134
+ }
135
+ const ok = ed.editor.commands.insertContent(${JSON.stringify(text)});
136
+ return ok ? 'ok' : 'tiptap-failed';
137
+ })()`,
138
+ ],
139
+ 5000,
140
+ );
141
+ if (typeResult !== "ok") {
142
+ throw new Error(`Logically type failed: ${typeResult}`);
143
+ }
144
+ await new Promise((r) => setTimeout(r, jitter(TIMING.postType)));
145
+
146
+ const inserted = await cdp([
147
+ "eval",
148
+ tab,
149
+ `Array.from(document.querySelectorAll('${SELECTORS.input}')).some((el) => (el.innerText || '').length >= ${Math.floor(text.length * 0.8)})`,
150
+ ]);
151
+ if (inserted !== "true") {
152
+ throw new Error(
153
+ "Logically input did not accept text — input verification failed",
154
+ );
155
+ }
156
+ }
157
+
158
+ async function waitForLogicallyAnswer(tab, timeoutMs = 90000) {
159
+ const code = String.raw`
160
+ new Promise((resolve, reject) => {
161
+ const deadline = Date.now() + ${timeoutMs};
162
+ let last = '';
163
+ let stable = 0;
164
+ function poll() {
165
+ try {
166
+ const answer = document.querySelector('${SELECTORS.answerContainer}');
167
+ const text = (answer?.innerText || '').trim();
168
+ const body = document.body.innerText || '';
169
+ const stillGenerating = /Generating answer|Thinking\.\.\.|Searching the internet|Discovered \d+/.test(body) && text.length < 200;
170
+ if (text.length >= 200 && !stillGenerating) {
171
+ if (text === last) stable += 1;
172
+ else { last = text; stable = 0; }
173
+ if (stable >= 3) { resolve(text.length); return; }
174
+ }
175
+ if (Date.now() < deadline) setTimeout(poll, 700 + Math.random() * 250);
176
+ else reject(new Error('Logically answer did not stabilise within ${timeoutMs}ms'));
177
+ } catch (e) { reject(e); }
178
+ }
179
+ poll();
180
+ })`;
181
+ return cdp(["eval", tab, code], timeoutMs + 10000);
182
+ }
183
+
184
+ async function extractAnswer(tab) {
185
+ const code = `(() => {
186
+ const el = document.querySelector('${SELECTORS.answerContainer}');
187
+ if (!el) return JSON.stringify({ answer: '', answerHtml: '' });
188
+ const clone = el.cloneNode(true);
189
+ clone.querySelectorAll('svg').forEach((n) => n.remove());
190
+ clone.querySelectorAll('span[width], span[height]').forEach((n) => {
191
+ if (!(n.innerText || '').trim()) n.remove();
192
+ });
193
+ return JSON.stringify({
194
+ answer: (el.innerText || '').trim(),
195
+ answerHtml: clone.innerHTML,
196
+ });
197
+ })()`;
198
+ return JSON.parse(await cdp(["eval", tab, code], 10000));
199
+ }
200
+
201
+ async function extractFullCitationSources(tab) {
202
+ // The rendered answer has inline citations, but the complete citation set is
203
+ // behind the `Citations (N)` button. Click it and parse the popover's Academic
204
+ // cards plus Web URL blocks. This is the only place where Logically exposes
205
+ // the full citation count (for example Academic (20) + Web (29) = 49).
206
+ const code = String.raw`
207
+ (async () => {
208
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
209
+
210
+ function clickElement(el) {
211
+ el.scrollIntoView({ block: 'center', inline: 'center' });
212
+ for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) {
213
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
214
+ }
215
+ }
216
+
217
+ function citationCardCount(root) {
218
+ return Array.from(root.querySelectorAll('div')).filter((el) => {
219
+ const text = (el.innerText || '').trim();
220
+ return /^(Academic|Web)\n\n/.test(text) && text.includes('\nView');
221
+ }).length;
222
+ }
223
+
224
+ function findFullCitationsPopover() {
225
+ return Array.from(document.querySelectorAll('.MuiPopover-root, .MuiModal-root, .MuiDrawer-root, [role="dialog"]'))
226
+ .find((el) => {
227
+ const text = el.innerText || '';
228
+ // Visible mode includes the tab header. Headless sometimes omits the
229
+ // header and renders only a scrollable card list in a popover with no id.
230
+ if (/Academic \(\d+\)|Web \(\d+\)/.test(text)) return true;
231
+ if (el.id?.startsWith('citation-')) return false;
232
+ return citationCardCount(el) > 0;
233
+ });
234
+ }
235
+
236
+ function waitForFullCitationsPopover() {
237
+ return new Promise((resolve) => {
238
+ const deadline = Date.now() + 5000;
239
+ function poll() {
240
+ const pop = findFullCitationsPopover();
241
+ if (pop) { resolve(pop); return; }
242
+ if (Date.now() < deadline) setTimeout(poll, 100);
243
+ else resolve(null);
244
+ }
245
+ poll();
246
+ });
247
+ }
248
+
249
+ function linesOf(el) {
250
+ return (el?.innerText || '').split(/\n+/).map((s) => s.trim()).filter(Boolean);
251
+ }
252
+
253
+ function textAfter(label, text) {
254
+ const re = new RegExp(label + ':\\s*([^\\n]+)', 'i');
255
+ return text.match(re)?.[1]?.trim() || '';
256
+ }
257
+
258
+ function parseAcademicCard(card, idx) {
259
+ const lines = linesOf(card);
260
+ const text = lines.join('\n');
261
+ const citationCount = Number((lines[1] || '').match(/\d+/)?.[0] || '') || null;
262
+ return {
263
+ type: 'academic',
264
+ citationIndex: idx + 1,
265
+ url: '',
266
+ title: lines[2] || '',
267
+ authors: lines[3] || textAfter('Authors', text),
268
+ venue: lines[4] || textAfter('Journal', text),
269
+ publicationDate: textAfter('Publication Date', text),
270
+ citationCount,
271
+ references: Number(text.match(/References:\s*(\d+)/i)?.[1] || '') || null,
272
+ fieldsOfStudy: textAfter('Fields of Study', text),
273
+ publicationTypes: textAfter('Publication Types', text),
274
+ snippet: lines.find((line) => /^(TLDR|Abstract):/i.test(line)) || '',
275
+ };
276
+ }
277
+
278
+ function isUrlBlockAnchor(a) {
279
+ let n = a;
280
+ for (let i = 0; n && i < 5; i++, n = n.parentElement) {
281
+ if (/^URL:\s*/.test((n.innerText || '').trim())) return true;
282
+ }
283
+ return false;
284
+ }
285
+
286
+ function parseWebAnchor(a, idx) {
287
+ let card = a;
288
+ for (let i = 0; card && i < 10; i++, card = card.parentElement) {
289
+ const text = card.innerText || '';
290
+ if (/\nUpdated:\s*/.test(text) && /\nURL:\s*/.test(text)) break;
291
+ }
292
+ const lines = linesOf(card || a.parentElement);
293
+ const url = a.href || '';
294
+ let title = lines.find((line) => line && !/^URL:|^Updated:|^Add to library$|^View$/.test(line) && line !== a.innerText) || '';
295
+ if (/^[\w.-]+\.[a-z]{2,}$/i.test(title)) title = lines[1] || title;
296
+ return {
297
+ type: 'web',
298
+ citationIndex: idx + 1,
299
+ url,
300
+ title: title || a.innerText || url,
301
+ domain: (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })(),
302
+ updated: textAfter('Updated', lines.join('\n')),
303
+ snippet: lines.filter((line) => !/^URL:|^Updated:|^Add to library$|^View$/.test(line)).slice(0, 8).join('\n'),
304
+ };
305
+ }
306
+
307
+ const btn = Array.from(document.querySelectorAll('button'))
308
+ .find((b) => /Citations\s*\n*\s*\(\d+\)/i.test(b.innerText || ''));
309
+ if (!btn) return JSON.stringify({ sources: [], summary: { expectedTotal: 0, academic: 0, web: 0, reason: 'button-not-found' } });
310
+ const buttonTotal = Number((btn.innerText || '').match(/\((\d+)\)/)?.[1] || '0');
311
+ // Inline citation popovers are independent MUI modals and can remain mounted
312
+ // after we click answer citations. Remove those stale id-bearing roots before
313
+ // opening the full Citations popover so headless mode does not keep focus in
314
+ // old inline popovers.
315
+ document.querySelectorAll('.MuiPopover-root[id^="citation-"]').forEach((el) => el.remove());
316
+ clickElement(btn);
317
+ const pop = await waitForFullCitationsPopover();
318
+ if (!pop) return JSON.stringify({ sources: [], summary: { expectedTotal: buttonTotal, academic: 0, web: 0, reason: 'popover-not-found' } });
319
+
320
+ const header = pop.innerText || '';
321
+ const academicExpected = Number(header.match(/Academic \((\d+)\)/)?.[1] || '0');
322
+ const webExpected = Number(header.match(/Web \((\d+)\)/)?.[1] || '0');
323
+ const allDivs = Array.from(pop.querySelectorAll('div'));
324
+ const academicCandidates = allDivs.filter((el) => {
325
+ const text = (el.innerText || '').trim();
326
+ return /^Academic\n\n/.test(text) && text.includes('\nView');
327
+ });
328
+ const academicCards = academicCandidates.filter((el) =>
329
+ !academicCandidates.some((other) => other !== el && el.contains(other))
330
+ );
331
+ const academicSources = academicCards.map(parseAcademicCard).filter((s) => s.title);
332
+
333
+ const webAnchors = Array.from(pop.querySelectorAll('a[href]')).filter(isUrlBlockAnchor);
334
+ const webSources = webAnchors.map(parseWebAnchor).filter((s) => s.url);
335
+
336
+ return JSON.stringify({
337
+ sources: [...academicSources, ...webSources],
338
+ summary: {
339
+ expectedTotal: academicExpected + webExpected || buttonTotal,
340
+ academicExpected,
341
+ webExpected,
342
+ academicCaptured: academicSources.length,
343
+ webCaptured: webSources.length,
344
+ },
345
+ });
346
+ })()`;
347
+ const raw = await cdp(["eval", tab, code], 20000);
348
+ return JSON.parse(raw);
349
+ }
350
+
351
+ async function extractCitationSources(tab) {
352
+ // Logically renders citation labels inside the answer as clickable spans.
353
+ // The actual source URL and metadata are mounted into a MUI Popover only
354
+ // after clicking the span. This single browser-side async routine clicks the
355
+ // distinct citation spans, walks grouped citations with the next button, and
356
+ // returns the popover metadata without Node-side DOM polling.
357
+ const code = String.raw`
358
+ (async () => {
359
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
360
+ const answer = document.querySelector('${SELECTORS.answerContainer}');
361
+ if (!answer) return JSON.stringify([]);
362
+
363
+ function parseUrlFromId(id) {
364
+ const m = String(id || '').match(/^citation-(https?:\/\/.+)-(\d+)$/);
365
+ return m ? { url: m[1], index: Number(m[2]) } : { url: '', index: null };
366
+ }
367
+
368
+ function scrapePopover() {
369
+ const pop = document.querySelector('.MuiPopover-root[id^="citation-"]');
370
+ if (!pop) return null;
371
+ const lines = (pop.innerText || '').split(/\n+/).map((s) => s.trim()).filter(Boolean);
372
+ const posIdx = lines.findIndex((line) => /^\d+\s+of\s+\d+$/i.test(line));
373
+ const { url, index } = parseUrlFromId(pop.id);
374
+ const citationCountLine = lines.find((line) => /^\d+\s+citations?$/i.test(line));
375
+ return {
376
+ url,
377
+ index,
378
+ type: lines[0] || '',
379
+ citationCount: citationCountLine ? Number(citationCountLine.match(/\d+/)?.[0]) : null,
380
+ position: posIdx !== -1 ? lines[posIdx] : '',
381
+ title: posIdx !== -1 ? (lines[posIdx + 1] || '') : '',
382
+ authors: posIdx !== -1 ? (lines[posIdx + 2] || '') : '',
383
+ venue: posIdx !== -1 ? (lines[posIdx + 3] || '') : '',
384
+ snippet: lines.find((line) => /^(TLDR|Abstract|Snippet|Description):/i.test(line)) || '',
385
+ };
386
+ }
387
+
388
+ async function waitForPopover(previousId = '') {
389
+ const deadline = Date.now() + 4000;
390
+ while (Date.now() < deadline) {
391
+ const pop = document.querySelector('.MuiPopover-root[id^="citation-"]');
392
+ if (pop && pop.id !== previousId) return pop;
393
+ await sleep(100);
394
+ }
395
+ return document.querySelector('.MuiPopover-root[id^="citation-"]');
396
+ }
397
+
398
+ function clickElement(el) {
399
+ el.scrollIntoView({ block: 'center', inline: 'center' });
400
+ for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) {
401
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
402
+ }
403
+ }
404
+
405
+ const spans = Array.from(answer.querySelectorAll('span[title]'))
406
+ .filter((s) => (s.innerText || s.title || '').trim());
407
+ const groups = [];
408
+ const seenGroups = new Set();
409
+ for (const span of spans) {
410
+ const text = (span.innerText || '').trim();
411
+ const plus = Number(text.match(/\(\+(\d+)\)/)?.[1] || '0');
412
+ const key = String(span.title) + '::' + String(plus);
413
+ if (seenGroups.has(key)) continue;
414
+ seenGroups.add(key);
415
+ groups.push({ span, title: span.title, count: Math.min(plus + 1, 12) });
416
+ if (groups.length >= 25) break;
417
+ }
418
+
419
+ const sources = [];
420
+ const seenUrls = new Set();
421
+ for (const group of groups) {
422
+ const previous = document.querySelector('.MuiPopover-root[id^="citation-"]')?.id || '';
423
+ clickElement(group.span);
424
+ await waitForPopover(previous);
425
+ for (let i = 0; i < group.count; i++) {
426
+ await sleep(150);
427
+ const src = scrapePopover();
428
+ if (src) {
429
+ if (!src.title) src.title = group.title;
430
+ if (src.url && !seenUrls.has(src.url)) {
431
+ seenUrls.add(src.url);
432
+ sources.push(src);
433
+ } else if (!src.url) {
434
+ const key = 'title:' + String(src.title || '');
435
+ if (!seenUrls.has(key)) { seenUrls.add(key); sources.push(src); }
436
+ }
437
+ }
438
+ if (i < group.count - 1) {
439
+ const pop = document.querySelector('.MuiPopover-root[id^="citation-"]');
440
+ const before = pop?.id || '';
441
+ const buttons = Array.from(pop?.querySelectorAll('button') || []);
442
+ const next = buttons.find((b) => !b.disabled && !(b.innerText || '').trim());
443
+ if (!next) break;
444
+ next.click();
445
+ await waitForPopover(before);
446
+ }
447
+ }
448
+ }
449
+
450
+ if (sources.length === 0) {
451
+ for (const span of spans) {
452
+ const title = span.title || (span.innerText || '').trim();
453
+ const key = 'title:' + String(title || '');
454
+ if (title && !seenUrls.has(key)) {
455
+ seenUrls.add(key);
456
+ sources.push({ title, url: '', type: 'citation-label' });
457
+ }
458
+ }
459
+ }
460
+ return JSON.stringify(sources);
461
+ })()`;
462
+ const raw = await cdp(["eval", tab, code], 45000);
463
+ return JSON.parse(raw);
464
+ }
465
+
466
+ const USAGE =
467
+ 'Usage: node extractors/logically.mjs "<query>" [--tab <prefix>]\n';
468
+
469
+ async function main() {
470
+ const args = await prepareArgs(process.argv.slice(2));
471
+ validateQuery(args, USAGE);
472
+
473
+ const { query, tabPrefix, short } = parseArgs(args);
474
+ const startTime = Date.now();
475
+ const mode =
476
+ process.env.GREEDY_SEARCH_VISIBLE === "1" ? "visible" : "headless";
477
+ const env = {
478
+ engine: "logically",
479
+ mode,
480
+ clipboardEmpty: null,
481
+ fallbackUsed: null,
482
+ blockedBy: null,
483
+ verificationResult: null,
484
+ inputReady: null,
485
+ };
486
+
487
+ try {
488
+ if (
489
+ process.env.GREEDY_SEARCH_VISIBLE !== "1" &&
490
+ process.env.GREEDY_SEARCH_ALWAYS_VISIBLE !== "1"
491
+ ) {
492
+ process.env.GREEDY_SEARCH_HEADLESS = "1";
493
+ }
494
+ await ensureChrome();
495
+
496
+ if (!tabPrefix) await cdp(["list"]);
497
+ const tab = await getOrOpenTab(tabPrefix);
498
+
499
+ const currentUrl = await cdp(["eval", tab, "document.location.href"]).catch(
500
+ () => "",
501
+ );
502
+ let onLogically = false;
503
+ try {
504
+ const host = new URL(currentUrl).hostname.toLowerCase();
505
+ onLogically = host === "logically.app" || host.endsWith(".logically.app");
506
+ } catch {}
507
+
508
+ if (!onLogically) {
509
+ logStage(env, "nav", startTime);
510
+ await cdp(["nav", tab, START_URL], 25000);
511
+ await new Promise((r) => setTimeout(r, 900));
512
+ }
513
+
514
+ logStage(env, "new-chat", startTime);
515
+ await startNewChat(tab);
516
+ await new Promise((r) => setTimeout(r, 700));
517
+
518
+ logStage(env, "input-wait", startTime);
519
+ const inputReady = await waitForSelector(tab, SELECTORS.input, 20000, 400);
520
+ env.inputReady = inputReady;
521
+ if (!inputReady) {
522
+ throw new Error(
523
+ "Logically input not found — page may not have loaded or is in unexpected state",
524
+ );
525
+ }
526
+
527
+ // Detect free-tier quota wall. Logically shows "Chat messages: N/5"
528
+ // in the sidebar. If the user is at 5/5, the "Create" button is
529
+ // disabled and queries can't be submitted. Same pattern as
530
+ // Perplexity's rate-limit wall — visible-mode cookies can't bypass
531
+ // this, it's account-level.
532
+ if (process.env.GREEDY_SEARCH_HEADLESS === "1") {
533
+ const quotaResult = await cdp(
534
+ [
535
+ "eval",
536
+ tab,
537
+ `(() => {
538
+ const text = document.body?.innerText || '';
539
+ const m = text.match(/Chat messages\\s*\\n?\\s*(\\d+)\\s*\\/\\s*(\\d+)/i);
540
+ if (m && parseInt(m[1], 10) >= parseInt(m[2], 10)) {
541
+ return 'quota-exceeded';
542
+ }
543
+ // Also check if Create button is disabled
544
+ const createBtn = Array.from(document.querySelectorAll('button')).find(
545
+ (b) => b.innerText.trim() === 'Create',
546
+ );
547
+ if (createBtn && createBtn.disabled) return 'create-disabled';
548
+ return 'ok';
549
+ })()`,
550
+ ],
551
+ 5000,
552
+ ).catch(() => "ok");
553
+ if (quotaResult === "quota-exceeded" || quotaResult === "create-disabled") {
554
+ console.error(
555
+ "[logically] Rate Limited — skipping (visible retry won't help)",
556
+ );
557
+ env.blockedBy = "rate-limit";
558
+ throw new Error(
559
+ "Rate Limited — Logically free message quota exhausted. Wait until reset.",
560
+ );
561
+ }
562
+ }
563
+
564
+ logStage(env, "type-and-submit", startTime);
565
+ await typeIntoLogically(tab, query);
566
+ const submitted = await cdp([
567
+ "eval",
568
+ tab,
569
+ `(() => { const btn = document.querySelector('${SELECTORS.submitButton}'); if (!btn) return 'missing'; btn.click(); return 'clicked'; })()`,
570
+ ]);
571
+ if (submitted !== "clicked")
572
+ throw new Error("Logically submit button not found");
573
+
574
+ // Anonymous sessions have a small free-message quota. Once exhausted,
575
+ // Logically opens a sign-up/login modal instead of generating an answer.
576
+ // Surface this like Consensus so the orchestrator can switch to visible
577
+ // Chrome and leave the browser open for the user to authenticate.
578
+ await new Promise((r) => setTimeout(r, 1500));
579
+ if (await detectLoginWall(tab)) {
580
+ env.blockedBy = "signin";
581
+ env.verificationResult = "needs-human";
582
+ throw new Error(
583
+ "Logically login required — please log in or create a free account in the visible browser window. Once signed in, cookies persist for future runs.",
584
+ );
585
+ }
586
+
587
+ logStage(env, "answer-wait", startTime);
588
+ await waitForLogicallyAnswer(tab, 90000);
589
+
590
+ logStage(env, "extract-answer-html", startTime);
591
+ const { answer, answerHtml } = await extractAnswer(tab);
592
+ if (!answer)
593
+ throw new Error("No answer extracted — Logically may not have responded");
594
+
595
+ logStage(env, "extract-inline-citations", startTime);
596
+ const inlineSources = await extractCitationSources(tab);
597
+
598
+ logStage(env, "extract-full-citations", startTime);
599
+ const fullCitations = await extractFullCitationSources(tab);
600
+ const sources = fullCitations.sources?.length
601
+ ? fullCitations.sources
602
+ : inlineSources;
603
+
604
+ const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
605
+ () => "",
606
+ );
607
+ env.durationMs = Date.now() - startTime;
608
+ env.sourcePath = fullCitations.sources?.length
609
+ ? "citations-popover"
610
+ : "inline-citation-popovers";
611
+ logStage(env, "done", startTime);
612
+
613
+ outputJson({
614
+ query,
615
+ url: finalUrl,
616
+ answer: formatAnswer(answer, short),
617
+ answerHtml,
618
+ sources,
619
+ inlineSources,
620
+ citationSummary: fullCitations.summary,
621
+ _envelope: buildEnvelope(env),
622
+ });
623
+ } catch (e) {
624
+ env.durationMs = Date.now() - startTime;
625
+ handleError(e, buildEnvelope(env));
626
+ }
627
+ }
628
+
629
+ main();