@apmantza/greedysearch-pi 1.9.2 → 2.0.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/CHANGELOG.md +80 -1
- package/README.md +82 -47
- package/bin/cdp.mjs +1153 -1108
- package/bin/launch.mjs +9 -0
- package/bin/search.mjs +197 -68
- package/extractors/bing-copilot.mjs +42 -4
- package/extractors/chatgpt.mjs +436 -0
- package/extractors/common.mjs +155 -21
- package/extractors/consensus.mjs +655 -0
- package/extractors/gemini.mjs +335 -217
- package/extractors/logically.mjs +567 -0
- package/extractors/selectors.mjs +3 -2
- package/extractors/semantic-scholar.mjs +219 -0
- package/package.json +7 -3
- package/skills/greedy-search/skill.md +9 -3
- package/src/fetcher.mjs +8 -1
- package/src/formatters/results.ts +163 -128
- package/src/search/browser-lifecycle.mjs +27 -5
- package/src/search/chrome.mjs +653 -590
- package/src/search/constants.mjs +150 -39
- package/src/search/engines.mjs +114 -76
- package/src/search/fetch-source.mjs +566 -451
- package/src/search/pdf.mjs +68 -0
- package/src/search/recovery.mjs +51 -45
- package/src/search/research.mjs +1059 -61
- package/src/search/sources.mjs +52 -22
- package/src/search/synthesis-runner.mjs +105 -26
- package/src/search/synthesis.mjs +286 -246
- package/src/tools/greedy-search-handler.ts +124 -52
- package/src/tools/shared.ts +187 -186
- package/src/types.ts +110 -104
- package/test.mjs +377 -6
package/src/search/synthesis.mjs
CHANGED
|
@@ -1,246 +1,286 @@
|
|
|
1
|
-
// src/search/synthesis.mjs — Synthesis prompt building, structured JSON parsing,
|
|
2
|
-
// confidence metrics, and payload normalization
|
|
3
|
-
//
|
|
4
|
-
// Extracted from search.mjs to reduce file complexity.
|
|
5
|
-
|
|
6
|
-
import { ALL_ENGINES } from "./constants.mjs";
|
|
7
|
-
import { trimText } from "./sources.mjs";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
let
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
1
|
+
// src/search/synthesis.mjs — Synthesis prompt building, structured JSON parsing,
|
|
2
|
+
// confidence metrics, and payload normalization
|
|
3
|
+
//
|
|
4
|
+
// Extracted from search.mjs to reduce file complexity.
|
|
5
|
+
|
|
6
|
+
import { ALL_ENGINES } from "./constants.mjs";
|
|
7
|
+
import { trimText } from "./sources.mjs";
|
|
8
|
+
|
|
9
|
+
function escapeControlCharsInsideJsonStrings(text) {
|
|
10
|
+
let out = "";
|
|
11
|
+
let inString = false;
|
|
12
|
+
let escaped = false;
|
|
13
|
+
for (const char of String(text)) {
|
|
14
|
+
if (escaped) {
|
|
15
|
+
out += char;
|
|
16
|
+
escaped = false;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (char === "\\") {
|
|
20
|
+
out += char;
|
|
21
|
+
escaped = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (char === '"') {
|
|
25
|
+
inString = !inString;
|
|
26
|
+
out += char;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (inString && char === "\n") out += "\\n";
|
|
30
|
+
else if (inString && char === "\r") out += "\\r";
|
|
31
|
+
else if (inString && char === "\t") out += "\\t";
|
|
32
|
+
else out += char;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseStructuredJson(text) {
|
|
38
|
+
if (!text) return null;
|
|
39
|
+
let trimmed = String(text).trim();
|
|
40
|
+
|
|
41
|
+
// Look for BEGIN_JSON/END_JSON markers first
|
|
42
|
+
const beginIdx = trimmed.indexOf("BEGIN_JSON");
|
|
43
|
+
const endIdx = trimmed.indexOf("END_JSON");
|
|
44
|
+
if (beginIdx !== -1 && endIdx !== -1 && beginIdx < endIdx) {
|
|
45
|
+
trimmed = trimmed.slice(beginIdx + "BEGIN_JSON".length, endIdx).trim();
|
|
46
|
+
} else {
|
|
47
|
+
// Strip out common LLM preamble text before the actual JSON
|
|
48
|
+
const jsonStart = trimmed.indexOf("{");
|
|
49
|
+
if (jsonStart > 0) {
|
|
50
|
+
trimmed = trimmed.slice(jsonStart);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const candidates = [
|
|
55
|
+
trimmed,
|
|
56
|
+
trimmed
|
|
57
|
+
.replace(/^```json\s*/i, "")
|
|
58
|
+
.replace(/^```\s*/i, "")
|
|
59
|
+
.replace(/```$/i, "")
|
|
60
|
+
.trim(),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Find the outermost JSON object via brace matching (avoids ReDoS-prone .* patterns)
|
|
64
|
+
const firstBrace = trimmed.indexOf("{");
|
|
65
|
+
const lastBrace = trimmed.lastIndexOf("}");
|
|
66
|
+
if (firstBrace !== -1 && lastBrace !== -1 && firstBrace < lastBrace) {
|
|
67
|
+
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const candidate of [...candidates]) {
|
|
71
|
+
const repaired = escapeControlCharsInsideJsonStrings(candidate);
|
|
72
|
+
if (repaired !== candidate) candidates.push(repaired);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const candidate of candidates) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(candidate);
|
|
78
|
+
} catch {
|
|
79
|
+
// try next candidate
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeSynthesisPayload(
|
|
86
|
+
payload,
|
|
87
|
+
sources,
|
|
88
|
+
fallbackAnswer = "",
|
|
89
|
+
) {
|
|
90
|
+
const sourceIds = new Set(sources.map((source) => source.id));
|
|
91
|
+
const agreementLevel = [
|
|
92
|
+
"high",
|
|
93
|
+
"medium",
|
|
94
|
+
"low",
|
|
95
|
+
"mixed",
|
|
96
|
+
"conflicting",
|
|
97
|
+
].includes(payload?.agreement?.level)
|
|
98
|
+
? payload.agreement.level
|
|
99
|
+
: "mixed";
|
|
100
|
+
const claims = Array.isArray(payload?.claims)
|
|
101
|
+
? payload.claims
|
|
102
|
+
.map((claim) => ({
|
|
103
|
+
claim: trimText(claim?.claim || "", 260),
|
|
104
|
+
support: ["strong", "moderate", "weak", "conflicting"].includes(
|
|
105
|
+
claim?.support,
|
|
106
|
+
)
|
|
107
|
+
? claim.support
|
|
108
|
+
: "moderate",
|
|
109
|
+
sourceIds: Array.isArray(claim?.sourceIds)
|
|
110
|
+
? claim.sourceIds.filter((id) => sourceIds.has(id))
|
|
111
|
+
: [],
|
|
112
|
+
}))
|
|
113
|
+
.filter((claim) => claim.claim)
|
|
114
|
+
: [];
|
|
115
|
+
const recommendedSources = Array.isArray(payload?.recommendedSources)
|
|
116
|
+
? payload.recommendedSources.filter((id) => sourceIds.has(id)).slice(0, 6)
|
|
117
|
+
: [];
|
|
118
|
+
|
|
119
|
+
// Clean up fallback answer if it contains preamble text
|
|
120
|
+
// Use indexOf/lastIndexOf instead of [\s\S]* patterns to avoid ReDoS
|
|
121
|
+
let cleanFallback = "";
|
|
122
|
+
if (fallbackAnswer) {
|
|
123
|
+
const firstBrace = fallbackAnswer.indexOf("{");
|
|
124
|
+
const lastBrace = fallbackAnswer.lastIndexOf("}");
|
|
125
|
+
if (firstBrace !== -1 && lastBrace !== -1 && firstBrace < lastBrace) {
|
|
126
|
+
cleanFallback = fallbackAnswer.slice(firstBrace, lastBrace + 1);
|
|
127
|
+
} else {
|
|
128
|
+
cleanFallback = fallbackAnswer;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
answer: trimText(payload?.answer || cleanFallback || fallbackAnswer, 4000),
|
|
134
|
+
agreement: {
|
|
135
|
+
level: agreementLevel,
|
|
136
|
+
summary: trimText(payload?.agreement?.summary || "", 280),
|
|
137
|
+
},
|
|
138
|
+
differences: Array.isArray(payload?.differences)
|
|
139
|
+
? payload.differences
|
|
140
|
+
.map((item) => trimText(item, 220))
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.slice(0, 5)
|
|
143
|
+
: [],
|
|
144
|
+
caveats: Array.isArray(payload?.caveats)
|
|
145
|
+
? payload.caveats
|
|
146
|
+
.map((item) => trimText(item, 220))
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.slice(0, 5)
|
|
149
|
+
: [],
|
|
150
|
+
claims,
|
|
151
|
+
recommendedSources,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function buildSynthesisPrompt(
|
|
156
|
+
query,
|
|
157
|
+
results,
|
|
158
|
+
sources,
|
|
159
|
+
{ grounded = false } = {},
|
|
160
|
+
) {
|
|
161
|
+
const engineSummaries = {};
|
|
162
|
+
for (const engine of ["perplexity", "bing", "google"]) {
|
|
163
|
+
const result = results[engine];
|
|
164
|
+
if (!result) continue;
|
|
165
|
+
if (result.error) {
|
|
166
|
+
engineSummaries[engine] = {
|
|
167
|
+
status: "error",
|
|
168
|
+
error: String(result.error),
|
|
169
|
+
};
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
engineSummaries[engine] = {
|
|
174
|
+
status: "ok",
|
|
175
|
+
answer: trimText(result.answer || "", grounded ? 4500 : 2200),
|
|
176
|
+
sourceIds: sources
|
|
177
|
+
.filter((source) => source.engines.includes(engine))
|
|
178
|
+
.sort(
|
|
179
|
+
(a, b) =>
|
|
180
|
+
(a.perEngine[engine]?.rank || 99) -
|
|
181
|
+
(b.perEngine[engine]?.rank || 99),
|
|
182
|
+
)
|
|
183
|
+
.map((source) => source.id)
|
|
184
|
+
.slice(0, 6),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Snippet budget: always include content for fetched sources so Gemini can
|
|
189
|
+
// make citation decisions based on what the sources actually say, not just
|
|
190
|
+
// their metadata. Grounded mode gets a larger budget per source.
|
|
191
|
+
const snippetChars = grounded ? 700 : 300;
|
|
192
|
+
const sourceRegistry = sources.slice(0, grounded ? 10 : 8).map((source) => ({
|
|
193
|
+
id: source.id,
|
|
194
|
+
title: source.title,
|
|
195
|
+
domain: source.domain,
|
|
196
|
+
canonicalUrl: source.canonicalUrl,
|
|
197
|
+
sourceType: source.sourceType,
|
|
198
|
+
isOfficial: source.isOfficial,
|
|
199
|
+
engines: source.engines,
|
|
200
|
+
engineCount: source.engineCount,
|
|
201
|
+
fetch: source.fetch?.attempted
|
|
202
|
+
? {
|
|
203
|
+
ok: source.fetch.ok,
|
|
204
|
+
publishedTime: source.fetch.publishedTime || "",
|
|
205
|
+
byline: source.fetch.byline || "",
|
|
206
|
+
snippet: trimText(source.fetch.snippet || "", snippetChars),
|
|
207
|
+
}
|
|
208
|
+
: undefined,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
return [
|
|
212
|
+
"You are a research synthesizer. Combine these search engine results into a single authoritative answer.",
|
|
213
|
+
"",
|
|
214
|
+
`Query: ${query}`,
|
|
215
|
+
"",
|
|
216
|
+
`Engine summaries:\n${JSON.stringify(engineSummaries, null, 2)}`,
|
|
217
|
+
"",
|
|
218
|
+
`Source registry:\n${JSON.stringify(sourceRegistry, null, 2)}`,
|
|
219
|
+
"",
|
|
220
|
+
"Instructions:",
|
|
221
|
+
"- Write a clear, direct answer in markdown (use headers/bullets where they help readability)",
|
|
222
|
+
"- Cite sources inline as [S1], [S2] etc. when making specific claims",
|
|
223
|
+
"- Prefer sources with content (fetch.ok=true and non-empty snippet) for citations",
|
|
224
|
+
"- Note where the engines agree or meaningfully disagree",
|
|
225
|
+
"- List any important caveats or limitations",
|
|
226
|
+
"- recommendedSources: the 2-4 source IDs most worth reading for this query",
|
|
227
|
+
"",
|
|
228
|
+
"Respond ONLY with a JSON object wrapped in BEGIN_JSON / END_JSON markers:",
|
|
229
|
+
"",
|
|
230
|
+
"BEGIN_JSON",
|
|
231
|
+
JSON.stringify(
|
|
232
|
+
{
|
|
233
|
+
answer: "<your markdown answer here>",
|
|
234
|
+
agreement: {
|
|
235
|
+
level: "high|medium|mixed|conflicting",
|
|
236
|
+
summary: "<one sentence>",
|
|
237
|
+
},
|
|
238
|
+
differences: ["<notable difference between engines, if any>"],
|
|
239
|
+
caveats: ["<important caveat or limitation>"],
|
|
240
|
+
recommendedSources: ["S1", "S2"],
|
|
241
|
+
},
|
|
242
|
+
null,
|
|
243
|
+
2,
|
|
244
|
+
),
|
|
245
|
+
"END_JSON",
|
|
246
|
+
].join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function buildConfidence(out) {
|
|
250
|
+
const sources = Array.isArray(out._sources) ? out._sources : [];
|
|
251
|
+
const topConsensus = sources.length > 0 ? sources[0]?.engineCount || 0 : 0;
|
|
252
|
+
const officialSourceCount = sources.filter(
|
|
253
|
+
(source) => source.isOfficial,
|
|
254
|
+
).length;
|
|
255
|
+
const firstPartySourceCount = sources.filter(
|
|
256
|
+
(source) => source.isOfficial || source.sourceType === "maintainer-blog",
|
|
257
|
+
).length;
|
|
258
|
+
const fetchedAttempted = sources.filter(
|
|
259
|
+
(source) => source.fetch?.attempted,
|
|
260
|
+
).length;
|
|
261
|
+
const fetchedSucceeded = sources.filter((source) => source.fetch?.ok).length;
|
|
262
|
+
const sourceTypeBreakdown = sources.reduce((acc, source) => {
|
|
263
|
+
acc[source.sourceType] = (acc[source.sourceType] || 0) + 1;
|
|
264
|
+
return acc;
|
|
265
|
+
}, {});
|
|
266
|
+
const synthesisLevel = out._synthesis?.agreement?.level;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
sourcesCount: sources.length,
|
|
270
|
+
topSourceConsensus: topConsensus,
|
|
271
|
+
agreementLevel:
|
|
272
|
+
synthesisLevel ||
|
|
273
|
+
(topConsensus >= 3 ? "high" : topConsensus >= 2 ? "medium" : "low"),
|
|
274
|
+
enginesResponded: ALL_ENGINES.filter(
|
|
275
|
+
(engine) => out[engine]?.answer && !out[engine]?.error,
|
|
276
|
+
),
|
|
277
|
+
enginesFailed: ALL_ENGINES.filter((engine) => out[engine]?.error),
|
|
278
|
+
officialSourceCount,
|
|
279
|
+
firstPartySourceCount,
|
|
280
|
+
fetchedSourceSuccessRate:
|
|
281
|
+
fetchedAttempted > 0
|
|
282
|
+
? Number((fetchedSucceeded / fetchedAttempted).toFixed(2))
|
|
283
|
+
: 0,
|
|
284
|
+
sourceTypeBreakdown,
|
|
285
|
+
};
|
|
286
|
+
}
|