@lincoln504/pi-research 0.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/LICENSE +21 -0
- package/README.md +131 -0
- package/config/searxng/default-settings.yml +189 -0
- package/config/searxng/limiter.toml +34 -0
- package/config/tooling/eslint.config.js +73 -0
- package/config/tooling/vitest.config.integration.ts +17 -0
- package/config/tooling/vitest.config.ts +40 -0
- package/config/tooling/vitest.config.unit.ts +12 -0
- package/docs/ARCHITECTURE.md +109 -0
- package/docs/assets/README-banner.jpg +0 -0
- package/package.json +92 -0
- package/scripts/setup.js +174 -0
- package/src/config.ts +206 -0
- package/src/constants.ts +148 -0
- package/src/healthcheck/index.ts +309 -0
- package/src/index.ts +71 -0
- package/src/infrastructure/searxng-lifecycle.ts +577 -0
- package/src/infrastructure/searxng-manager.ts +1173 -0
- package/src/infrastructure/state-manager.ts +918 -0
- package/src/logger.ts +246 -0
- package/src/orchestration/deep-research-orchestrator.ts +848 -0
- package/src/orchestration/deep-research-reducer.ts +129 -0
- package/src/orchestration/deep-research-types.ts +54 -0
- package/src/orchestration/id-utils.ts +63 -0
- package/src/orchestration/researcher.ts +104 -0
- package/src/orchestration/session-context.ts +43 -0
- package/src/prompts/research-tool-usage.md +45 -0
- package/src/prompts/researcher.md +166 -0
- package/src/prompts/system-coordinator.md +24 -0
- package/src/prompts/system-lead-evaluator.md +225 -0
- package/src/security/cisa-kev.ts +242 -0
- package/src/security/github-advisories.ts +607 -0
- package/src/security/index.ts +406 -0
- package/src/security/nvd.ts +532 -0
- package/src/security/osv.ts +487 -0
- package/src/security/types.ts +138 -0
- package/src/stackexchange/cache.ts +111 -0
- package/src/stackexchange/index.ts +352 -0
- package/src/stackexchange/output/compact.ts +64 -0
- package/src/stackexchange/output/index.ts +7 -0
- package/src/stackexchange/output/json.ts +21 -0
- package/src/stackexchange/output/table.ts +123 -0
- package/src/stackexchange/queries.ts +168 -0
- package/src/stackexchange/rest-client.ts +127 -0
- package/src/stackexchange/types.ts +129 -0
- package/src/tool.ts +430 -0
- package/src/tools/grep.ts +335 -0
- package/src/tools/index.ts +77 -0
- package/src/tools/scrape.ts +451 -0
- package/src/tools/search.ts +188 -0
- package/src/tools/security.ts +292 -0
- package/src/tools/stackexchange.ts +101 -0
- package/src/tui/research-panel.ts +576 -0
- package/src/tui/searxng-status.ts +80 -0
- package/src/types/extension-context.ts +60 -0
- package/src/types/llm.ts +111 -0
- package/src/utils/inject-date.ts +29 -0
- package/src/utils/input-validation.ts +103 -0
- package/src/utils/json-utils.ts +262 -0
- package/src/utils/make-resource-loader.ts +43 -0
- package/src/utils/research-error.ts +205 -0
- package/src/utils/research-export.ts +199 -0
- package/src/utils/searxng-config.ts +94 -0
- package/src/utils/session-state.ts +295 -0
- package/src/utils/shared-links.ts +123 -0
- package/src/utils/shutdown-manager.ts +54 -0
- package/src/utils/text-utils.ts +83 -0
- package/src/utils/tool-usage-tracker.ts +135 -0
- package/src/web-research/dependencies.ts +20 -0
- package/src/web-research/retry-utils.ts +232 -0
- package/src/web-research/scrapers.ts +935 -0
- package/src/web-research/search.ts +235 -0
- package/src/web-research/types.ts +47 -0
- package/src/web-research/utils.ts +158 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scrape Tool
|
|
3
|
+
*
|
|
4
|
+
* Scrape full content from URLs using a context-aware, up-to-4-call protocol:
|
|
5
|
+
*
|
|
6
|
+
* Call 1 (Handshake) – Return already-scraped links; no network activity.
|
|
7
|
+
* Call 2 (Batch 1) – Scrape up to MAX_SCRAPE_URLS URLs; requires context < 55%.
|
|
8
|
+
* Call 3 (Batch 2) – Scrape up to BATCH_2_MAX_SCRAPE_URLS URLs (targeted follow-up);
|
|
9
|
+
* requires context < 55%; deduplicates against Batch 1.
|
|
10
|
+
* Call 4 (Batch 3) – Scrape up to MAX_SCRAPE_URLS additional URLs; requires context < 55%.
|
|
11
|
+
* Call 5+ (Locked) – All batches exhausted; move to synthesis.
|
|
12
|
+
*
|
|
13
|
+
* Context is measured as tokens_used / model_context_window. When the threshold
|
|
14
|
+
* is exceeded the tool returns an informative message instead of scraping, allowing
|
|
15
|
+
* the researcher to proceed directly to synthesis with whatever it already has.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ToolDefinition, AgentToolResult, ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
19
|
+
import { Type } from '@sinclair/typebox';
|
|
20
|
+
import { scrape, scrapeSingle } from '../web-research/scrapers.ts';
|
|
21
|
+
import { validateMaxConcurrency } from '../web-research/utils.ts';
|
|
22
|
+
import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
23
|
+
import type { SystemResearchState } from '../orchestration/deep-research-types.ts';
|
|
24
|
+
import { deduplicateUrls } from '../utils/shared-links.ts';
|
|
25
|
+
import {
|
|
26
|
+
MAX_SCRAPE_URLS,
|
|
27
|
+
BATCH_2_MAX_SCRAPE_URLS,
|
|
28
|
+
BATCH_2_DEFAULT_CONCURRENCY,
|
|
29
|
+
MAX_CONTEXT_FRACTION_FOR_SCRAPING,
|
|
30
|
+
DEFAULT_MODEL_CONTEXT_WINDOW,
|
|
31
|
+
AVG_TOKENS_PER_SCRAPE,
|
|
32
|
+
} from '../constants.ts';
|
|
33
|
+
|
|
34
|
+
export function createScrapeTool(options: {
|
|
35
|
+
searxngUrl: string;
|
|
36
|
+
ctx: ExtensionContext;
|
|
37
|
+
tracker: ToolUsageTracker;
|
|
38
|
+
getGlobalState: () => SystemResearchState;
|
|
39
|
+
updateGlobalLinks: (links: string[]) => void;
|
|
40
|
+
/** Returns tokens consumed by this researcher session so far. */
|
|
41
|
+
getTokensUsed?: () => number;
|
|
42
|
+
/** Model context window in tokens; defaults to DEFAULT_MODEL_CONTEXT_WINDOW. */
|
|
43
|
+
contextWindowSize?: number;
|
|
44
|
+
}): ToolDefinition {
|
|
45
|
+
|
|
46
|
+
// Helper to get globally scraped URLs for deduplication
|
|
47
|
+
const getScrapedState = () => options.getGlobalState().allScrapedLinks || [];
|
|
48
|
+
|
|
49
|
+
const ctxWindow = options.contextWindowSize ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
50
|
+
const getContextFraction = (additionalTokens: number = 0): number => {
|
|
51
|
+
if (!options.getTokensUsed) return 0;
|
|
52
|
+
return (options.getTokensUsed() + additionalTokens) / ctxWindow;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const contextLimitMessage = (fraction: number, batchLabel: string, projected: boolean = false): string => [
|
|
56
|
+
`# Scrape Skipped — Context Budget ${projected ? 'Projection' : 'Reached'} (${batchLabel})`,
|
|
57
|
+
'',
|
|
58
|
+
`Your context window is currently **${Math.round((options.getTokensUsed?.() || 0) / ctxWindow * 100)}% full**.`,
|
|
59
|
+
projected ? `Adding this scrape batch would push it to approximately **${Math.round(fraction * 100)}% full** ` +
|
|
60
|
+
`(threshold: ${Math.round(MAX_CONTEXT_FRACTION_FOR_SCRAPING * 100)}%).` :
|
|
61
|
+
`This is at or above the threshold of **${Math.round(MAX_CONTEXT_FRACTION_FOR_SCRAPING * 100)}%**.`,
|
|
62
|
+
'Scraping beyond this point risks truncating your findings during synthesis.',
|
|
63
|
+
'',
|
|
64
|
+
'**Action**: Proceed directly to **Phase 3 — Synthesis**.',
|
|
65
|
+
'Compile your findings from Phase 1 (search results) and any earlier scrape batches,',
|
|
66
|
+
'and submit your full report now.',
|
|
67
|
+
].join('\n');
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: 'scrape',
|
|
71
|
+
label: 'Scrape',
|
|
72
|
+
description: 'Scrape content from URLs. Context-aware 4-call protocol: handshake → batch 1 → batch 2 (targeted) → batch 3 (deep-dive).',
|
|
73
|
+
promptSnippet: 'Scrape full content from URLs (context-aware protocol)',
|
|
74
|
+
promptGuidelines: [
|
|
75
|
+
'PROTOCOL: This tool uses up to FOUR calls per researcher session.',
|
|
76
|
+
'CALL 1 (Handshake): Provide your intended URLs. Returns links already scraped globally.',
|
|
77
|
+
'CALL 2 (Batch 1): Provide your filtered list (max 3 URLs). Primary broad scraping.',
|
|
78
|
+
'CALL 3 (Batch 2): Targeted follow-up (max 2 URLs). Deduplicated against Batch 1 automatically.',
|
|
79
|
+
'CALL 4 (Batch 3): Deep-dive scrape up to 3 more URLs.',
|
|
80
|
+
`CONTEXT LIMIT: All batches are skipped automatically if context exceeds ${Math.round(MAX_CONTEXT_FRACTION_FOR_SCRAPING * 100)}%. Move to synthesis when this happens.`,
|
|
81
|
+
`BATCH 1 LIMIT: max ${MAX_SCRAPE_URLS} URLs. BATCH 2 LIMIT: max ${BATCH_2_MAX_SCRAPE_URLS} URLs (higher concurrency). BATCH 3 LIMIT: max ${MAX_SCRAPE_URLS} URLs.`,
|
|
82
|
+
'USE CASES FOR BATCH 2: Targeted follow-up on Batch 1 findings, retry failed scrapes.',
|
|
83
|
+
'USE CASES FOR BATCH 3 (if available): Deep-dive on a narrow sub-topic identified in Batch 1/2.',
|
|
84
|
+
],
|
|
85
|
+
parameters: Type.Object({
|
|
86
|
+
urls: Type.Array(Type.String({
|
|
87
|
+
description: 'URLs to scrape',
|
|
88
|
+
}), { minItems: 1 }),
|
|
89
|
+
maxConcurrency: Type.Optional(Type.Number({
|
|
90
|
+
description: `Max parallel scrapes (default: 10 for batch 1/3, ${BATCH_2_DEFAULT_CONCURRENCY} for batch 2; capped at 20)`,
|
|
91
|
+
default: 10,
|
|
92
|
+
minimum: 1,
|
|
93
|
+
maximum: 20,
|
|
94
|
+
})),
|
|
95
|
+
excludeLinks: Type.Optional(Type.Array(Type.String(), {
|
|
96
|
+
description: 'Links you considered but are NOT scraping in this batch (Call 2/3/4 only). Helps track research decisions.',
|
|
97
|
+
})),
|
|
98
|
+
}),
|
|
99
|
+
async execute(
|
|
100
|
+
_toolCallId,
|
|
101
|
+
params,
|
|
102
|
+
signal,
|
|
103
|
+
_onUpdate,
|
|
104
|
+
_extensionCtx,
|
|
105
|
+
): Promise<AgentToolResult<unknown>> {
|
|
106
|
+
const callCount = options.tracker.getCallCount('scrape');
|
|
107
|
+
const state = options.getGlobalState();
|
|
108
|
+
|
|
109
|
+
// ─── CALL 1: Handshake ────────────────────────────────────────────────
|
|
110
|
+
if (callCount === 0) {
|
|
111
|
+
options.tracker.recordCall('scrape');
|
|
112
|
+
|
|
113
|
+
const ctxFraction = getContextFraction();
|
|
114
|
+
const nextBatchProjection = getContextFraction(MAX_SCRAPE_URLS * AVG_TOKENS_PER_SCRAPE);
|
|
115
|
+
const ctxPct = Math.round(ctxFraction * 100);
|
|
116
|
+
|
|
117
|
+
// Tell the researcher how many batches are realistically available
|
|
118
|
+
const batch3Note = nextBatchProjection < MAX_CONTEXT_FRACTION_FOR_SCRAPING
|
|
119
|
+
? `**Batches 1–3 available** — context is only ${ctxPct}% full.`
|
|
120
|
+
: `⚠️ Context is ${ctxPct}% full. **Batches 1–3 may be skipped** — consider proceeding directly to synthesis.`;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
content: [{
|
|
124
|
+
type: 'text',
|
|
125
|
+
text: [
|
|
126
|
+
'# Scrape Protocol: Call 1 (Handshake)',
|
|
127
|
+
'Here are all links already scraped in this research session:',
|
|
128
|
+
'',
|
|
129
|
+
state.allScrapedLinks && state.allScrapedLinks.length > 0
|
|
130
|
+
? state.allScrapedLinks.map(l => `- ${l}`).join('\n')
|
|
131
|
+
: '*No links have been scraped yet.*',
|
|
132
|
+
'',
|
|
133
|
+
'**Protocol Overview:**',
|
|
134
|
+
`- **Batch 1** (Call 2): Up to ${MAX_SCRAPE_URLS} URLs — primary broad scraping.`,
|
|
135
|
+
`- **Batch 2** (Call 3): Up to ${BATCH_2_MAX_SCRAPE_URLS} URLs — targeted follow-up (auto-deduped against Batch 1).`,
|
|
136
|
+
`- **Batch 3** (Call 4): Up to ${MAX_SCRAPE_URLS} URLs — deep-dive.`,
|
|
137
|
+
'',
|
|
138
|
+
batch3Note,
|
|
139
|
+
'',
|
|
140
|
+
'**Action Required:**',
|
|
141
|
+
'1. Review the scraped links list above and remove duplicates from your list.',
|
|
142
|
+
`2. Call \`scrape\` again with your filtered list (max ${MAX_SCRAPE_URLS} URLs) to begin Batch 1.`,
|
|
143
|
+
].join('\n')
|
|
144
|
+
}],
|
|
145
|
+
details: {
|
|
146
|
+
protocol: 'handshake',
|
|
147
|
+
previouslyScrapedCount: state.allScrapedLinks?.length || 0,
|
|
148
|
+
contextFraction: ctxFraction,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── CALL 2: Batch 1 ──────────────────────────────────────────────────
|
|
154
|
+
if (callCount === 1) {
|
|
155
|
+
const allowed = options.tracker.recordCall('scrape');
|
|
156
|
+
if (!allowed) {
|
|
157
|
+
// THROW to prevent researcher from calling again
|
|
158
|
+
throw new Error('Scrape limit reached. Handshake + 3 batches completed.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const paramsRecord = params as Record<string, unknown>;
|
|
162
|
+
const urls = (paramsRecord['urls'] as string[] | undefined) || [];
|
|
163
|
+
|
|
164
|
+
// Context gate (with projection)
|
|
165
|
+
const projectedFraction = getContextFraction(urls.length * AVG_TOKENS_PER_SCRAPE);
|
|
166
|
+
if (projectedFraction >= MAX_CONTEXT_FRACTION_FOR_SCRAPING) {
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: 'text', text: contextLimitMessage(projectedFraction, 'Batch 1', true) }],
|
|
169
|
+
details: { skipped: true, reason: 'context_limit_projection', contextFraction: projectedFraction },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const startTime = Date.now();
|
|
174
|
+
const excludeLinks = (paramsRecord['excludeLinks'] as string[] | undefined) || [];
|
|
175
|
+
const maxConcurrency = validateMaxConcurrency(paramsRecord['maxConcurrency'] as number | undefined);
|
|
176
|
+
|
|
177
|
+
if (!Array.isArray(urls) || urls.length === 0 || !urls.every(u => typeof u === 'string')) {
|
|
178
|
+
throw new Error('At least one valid URL string is required for scraping.');
|
|
179
|
+
}
|
|
180
|
+
if (urls.length > MAX_SCRAPE_URLS) {
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text', text: `Error: Batch 1 limit is ${MAX_SCRAPE_URLS} URLs, but ${urls.length} were provided. Prioritize the most important ones.` }],
|
|
183
|
+
details: { blocked: true },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (!Array.isArray(excludeLinks) || !excludeLinks.every(l => typeof l === 'string')) {
|
|
187
|
+
throw new Error('excludeLinks must be an array of strings if provided.');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Immediately update global link pool (signals intent to other researchers)
|
|
191
|
+
// This ensures other researchers see these URLs as being scraped, even if
|
|
192
|
+
// the fetch hasn't completed yet. Prevents duplicate scraping in concurrent scenarios.
|
|
193
|
+
options.updateGlobalLinks(urls);
|
|
194
|
+
|
|
195
|
+
let scrapeResults: any[];
|
|
196
|
+
try {
|
|
197
|
+
scrapeResults = urls.length === 1
|
|
198
|
+
? [await scrapeSingle(urls[0]!, signal)]
|
|
199
|
+
: await scrape(urls, maxConcurrency, signal);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
202
|
+
return {
|
|
203
|
+
content: [{ type: 'text', text: `# Scrape Failed\n\n${errorMsg}` }],
|
|
204
|
+
details: { error: errorMsg },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const successful = scrapeResults.filter(r => r.source !== 'failed');
|
|
209
|
+
const failed = scrapeResults.filter(r => r.source === 'failed');
|
|
210
|
+
const elapsed = Date.now() - startTime;
|
|
211
|
+
|
|
212
|
+
const ctxAfter = getContextFraction();
|
|
213
|
+
const batch2Available = ctxAfter < MAX_CONTEXT_FRACTION_FOR_SCRAPING;
|
|
214
|
+
|
|
215
|
+
let markdown = `# URL Scrape Results (Batch 1 of up to 3)\n\n`;
|
|
216
|
+
markdown += `**Successful:** ${successful.length}, **Failed:** ${failed.length}, **Duration:** ${(elapsed / 1000).toFixed(2)}s\n\n`;
|
|
217
|
+
|
|
218
|
+
if (excludeLinks.length > 0) {
|
|
219
|
+
markdown += `**Links considered but not scraped:**\n${excludeLinks.map(l => `- ${l}`).join('\n')}\n\n`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (batch2Available) {
|
|
223
|
+
markdown += `**Batch 2 available** (max ${BATCH_2_MAX_SCRAPE_URLS} URLs, targeted follow-up). **Batch 3 available** (max ${MAX_SCRAPE_URLS} URLs, deep-dive). Context ${Math.round(ctxAfter * 100)}% full.\n`;
|
|
224
|
+
} else {
|
|
225
|
+
markdown += `⚠️ **Context ${Math.round(ctxAfter * 100)}% full** — Batch 2 will be skipped. Proceed to synthesis after reviewing these results.\n`;
|
|
226
|
+
}
|
|
227
|
+
markdown += '\n';
|
|
228
|
+
|
|
229
|
+
for (const result of successful) {
|
|
230
|
+
const category = result.sourceCategory ? `**Source type**: ${result.sourceCategory}\n` : '';
|
|
231
|
+
markdown += `### ${result.url}\n${category}\n${result.markdown}\n\n---\n\n`;
|
|
232
|
+
}
|
|
233
|
+
if (failed.length > 0) {
|
|
234
|
+
markdown += `### Failed URLs\n${failed.map((r: any) => `- ${r.url}`).join('\n')}\n`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
content: [{ type: 'text', text: markdown }],
|
|
239
|
+
details: { urls, excludeLinks, successfulCount: successful.length, duration: elapsed, batch: 1 },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── CALL 3: Batch 2 ──────────────────────────────────────────────────
|
|
244
|
+
if (callCount === 2) {
|
|
245
|
+
const allowed = options.tracker.recordCall('scrape');
|
|
246
|
+
if (!allowed) {
|
|
247
|
+
// THROW to prevent researcher from calling again
|
|
248
|
+
throw new Error('Scrape limit reached. Handshake + 3 batches completed.');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const paramsRecord = params as Record<string, unknown>;
|
|
252
|
+
let urls = (paramsRecord['urls'] as string[] | undefined) || [];
|
|
253
|
+
|
|
254
|
+
// Context gate (with projection)
|
|
255
|
+
const projectedFraction = getContextFraction(urls.length * AVG_TOKENS_PER_SCRAPE);
|
|
256
|
+
if (projectedFraction >= MAX_CONTEXT_FRACTION_FOR_SCRAPING) {
|
|
257
|
+
return {
|
|
258
|
+
content: [{ type: 'text', text: contextLimitMessage(projectedFraction, 'Batch 2', true) }],
|
|
259
|
+
details: { skipped: true, reason: 'context_limit_projection', contextFraction: projectedFraction },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const startTime = Date.now();
|
|
264
|
+
const rawExclude = (paramsRecord['excludeLinks'] as string[] | undefined) || [];
|
|
265
|
+
// Batch 2 uses higher default concurrency
|
|
266
|
+
const maxConcurrency = validateMaxConcurrency(
|
|
267
|
+
(paramsRecord['maxConcurrency'] as number | undefined) ?? BATCH_2_DEFAULT_CONCURRENCY
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (!Array.isArray(urls) || urls.length === 0 || !urls.every(u => typeof u === 'string')) {
|
|
271
|
+
throw new Error('At least one valid URL string is required for scraping.');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Deduplicate against globally scraped URLs (includes all previous batches)
|
|
275
|
+
const { kept: dedupedUrls, duplicates: globalDuplicates } = deduplicateUrls(urls, getScrapedState());
|
|
276
|
+
|
|
277
|
+
// Report duplicates to the AI
|
|
278
|
+
let dedupNote = '';
|
|
279
|
+
const deduped = globalDuplicates.length;
|
|
280
|
+
if (deduped > 0) {
|
|
281
|
+
dedupNote = `**Global Deduplication**: ${deduped} URL(s) were already in the global scraped pool.\n\n`;
|
|
282
|
+
}
|
|
283
|
+
urls = dedupedUrls;
|
|
284
|
+
|
|
285
|
+
if (urls.length === 0) {
|
|
286
|
+
return {
|
|
287
|
+
content: [{ type: 'text', text: `# Batch 2 Skipped\n\nAll URLs were already scraped in previous batches or globally. Proceed to synthesis.` }],
|
|
288
|
+
details: { skipped: true, reason: 'all_duplicates' },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (urls.length > BATCH_2_MAX_SCRAPE_URLS) {
|
|
293
|
+
urls = urls.slice(0, BATCH_2_MAX_SCRAPE_URLS);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Immediately update global link pool (signals intent to other researchers)
|
|
297
|
+
// This ensures other researchers see these URLs as being scraped, even if
|
|
298
|
+
// the fetch hasn't completed yet. Prevents duplicate scraping in concurrent scenarios.
|
|
299
|
+
options.updateGlobalLinks(urls);
|
|
300
|
+
|
|
301
|
+
let scrapeResults: any[];
|
|
302
|
+
try {
|
|
303
|
+
scrapeResults = urls.length === 1
|
|
304
|
+
? [await scrapeSingle(urls[0]!, signal)]
|
|
305
|
+
: await scrape(urls, maxConcurrency, signal);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
308
|
+
return {
|
|
309
|
+
content: [{ type: 'text', text: `# Scrape Failed\n\n${errorMsg}` }],
|
|
310
|
+
details: { error: errorMsg },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const successful = scrapeResults.filter(r => r.source !== 'failed');
|
|
315
|
+
const failed = scrapeResults.filter(r => r.source === 'failed');
|
|
316
|
+
const elapsed = Date.now() - startTime;
|
|
317
|
+
|
|
318
|
+
const ctxAfter = getContextFraction();
|
|
319
|
+
const batch3Available = ctxAfter < MAX_CONTEXT_FRACTION_FOR_SCRAPING;
|
|
320
|
+
|
|
321
|
+
let markdown = `# URL Scrape Results (Batch 2)\n\n`;
|
|
322
|
+
if (dedupNote) markdown += dedupNote;
|
|
323
|
+
markdown += `**Successful:** ${successful.length}, **Failed:** ${failed.length}, **Duration:** ${(elapsed / 1000).toFixed(2)}s`;
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if (rawExclude.length > 0) {
|
|
327
|
+
markdown += `**Links considered but not scraped:**\n${rawExclude.map(l => `- ${l}`).join('\n')}\n\n`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (batch3Available) {
|
|
331
|
+
markdown += `**Batch 3 available** (context ${Math.round(ctxAfter * 100)}% full). Up to ${MAX_SCRAPE_URLS} more URLs for deep-dive.\n\n`;
|
|
332
|
+
} else {
|
|
333
|
+
markdown += `**All standard batches complete** (context ${Math.round(ctxAfter * 100)}% full). Proceed to Phase 3 — Synthesis.\n\n`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const result of successful) {
|
|
337
|
+
const category = result.sourceCategory ? `**Source type**: ${result.sourceCategory}\n` : '';
|
|
338
|
+
markdown += `### ${result.url}\n${category}\n${result.markdown}\n\n---\n\n`;
|
|
339
|
+
}
|
|
340
|
+
if (failed.length > 0) {
|
|
341
|
+
markdown += `### Failed URLs\n${failed.map((r: any) => `- ${r.url}`).join('\n')}\n`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
content: [{ type: 'text', text: markdown }],
|
|
346
|
+
details: { urls, excludeLinks: rawExclude, successfulCount: successful.length, duration: elapsed, batch: 2 },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── CALL 4: Batch 3 (context-gated deep-dive) ───────────────────────
|
|
351
|
+
if (callCount === 3) {
|
|
352
|
+
const allowed = options.tracker.recordCall('scrape');
|
|
353
|
+
if (!allowed) {
|
|
354
|
+
// THROW to prevent researcher from calling again
|
|
355
|
+
throw new Error('Scrape limit reached. Handshake + 3 batches completed.');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const paramsRecord = params as Record<string, unknown>;
|
|
359
|
+
let urls = (paramsRecord['urls'] as string[] | undefined) || [];
|
|
360
|
+
|
|
361
|
+
// Context gate (with projection)
|
|
362
|
+
const projectedFraction = getContextFraction(urls.length * AVG_TOKENS_PER_SCRAPE);
|
|
363
|
+
if (projectedFraction >= MAX_CONTEXT_FRACTION_FOR_SCRAPING) {
|
|
364
|
+
return {
|
|
365
|
+
content: [{ type: 'text', text: contextLimitMessage(projectedFraction, 'Batch 3', true) }],
|
|
366
|
+
details: { skipped: true, reason: 'context_limit_projection', contextFraction: projectedFraction },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const startTime = Date.now();
|
|
371
|
+
const rawExclude = (paramsRecord['excludeLinks'] as string[] | undefined) || [];
|
|
372
|
+
const maxConcurrency = validateMaxConcurrency(paramsRecord['maxConcurrency'] as number | undefined);
|
|
373
|
+
|
|
374
|
+
if (!Array.isArray(urls) || urls.length === 0 || !urls.every(u => typeof u === 'string')) {
|
|
375
|
+
throw new Error('At least one valid URL string is required for scraping.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Deduplicate against globally scraped URLs (includes all previous batches)
|
|
379
|
+
const { kept: dedupedUrls, duplicates: globalDuplicates } = deduplicateUrls(urls, getScrapedState());
|
|
380
|
+
|
|
381
|
+
// Report duplicates to the AI
|
|
382
|
+
let dedupNote = '';
|
|
383
|
+
if (globalDuplicates.length > 0) {
|
|
384
|
+
dedupNote = `**Global Deduplication**: ${globalDuplicates.length} URL(s) were already in the global scraped pool.\n\n`;
|
|
385
|
+
}
|
|
386
|
+
urls = dedupedUrls;
|
|
387
|
+
|
|
388
|
+
if (urls.length === 0) {
|
|
389
|
+
return {
|
|
390
|
+
content: [{ type: 'text', text: `# Batch 3 Skipped\n\nAll URLs were already scraped in previous batches or globally. Proceed to synthesis.` }],
|
|
391
|
+
details: { skipped: true, reason: 'all_duplicates' },
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (urls.length > MAX_SCRAPE_URLS) {
|
|
396
|
+
urls = urls.slice(0, MAX_SCRAPE_URLS);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Immediately update global link pool (signals intent to other researchers)
|
|
400
|
+
// This ensures other researchers see these URLs as being scraped, even if
|
|
401
|
+
// the fetch hasn't completed yet. Prevents duplicate scraping in concurrent scenarios.
|
|
402
|
+
options.updateGlobalLinks(urls);
|
|
403
|
+
|
|
404
|
+
let scrapeResults: any[];
|
|
405
|
+
try {
|
|
406
|
+
scrapeResults = urls.length === 1
|
|
407
|
+
? [await scrapeSingle(urls[0]!, signal)]
|
|
408
|
+
: await scrape(urls, maxConcurrency, signal);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
411
|
+
return {
|
|
412
|
+
content: [{ type: 'text', text: `# Scrape Failed\n\n${errorMsg}` }],
|
|
413
|
+
details: { error: errorMsg },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const successful = scrapeResults.filter(r => r.source !== 'failed');
|
|
418
|
+
const failed = scrapeResults.filter(r => r.source === 'failed');
|
|
419
|
+
const elapsed = Date.now() - startTime;
|
|
420
|
+
|
|
421
|
+
let markdown = `# URL Scrape Results (Batch 3 — Deep-Dive)\n\n`;
|
|
422
|
+
if (dedupNote) markdown += dedupNote;
|
|
423
|
+
markdown += `**Successful:** ${successful.length}, **Failed:** ${failed.length}, **Duration:** ${(elapsed / 1000).toFixed(2)}s\n\n`;
|
|
424
|
+
markdown += `**All scrape batches complete.** Proceed to Phase 3 — Synthesis.\n\n`;
|
|
425
|
+
|
|
426
|
+
if (rawExclude.length > 0) {
|
|
427
|
+
markdown += `**Links considered but not scraped:**\n${rawExclude.map(l => `- ${l}`).join('\n')}\n\n`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for (const result of successful) {
|
|
431
|
+
const category = result.sourceCategory ? `**Source type**: ${result.sourceCategory}\n` : '';
|
|
432
|
+
markdown += `### ${result.url}\n${category}\n${result.markdown}\n\n---\n\n`;
|
|
433
|
+
}
|
|
434
|
+
if (failed.length > 0) {
|
|
435
|
+
markdown += `### Failed URLs\n${failed.map((r: any) => `- ${r.url}`).join('\n')}\n`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
content: [{ type: 'text', text: markdown }],
|
|
440
|
+
details: { urls, excludeLinks: rawExclude, successfulCount: successful.length, duration: elapsed, batch: 3 },
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── CALL 5+: Locked ──────────────────────────────────────────────────
|
|
445
|
+
return {
|
|
446
|
+
content: [{ type: 'text', text: 'Locked: All scrape batches have been used. Proceed directly to Phase 3 — Synthesis.' }],
|
|
447
|
+
details: { locked: true },
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search Tool
|
|
3
|
+
*
|
|
4
|
+
* Search the web via SearXNG and return URLs, titles, and snippets.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ToolDefinition, AgentToolResult, ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
8
|
+
import { Type } from '@sinclair/typebox';
|
|
9
|
+
import { search } from '../web-research/search.ts';
|
|
10
|
+
import type { SearxngSearchOptions } from '../web-research/types.ts';
|
|
11
|
+
import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
12
|
+
|
|
13
|
+
interface SearchParams {
|
|
14
|
+
queries: string[];
|
|
15
|
+
maxResults?: number;
|
|
16
|
+
freshness?: SearxngSearchOptions['freshness'];
|
|
17
|
+
sourceType?: SearxngSearchOptions['sourceType'];
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createSearchTool(options: {
|
|
22
|
+
ctx: ExtensionContext;
|
|
23
|
+
tracker: ToolUsageTracker;
|
|
24
|
+
}): ToolDefinition {
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: 'search',
|
|
28
|
+
label: 'Search',
|
|
29
|
+
description: 'Search the web via SearXNG and return URLs, titles, and snippets.',
|
|
30
|
+
promptSnippet: 'Search the web for URLs and information (returns snippets only, not full content)',
|
|
31
|
+
promptGuidelines: [
|
|
32
|
+
'Use search when you need to find URLs or information about a topic.',
|
|
33
|
+
'This returns search results with snippets. Use scrape to get full content from specific URLs.',
|
|
34
|
+
'For security research, use security_search to query vulnerability databases.',
|
|
35
|
+
'CRITICAL: You are allowed a maximum of 4 gathering calls total across ALL tools. Use them for breadth.',
|
|
36
|
+
'Use sourceType "news" when researching current events, recent releases, or changelogs.',
|
|
37
|
+
'Use sourceType "github" when looking for repositories, packages, or open-source libraries.',
|
|
38
|
+
'Combine sourceType "news" with a freshness window (e.g. "week" or "month") for recent developments.',
|
|
39
|
+
],
|
|
40
|
+
parameters: Type.Object({
|
|
41
|
+
queries: Type.Array(Type.String({
|
|
42
|
+
description: 'Search queries - plain text strings, one or more',
|
|
43
|
+
}), { minItems: 1 }),
|
|
44
|
+
maxResults: Type.Optional(Type.Number({
|
|
45
|
+
description: 'Maximum results to show per query (default: 20)',
|
|
46
|
+
default: 20,
|
|
47
|
+
minimum: 1,
|
|
48
|
+
maximum: 30,
|
|
49
|
+
})),
|
|
50
|
+
freshness: Type.Optional(Type.Union([
|
|
51
|
+
Type.Literal('any'),
|
|
52
|
+
Type.Literal('day'),
|
|
53
|
+
Type.Literal('week'),
|
|
54
|
+
Type.Literal('month'),
|
|
55
|
+
Type.Literal('year'),
|
|
56
|
+
], {
|
|
57
|
+
description: 'Restrict results to a time window. "any" (default) applies no filter. Use "week" or "month" for recent news or changelogs.',
|
|
58
|
+
})),
|
|
59
|
+
sourceType: Type.Optional(Type.Union([
|
|
60
|
+
Type.Literal('general'),
|
|
61
|
+
Type.Literal('news'),
|
|
62
|
+
Type.Literal('github'),
|
|
63
|
+
], {
|
|
64
|
+
description: 'Preferred source category. "general" (default) is broad web. "news" targets news sources. "github" searches the tech/code index (best for repos, packages, open-source).',
|
|
65
|
+
})),
|
|
66
|
+
}),
|
|
67
|
+
async execute(_toolCallId, params, signal, _onUpdate, _extensionCtx): Promise<AgentToolResult<unknown>> {
|
|
68
|
+
// Record call in tracker - returns false if limit reached
|
|
69
|
+
const allowed = options.tracker.recordCall('search');
|
|
70
|
+
if (!allowed) {
|
|
71
|
+
// THROW to prevent researcher from calling again
|
|
72
|
+
throw new Error(options.tracker.getLimitMessage('search'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const startTime = Date.now();
|
|
76
|
+
const paramsRecord = params as Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
if (!isSearchParams(paramsRecord)) {
|
|
79
|
+
throw new Error('Invalid parameters for search');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const queries = paramsRecord['queries'] as string[];
|
|
83
|
+
if (queries.length === 0) {
|
|
84
|
+
throw new Error('At least one query is required');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const maxResults = (paramsRecord['maxResults'] as number | undefined) ?? 20;
|
|
88
|
+
const freshness = paramsRecord['freshness'] as SearxngSearchOptions['freshness'] | undefined;
|
|
89
|
+
const sourceType = paramsRecord['sourceType'] as SearxngSearchOptions['sourceType'] | undefined;
|
|
90
|
+
const searchOptions: SearxngSearchOptions = {
|
|
91
|
+
...(freshness && freshness !== 'any' ? { freshness } : {}),
|
|
92
|
+
...(sourceType ? { sourceType } : {}),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const queriesText = queries.length === 1 ? 'query' : 'queries';
|
|
96
|
+
|
|
97
|
+
let queryResults;
|
|
98
|
+
try {
|
|
99
|
+
queryResults = await search(queries, searchOptions, signal);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
102
|
+
const elapsed = Date.now() - startTime;
|
|
103
|
+
return {
|
|
104
|
+
content: [
|
|
105
|
+
{
|
|
106
|
+
type: 'text',
|
|
107
|
+
text: `# Web Search Failed\n\n**Error:** ${errorMsg}\n\n**Queries Attempted:** ${queries.length}\n\n**Duration:** ${(elapsed / 1000).toFixed(2)}s\n\nFailed to search the web. This may be due to network issues, SearXNG unavailability, or search engine blocking.`,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
details: {
|
|
111
|
+
queries,
|
|
112
|
+
error: errorMsg,
|
|
113
|
+
duration: elapsed,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const elapsed = Date.now() - startTime;
|
|
119
|
+
|
|
120
|
+
let markdown = '# Web Search Results\n\n';
|
|
121
|
+
markdown += `**Searched:** ${queries.length} ${queriesText}\n`;
|
|
122
|
+
markdown += `**Duration:** ${(elapsed / 1000).toFixed(2)}s\n`;
|
|
123
|
+
if (sourceType && sourceType !== 'general') markdown += `**Source type:** ${sourceType}\n`;
|
|
124
|
+
if (freshness && freshness !== 'any') markdown += `**Freshness filter:** ${freshness}\n`;
|
|
125
|
+
markdown += '\n';
|
|
126
|
+
|
|
127
|
+
for (const qr of queryResults) {
|
|
128
|
+
markdown += `## Query: ${qr.query}\n\n`;
|
|
129
|
+
if (qr.error !== undefined) {
|
|
130
|
+
const errorIcon = qr.error.type === 'empty_results' ? '📭' : '⚠️';
|
|
131
|
+
markdown += `${errorIcon} **${qr.error.type === 'empty_results' ? 'No results' : 'Error'}:** ${qr.error.message}\n\n`;
|
|
132
|
+
if (qr.error.type !== 'empty_results') {
|
|
133
|
+
markdown += `**Error Type:** ${qr.error.type}\n\n`;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
markdown += `**Results:** ${qr.results.length} found\n\n`;
|
|
137
|
+
for (let i = 0; i < Math.min(qr.results.length, maxResults); i++) {
|
|
138
|
+
const result = qr.results[i];
|
|
139
|
+
if (!result) continue;
|
|
140
|
+
markdown += `### ${i + 1}. ${result.title}\n\n`;
|
|
141
|
+
markdown += `- **URL:** ${result.url}\n`;
|
|
142
|
+
if (result.content !== undefined) {
|
|
143
|
+
const content = result.content;
|
|
144
|
+
markdown += `- **Snippet:** ${content.substring(0, 200)}${content.length > 200 ? '...' : ''}\n`;
|
|
145
|
+
}
|
|
146
|
+
markdown += '\n';
|
|
147
|
+
}
|
|
148
|
+
if (qr.results.length > maxResults) {
|
|
149
|
+
const moreCount = qr.results.length - maxResults;
|
|
150
|
+
const moreText = moreCount === 1 ? 'result' : 'results';
|
|
151
|
+
markdown += `\n*... and ${moreCount} more ${moreText} not shown.*\n`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
markdown += '\n---\n\n';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: markdown }],
|
|
159
|
+
details: {
|
|
160
|
+
queryResults,
|
|
161
|
+
totalQueries: queries.length,
|
|
162
|
+
totalResults: queryResults.reduce((sum, qr) => sum + qr.results.length, 0),
|
|
163
|
+
duration: elapsed,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const VALID_FRESHNESS = new Set(['any', 'day', 'week', 'month', 'year']);
|
|
171
|
+
const VALID_SOURCE_TYPE = new Set(['general', 'news', 'github']);
|
|
172
|
+
|
|
173
|
+
function isSearchParams(params: Record<string, unknown>): params is SearchParams {
|
|
174
|
+
if (
|
|
175
|
+
typeof params !== 'object' ||
|
|
176
|
+
params === null ||
|
|
177
|
+
!('queries' in params) ||
|
|
178
|
+
!Array.isArray(params['queries']) ||
|
|
179
|
+
!(params['queries'] as unknown[]).every((q: unknown) => typeof q === 'string')
|
|
180
|
+
) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const freshness = params['freshness'];
|
|
184
|
+
if (freshness !== undefined && !VALID_FRESHNESS.has(freshness as string)) return false;
|
|
185
|
+
const sourceType = params['sourceType'];
|
|
186
|
+
if (sourceType !== undefined && !VALID_SOURCE_TYPE.has(sourceType as string)) return false;
|
|
187
|
+
return true;
|
|
188
|
+
}
|