@apmantza/greedysearch-pi 1.1.0 → 1.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.
package/index.ts CHANGED
@@ -1,177 +1,222 @@
1
- /**
2
- * GreedySearch Pi Extension
3
- *
4
- * Adds a `greedy_search` tool to Pi that fans out queries to Perplexity,
5
- * Bing Copilot, and Google AI in parallel, returning synthesized AI answers.
6
- *
7
- * Requires Chrome to be running (or it auto-launches a dedicated instance).
8
- */
9
-
10
- import { spawn } from "node:child_process";
11
- import { existsSync } from "node:fs";
12
- import { join, dirname } from "node:path";
13
- import { fileURLToPath } from "node:url";
14
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
- import { Type } from "@sinclair/typebox";
16
-
17
- const __dir = dirname(fileURLToPath(import.meta.url));
18
-
19
- function cdpAvailable(): boolean {
20
- return existsSync(join(__dir, "cdp.mjs"));
21
- }
22
-
23
- function runSearch(engine: string, query: string, flags: string[] = []): Promise<Record<string, unknown>> {
24
- return new Promise((resolve, reject) => {
25
- const proc = spawn("node", [__dir + "/search.mjs", engine, "--inline", ...flags, query], {
26
- stdio: ["ignore", "pipe", "pipe"],
27
- });
28
- let out = "";
29
- let err = "";
30
- proc.stdout.on("data", (d: Buffer) => (out += d));
31
- proc.stderr.on("data", (d: Buffer) => (err += d));
32
- proc.on("close", (code: number) => {
33
- if (code !== 0) {
34
- reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
35
- } else {
36
- try {
37
- resolve(JSON.parse(out.trim()));
38
- } catch {
39
- reject(new Error(`Invalid JSON from search.mjs: ${out.slice(0, 200)}`));
40
- }
41
- }
42
- });
43
- });
44
- }
45
-
46
- function formatResults(engine: string, data: Record<string, unknown>): string {
47
- const lines: string[] = [];
48
-
49
- if (engine === "all") {
50
- // Synthesized output: prefer _synthesis + _sources
51
- const synthesis = data._synthesis as Record<string, unknown> | undefined;
52
- const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
53
- if (synthesis?.answer) {
54
- lines.push("## Synthesis");
55
- lines.push(String(synthesis.answer));
56
- if (dedupedSources?.length) {
57
- lines.push("\n**Top sources by consensus:**");
58
- for (const s of dedupedSources.slice(0, 6)) {
59
- const engines = (s.engines as string[]) || [];
60
- lines.push(`- [${s.title || s.url}](${s.url}) [${engines.length}/3]`);
61
- }
62
- }
63
- lines.push("\n---\n*Synthesized from Perplexity, Bing Copilot, and Google AI*");
64
- return lines.join("\n").trim();
65
- }
66
-
67
- // Standard output: per-engine answers
68
- for (const [eng, result] of Object.entries(data)) {
69
- if (eng.startsWith("_")) continue;
70
- lines.push(`\n## ${eng.charAt(0).toUpperCase() + eng.slice(1)}`);
71
- const r = result as Record<string, unknown>;
72
- if (r.error) {
73
- lines.push(`Error: ${r.error}`);
74
- } else {
75
- if (r.answer) lines.push(String(r.answer));
76
- if (Array.isArray(r.sources) && r.sources.length > 0) {
77
- lines.push("\nSources:");
78
- for (const s of r.sources.slice(0, 3)) {
79
- const src = s as Record<string, string>;
80
- lines.push(`- [${src.title || src.url}](${src.url})`);
81
- }
82
- }
83
- }
84
- }
85
- } else {
86
- if (data.error) {
87
- lines.push(`Error: ${data.error}`);
88
- } else {
89
- if (data.answer) lines.push(String(data.answer));
90
- if (Array.isArray(data.sources) && data.sources.length > 0) {
91
- lines.push("\nSources:");
92
- for (const s of data.sources.slice(0, 5)) {
93
- const src = s as Record<string, string>;
94
- lines.push(`- [${src.title || src.url}](${src.url})`);
95
- }
96
- }
97
- }
98
- }
99
-
100
- return lines.join("\n").trim();
101
- }
102
-
103
- export default function greedySearchExtension(pi: ExtensionAPI) {
104
- pi.on("session_start", async (_event, ctx) => {
105
- if (!cdpAvailable()) {
106
- ctx.ui.notify(
107
- "GreedySearch: cdp.mjs missing from package directory — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi",
108
- "warning",
109
- );
110
- }
111
- });
112
-
113
- pi.registerTool({
114
- name: "greedy_search",
115
- label: "Greedy Search",
116
- description:
117
- "Search the web using AI-powered engines (Perplexity, Bing Copilot, Google AI) in parallel. " +
118
- "Optionally synthesize results with Gemini deduplicates sources by consensus and returns one grounded answer. " +
119
- "Use for current information, library docs, error messages, best practices, or any question where training data may be stale.",
120
- parameters: Type.Object({
121
- query: Type.String({ description: "The search query" }),
122
- engine: Type.Union(
123
- [
124
- Type.Literal("all"),
125
- Type.Literal("perplexity"),
126
- Type.Literal("bing"),
127
- Type.Literal("google"),
128
- Type.Literal("gemini"),
129
- Type.Literal("gem"),
130
- ],
131
- {
132
- description: 'Engine to use. "all" fans out to Perplexity, Bing, and Google in parallel (default).',
133
- default: "all",
134
- },
135
- ),
136
- synthesize: Type.Optional(Type.Boolean({
137
- description: 'When true and engine is "all", deduplicates sources across engines and feeds them to Gemini for a single grounded synthesis. Adds ~30s but saves tokens and improves answer quality.',
138
- default: false,
139
- })),
140
- fullAnswer: Type.Optional(Type.Boolean({
141
- description: 'When true, returns the complete answer instead of a truncated preview (default: false, answers are shortened to ~300 chars to save tokens).',
142
- default: false,
143
- })),
144
- }),
145
- execute: async (_toolCallId, params) => {
146
- const { query, engine = "all", synthesize = false, fullAnswer = false } = params as { query: string; engine: string; synthesize?: boolean; fullAnswer?: boolean };
147
-
148
- if (!cdpAvailable()) {
149
- return {
150
- content: [{ type: "text", text: "cdp.mjs missing — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi" }],
151
- details: {} as { raw?: Record<string, unknown> },
152
- };
153
- }
154
-
155
- const flags: string[] = [];
156
- if (synthesize && engine === "all") flags.push("--synthesize");
157
- if (fullAnswer) flags.push("--full");
158
-
159
- let data: Record<string, unknown>;
160
- try {
161
- data = await runSearch(engine, query, flags);
162
- } catch (e) {
163
- const msg = e instanceof Error ? e.message : String(e);
164
- return {
165
- content: [{ type: "text", text: `Search failed: ${msg}` }],
166
- details: {} as { raw?: Record<string, unknown> },
167
- };
168
- }
169
-
170
- const text = formatResults(engine, data);
171
- return {
172
- content: [{ type: "text", text: text || "No results returned." }],
173
- details: { raw: data } as { raw?: Record<string, unknown> },
174
- };
175
- },
176
- });
177
- }
1
+ /**
2
+ * GreedySearch Pi Extension
3
+ *
4
+ * Adds a `greedy_search` tool to Pi that fans out queries to Perplexity,
5
+ * Bing Copilot, and Google AI in parallel, returning synthesized AI answers.
6
+ *
7
+ * Reports streaming progress as each engine completes.
8
+ * Requires Chrome to be running (or it auto-launches a dedicated instance).
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+ import { Type } from "@sinclair/typebox";
17
+
18
+ const __dir = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const ALL_ENGINES = ["perplexity", "bing", "google"] as const;
21
+
22
+ function cdpAvailable(): boolean {
23
+ return existsSync(join(__dir, "cdp.mjs"));
24
+ }
25
+
26
+ function runSearch(
27
+ engine: string,
28
+ query: string,
29
+ flags: string[] = [],
30
+ signal?: AbortSignal,
31
+ onProgress?: (engine: string, status: "done" | "error") => void,
32
+ ): Promise<Record<string, unknown>> {
33
+ return new Promise((resolve, reject) => {
34
+ const proc = spawn("node", [__dir + "/search.mjs", engine, "--inline", ...flags, query], {
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ let out = "";
38
+ let err = "";
39
+
40
+ const onAbort = () => { proc.kill("SIGTERM"); reject(new Error("Aborted")); };
41
+ signal?.addEventListener("abort", onAbort, { once: true });
42
+
43
+ // Watch stderr for progress events (PROGRESS:engine:done|error)
44
+ proc.stderr.on("data", (d: Buffer) => {
45
+ err += d;
46
+ const lines = d.toString().split("\n");
47
+ for (const line of lines) {
48
+ const match = line.match(/^PROGRESS:(\w+):(done|error)$/);
49
+ if (match && onProgress) {
50
+ onProgress(match[1], match[2] as "done" | "error");
51
+ }
52
+ }
53
+ });
54
+
55
+ proc.stdout.on("data", (d: Buffer) => (out += d));
56
+ proc.on("close", (code: number) => {
57
+ signal?.removeEventListener("abort", onAbort);
58
+ if (code !== 0) {
59
+ reject(new Error(err.trim() || `search.mjs exited with code ${code}`));
60
+ } else {
61
+ try {
62
+ resolve(JSON.parse(out.trim()));
63
+ } catch {
64
+ reject(new Error(`Invalid JSON from search.mjs: ${out.slice(0, 200)}`));
65
+ }
66
+ }
67
+ });
68
+ });
69
+ }
70
+
71
+ function formatResults(engine: string, data: Record<string, unknown>): string {
72
+ const lines: string[] = [];
73
+
74
+ if (engine === "all") {
75
+ // Synthesized output: prefer _synthesis + _sources
76
+ const synthesis = data._synthesis as Record<string, unknown> | undefined;
77
+ const dedupedSources = data._sources as Array<Record<string, unknown>> | undefined;
78
+ if (synthesis?.answer) {
79
+ lines.push("## Synthesis");
80
+ lines.push(String(synthesis.answer));
81
+ if (dedupedSources?.length) {
82
+ lines.push("\n**Top sources by consensus:**");
83
+ for (const s of dedupedSources.slice(0, 6)) {
84
+ const engines = (s.engines as string[]) || [];
85
+ lines.push(`- [${s.title || s.url}](${s.url}) [${engines.length}/3]`);
86
+ }
87
+ }
88
+ lines.push("\n---\n*Synthesized from Perplexity, Bing Copilot, and Google AI*");
89
+ return lines.join("\n").trim();
90
+ }
91
+
92
+ // Standard output: per-engine answers
93
+ for (const [eng, result] of Object.entries(data)) {
94
+ if (eng.startsWith("_")) continue;
95
+ lines.push(`\n## ${eng.charAt(0).toUpperCase() + eng.slice(1)}`);
96
+ const r = result as Record<string, unknown>;
97
+ if (r.error) {
98
+ lines.push(`Error: ${r.error}`);
99
+ } else {
100
+ if (r.answer) lines.push(String(r.answer));
101
+ if (Array.isArray(r.sources) && r.sources.length > 0) {
102
+ lines.push("\nSources:");
103
+ for (const s of r.sources.slice(0, 3)) {
104
+ const src = s as Record<string, string>;
105
+ lines.push(`- [${src.title || src.url}](${src.url})`);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ } else {
111
+ if (data.error) {
112
+ lines.push(`Error: ${data.error}`);
113
+ } else {
114
+ if (data.answer) lines.push(String(data.answer));
115
+ if (Array.isArray(data.sources) && data.sources.length > 0) {
116
+ lines.push("\nSources:");
117
+ for (const s of data.sources.slice(0, 5)) {
118
+ const src = s as Record<string, string>;
119
+ lines.push(`- [${src.title || src.url}](${src.url})`);
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ return lines.join("\n").trim();
126
+ }
127
+
128
+ export default function greedySearchExtension(pi: ExtensionAPI) {
129
+ pi.on("session_start", async (_event, ctx) => {
130
+ if (!cdpAvailable()) {
131
+ ctx.ui.notify(
132
+ "GreedySearch: cdp.mjs missing from package directory try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi",
133
+ "warning",
134
+ );
135
+ }
136
+ });
137
+
138
+ pi.registerTool({
139
+ name: "greedy_search",
140
+ label: "Greedy Search",
141
+ description:
142
+ "Search the web using AI-powered engines (Perplexity, Bing Copilot, Google AI) in parallel. " +
143
+ "Optionally synthesize results with Gemini — deduplicates sources by consensus and returns one grounded answer. " +
144
+ "Reports streaming progress as each engine completes. " +
145
+ "Use for current information, library docs, error messages, best practices, or any question where training data may be stale.",
146
+ promptSnippet: "Multi-engine AI web search with streaming progress",
147
+ parameters: Type.Object({
148
+ query: Type.String({ description: "The search query" }),
149
+ engine: Type.Union(
150
+ [
151
+ Type.Literal("all"),
152
+ Type.Literal("perplexity"),
153
+ Type.Literal("bing"),
154
+ Type.Literal("google"),
155
+ Type.Literal("gemini"),
156
+ Type.Literal("gem"),
157
+ ],
158
+ {
159
+ description: 'Engine to use. "all" fans out to Perplexity, Bing, and Google in parallel (default).',
160
+ default: "all",
161
+ },
162
+ ),
163
+ synthesize: Type.Optional(Type.Boolean({
164
+ description: 'When true and engine is "all", deduplicates sources across engines and feeds them to Gemini for a single grounded synthesis. Adds ~30s but saves tokens and improves answer quality.',
165
+ default: false,
166
+ })),
167
+ fullAnswer: Type.Optional(Type.Boolean({
168
+ description: 'When true, returns the complete answer instead of a truncated preview (default: false, answers are shortened to ~300 chars to save tokens).',
169
+ default: false,
170
+ })),
171
+ }),
172
+ execute: async (_toolCallId, params, signal, onUpdate) => {
173
+ const { query, engine = "all", synthesize = false, fullAnswer = false } = params as {
174
+ query: string; engine: string; synthesize?: boolean; fullAnswer?: boolean;
175
+ };
176
+
177
+ if (!cdpAvailable()) {
178
+ return {
179
+ content: [{ type: "text", text: "cdp.mjs missing — try reinstalling: pi install git:github.com/apmantza/GreedySearch-pi" }],
180
+ details: {} as { raw?: Record<string, unknown> },
181
+ };
182
+ }
183
+
184
+ const flags: string[] = [];
185
+ if (fullAnswer) flags.push("--full");
186
+ if (synthesize && engine === "all") flags.push("--synthesize");
187
+
188
+ // Track progress for "all" engine mode
189
+ const completed = new Set<string>();
190
+
191
+ const onProgress = (eng: string, status: "done" | "error") => {
192
+ completed.add(eng);
193
+ const parts: string[] = [];
194
+ for (const e of ALL_ENGINES) {
195
+ if (completed.has(e)) parts.push(`✅ ${e} done`);
196
+ else parts.push(`⏳ ${e}`);
197
+ }
198
+ if (synthesize && completed.size >= 3) parts.push("🔄 synthesizing");
199
+
200
+ onUpdate?.({
201
+ content: [{ type: "text", text: `**Searching...** ${parts.join(" · ")}` }],
202
+ details: { _progress: true },
203
+ } as any);
204
+ };
205
+
206
+ try {
207
+ const data = await runSearch(engine, query, flags, signal, engine === "all" ? onProgress : undefined);
208
+ const text = formatResults(engine, data);
209
+ return {
210
+ content: [{ type: "text", text: text || "No results returned." }],
211
+ details: { raw: data },
212
+ };
213
+ } catch (e) {
214
+ const msg = e instanceof Error ? e.message : String(e);
215
+ return {
216
+ content: [{ type: "text", text: `Search failed: ${msg}` }],
217
+ details: {} as { raw?: Record<string, unknown> },
218
+ };
219
+ }
220
+ },
221
+ });
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apmantza/greedysearch-pi",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Pi extension: search Perplexity, Bing Copilot, and Google AI in parallel with optional Gemini synthesis — grounded AI answers, not just links",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,6 @@
1
+ {
2
+ "query": "test query for file writing",
3
+ "url": "https://www.perplexity.ai/search/test-query-for-file-writing-.FobTp2uSWSg70txH6RthQ",
4
+ "answer": "This message is just a test query and does not trigger any actual file‑writing action on your system. \n\nIf you want, you can describe what kind of file you’d like to write (for example: a text file, Python script, CSV, etc.), and I can give you a concrete example in code that you can run…",
5
+ "sources": []
6
+ }
package/search.mjs CHANGED
@@ -405,7 +405,15 @@ async function main() {
405
405
  // All tabs assigned — run extractors in parallel
406
406
  const results = await Promise.allSettled(
407
407
  ALL_ENGINES.map((e, i) =>
408
- runExtractor(ENGINES[e], query, tabs[i], short).then(r => ({ engine: e, ...r }))
408
+ runExtractor(ENGINES[e], query, tabs[i], short)
409
+ .then(r => {
410
+ process.stderr.write(`PROGRESS:${e}:done\n`);
411
+ return { engine: e, ...r };
412
+ })
413
+ .catch(err => {
414
+ process.stderr.write(`PROGRESS:${e}:error\n`);
415
+ throw err;
416
+ })
409
417
  )
410
418
  );
411
419
 
@@ -424,6 +432,7 @@ async function main() {
424
432
 
425
433
  // Synthesize with Gemini if requested
426
434
  if (synthesize) {
435
+ process.stderr.write('PROGRESS:synthesis:start\n');
427
436
  process.stderr.write('[greedysearch] Synthesizing results with Gemini...\n');
428
437
  try {
429
438
  const synthesis = await synthesizeWithGemini(query, out);
@@ -432,6 +441,7 @@ async function main() {
432
441
  sources: synthesis.sources || [],
433
442
  synthesized: true,
434
443
  };
444
+ process.stderr.write('PROGRESS:synthesis:done\n');
435
445
  } catch (e) {
436
446
  process.stderr.write(`[greedysearch] Synthesis failed: ${e.message}\n`);
437
447
  out._synthesis = { error: e.message, synthesized: false };
@@ -1,38 +1,72 @@
1
- ---
2
- name: greedy-search
3
- description: Multi-engine AI web search — Perplexity, Bing Copilot, Google AI in parallel. Use for current library docs, error messages, version diffs, and any research where training data may be stale.
4
- ---
5
-
6
- # Greedy Search Skill
7
-
8
- Use the `greedy_search` tool when you need current information from the web.
9
-
10
- ## When to Use
11
-
12
- - Questions about libraries, APIs, or frameworks — especially version-specific
13
- - User pastes an error message or stack trace
14
- - Question contains "latest", "current", "2025", "2026", "deprecated", "still recommended"
15
- - Choosing between dependencies or tools
16
- - Architecture validation or best-practice confirmation
17
- - Any research question where training data may be stale
18
-
19
- ## How to Use
20
-
21
- Call `greedy_search` with the user's question as `query`. Use `engine: "all"` (default) to fan out to all three engines in parallel for the highest-confidence answer.
22
-
23
- ```
24
- greedy_search({ query: "how to use X in Y version", engine: "all" })
25
- ```
26
-
27
- For quick lookups where one source is sufficient:
28
- - `engine: "perplexity"`best for technical Q&A
29
- - `engine: "bing"` — best for recent news and Microsoft ecosystem
30
- - `engine: "google"` — best for broad coverage
31
-
32
- ## Interpreting Results
33
-
34
- Each engine returns an AI-synthesized answer plus sources. Where all three agree, confidence is high. Where they diverge, present both perspectives to the user.
35
-
36
- ## Requirements
37
-
38
- Chrome must be running. The extension auto-launches a dedicated GreedySearch Chrome instance if needed (via `launch.mjs`).
1
+ ---
2
+ name: greedy-search
3
+ description: Multi-engine AI web search — Perplexity, Bing Copilot, Google AI in parallel with optional Gemini synthesis. Use for high-quality research where training data may be stale or single-engine results are insufficient.
4
+ ---
5
+
6
+ # Greedy Search
7
+
8
+ Use `greedy_search` when you need high-quality, multi-perspective answers from the web.
9
+
10
+ ## Greedy Search vs Built-in Web Search
11
+
12
+ | | `web_search` | `greedy_search` |
13
+ |---|---|---|
14
+ | Speed | Instant (~2s) | 15-60s (one engine) / 30-90s (all engines) |
15
+ | Quality | Good for simple lookups | Higher — 3 AI engines cross-verify |
16
+ | Synthesis | Single engine answer | Optional Gemini synthesis (cleanest answer) |
17
+ | Use for | Quick facts, simple questions | Research, decisions, complex topics |
18
+
19
+ **Rule of thumb:** Use `web_search` for quick facts. Use `greedy_search` when the answer matters — architecture decisions, comparing libraries, understanding new releases, debugging tricky errors.
20
+
21
+ ## When to Use
22
+
23
+ - **Version-specific changes** — "What changed in React 19?" / "Breaking changes in FastAPI 0.100"
24
+ - **Choosing between tools** — "Prisma vs Drizzle in 2026" / "Best auth library for Next.js 15"
25
+ - **Debugging** — User pastes an error message or stack trace
26
+ - **Research tasks** — When you need to synthesize information from multiple sources
27
+ - **Best practices** "How to structure a monorepo" / "Auth patterns for SaaS"
28
+ - **Anything where training data might be stale** 2025+, 2026+, "latest", "current", "still maintained"
29
+
30
+ ## Engine Selection
31
+
32
+ ```greedy_search({ query: "what changed in React 19", engine: "all" })```
33
+
34
+ | Engine | Latency | Best for |
35
+ |---|---|---|
36
+ | `all` (default) | 30-90s | Highest confidence — all 3 engines in parallel |
37
+ | `perplexity` | 15-30s | Technical Q&A, code explanations, documentation |
38
+ | `bing` | 15-30s | Recent news, Microsoft ecosystem |
39
+ | `google` | 15-30s | Broad coverage, multiple perspectives |
40
+ | `gemini` | 15-30s | Google's perspective, different training data |
41
+
42
+ Use a single engine when speed matters and the question isn't contentious.
43
+
44
+ ## Synthesis Mode
45
+
46
+ For complex research questions, use `synthesize: true` with `engine: "all"`:
47
+
48
+ ```greedy_search({ query: "best auth patterns for SaaS in 2026", engine: "all", synthesize: true })```
49
+
50
+ This deduplicates sources across engines and feeds them to Gemini for one clean, synthesized answer. Adds ~30s but produces the highest quality output — ideal for research tasks where you'd otherwise need to parse 3 separate answers.
51
+
52
+ Use synthesis when:
53
+ - You need one definitive answer, not multiple perspectives
54
+ - You're researching a topic to write about or make a decision
55
+ - The question has a lot of noise and you want the signal
56
+
57
+ Skip synthesis when:
58
+ - You want to see where engines disagree (useful for controversial topics)
59
+ - Speed matters
60
+
61
+ ## Full vs Short Answers
62
+
63
+ Default mode returns ~300 char summaries to save tokens. Use `fullAnswer: true` when you need the complete response:
64
+
65
+ ```greedy_search({ query: "explain the React compiler", engine: "perplexity", fullAnswer: true })```
66
+
67
+ ## Interpreting Results
68
+
69
+ - **All 3 agree** → High confidence, present as fact
70
+ - **2 agree, 1 differs** → Likely correct but note the dissent
71
+ - **All differ** → Present the different perspectives to the user
72
+ - **Sources with `[3/3]` or `[2/3]`** → Cited by multiple engines, higher confidence