@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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/config/searxng/default-settings.yml +189 -0
  4. package/config/searxng/limiter.toml +34 -0
  5. package/config/tooling/eslint.config.js +73 -0
  6. package/config/tooling/vitest.config.integration.ts +17 -0
  7. package/config/tooling/vitest.config.ts +40 -0
  8. package/config/tooling/vitest.config.unit.ts +12 -0
  9. package/docs/ARCHITECTURE.md +109 -0
  10. package/docs/assets/README-banner.jpg +0 -0
  11. package/package.json +92 -0
  12. package/scripts/setup.js +174 -0
  13. package/src/config.ts +206 -0
  14. package/src/constants.ts +148 -0
  15. package/src/healthcheck/index.ts +309 -0
  16. package/src/index.ts +71 -0
  17. package/src/infrastructure/searxng-lifecycle.ts +577 -0
  18. package/src/infrastructure/searxng-manager.ts +1173 -0
  19. package/src/infrastructure/state-manager.ts +918 -0
  20. package/src/logger.ts +246 -0
  21. package/src/orchestration/deep-research-orchestrator.ts +848 -0
  22. package/src/orchestration/deep-research-reducer.ts +129 -0
  23. package/src/orchestration/deep-research-types.ts +54 -0
  24. package/src/orchestration/id-utils.ts +63 -0
  25. package/src/orchestration/researcher.ts +104 -0
  26. package/src/orchestration/session-context.ts +43 -0
  27. package/src/prompts/research-tool-usage.md +45 -0
  28. package/src/prompts/researcher.md +166 -0
  29. package/src/prompts/system-coordinator.md +24 -0
  30. package/src/prompts/system-lead-evaluator.md +225 -0
  31. package/src/security/cisa-kev.ts +242 -0
  32. package/src/security/github-advisories.ts +607 -0
  33. package/src/security/index.ts +406 -0
  34. package/src/security/nvd.ts +532 -0
  35. package/src/security/osv.ts +487 -0
  36. package/src/security/types.ts +138 -0
  37. package/src/stackexchange/cache.ts +111 -0
  38. package/src/stackexchange/index.ts +352 -0
  39. package/src/stackexchange/output/compact.ts +64 -0
  40. package/src/stackexchange/output/index.ts +7 -0
  41. package/src/stackexchange/output/json.ts +21 -0
  42. package/src/stackexchange/output/table.ts +123 -0
  43. package/src/stackexchange/queries.ts +168 -0
  44. package/src/stackexchange/rest-client.ts +127 -0
  45. package/src/stackexchange/types.ts +129 -0
  46. package/src/tool.ts +430 -0
  47. package/src/tools/grep.ts +335 -0
  48. package/src/tools/index.ts +77 -0
  49. package/src/tools/scrape.ts +451 -0
  50. package/src/tools/search.ts +188 -0
  51. package/src/tools/security.ts +292 -0
  52. package/src/tools/stackexchange.ts +101 -0
  53. package/src/tui/research-panel.ts +576 -0
  54. package/src/tui/searxng-status.ts +80 -0
  55. package/src/types/extension-context.ts +60 -0
  56. package/src/types/llm.ts +111 -0
  57. package/src/utils/inject-date.ts +29 -0
  58. package/src/utils/input-validation.ts +103 -0
  59. package/src/utils/json-utils.ts +262 -0
  60. package/src/utils/make-resource-loader.ts +43 -0
  61. package/src/utils/research-error.ts +205 -0
  62. package/src/utils/research-export.ts +199 -0
  63. package/src/utils/searxng-config.ts +94 -0
  64. package/src/utils/session-state.ts +295 -0
  65. package/src/utils/shared-links.ts +123 -0
  66. package/src/utils/shutdown-manager.ts +54 -0
  67. package/src/utils/text-utils.ts +83 -0
  68. package/src/utils/tool-usage-tracker.ts +135 -0
  69. package/src/web-research/dependencies.ts +20 -0
  70. package/src/web-research/retry-utils.ts +232 -0
  71. package/src/web-research/scrapers.ts +935 -0
  72. package/src/web-research/search.ts +235 -0
  73. package/src/web-research/types.ts +47 -0
  74. package/src/web-research/utils.ts +158 -0
  75. package/tsconfig.json +40 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Health Check Module
3
+ *
4
+ * QUICK connectivity validation for search and scrape tools.
5
+ * Makes real requests to verify network is alive.
6
+ * Fast timeouts, simple checks (just verify tools respond with content).
7
+ */
8
+
9
+ import { logger } from '../logger.ts';
10
+ import { getConfig } from '../config.ts';
11
+ import { getManager } from '../infrastructure/searxng-lifecycle.ts';
12
+ import { search } from '../web-research/search.ts';
13
+ import { scrapeSingle } from '../web-research/scrapers.ts';
14
+ import { setSearxngManager } from '../web-research/utils.ts';
15
+ import { getActiveSearxngEngines } from '../utils/searxng-config.ts';
16
+ import { withTimeout } from '../web-research/retry-utils.ts';
17
+ import type { SearXNGResult } from '../web-research/types.ts';
18
+
19
+ // Health check cache to avoid running multiple health checks concurrently
20
+ interface CachedHealthCheck {
21
+ result: HealthCheckResult;
22
+ timestamp: number;
23
+ }
24
+
25
+ let cachedHealthCheck: CachedHealthCheck | null = null;
26
+ const HEALTH_CHECK_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
27
+ let healthCheckPromise: Promise<HealthCheckResult> | null = null;
28
+
29
+ // Get timeout from config or use defaults
30
+ const config = getConfig();
31
+ const SEARCH_TIMEOUT_MS = config.HEALTH_CHECK_TIMEOUT_MS || 15000; // 15s max for search
32
+ const SCRAPE_TIMEOUT_MS = config.HEALTH_CHECK_TIMEOUT_MS ? config.HEALTH_CHECK_TIMEOUT_MS + 5000 : 20000; // 5s longer than search
33
+
34
+ export interface HealthCheckResult {
35
+ success: boolean;
36
+ searchOk: boolean;
37
+ scrapeOk: boolean;
38
+ error?: string;
39
+ details: {
40
+ searchQuery?: string;
41
+ searchResultCount?: number;
42
+ searchDurationMs?: number;
43
+ workingEngines?: string[];
44
+ workingEngineCount?: number;
45
+ scrapedUrl?: string;
46
+ scrapedContentLength?: number;
47
+ scrapedDurationMs?: number;
48
+ };
49
+ }
50
+
51
+ /**
52
+ * QUICK scrape validation: just check content exists and is readable
53
+ */
54
+ function validateScrapeOutput(markdown: string): { valid: boolean; error?: string } {
55
+ if (typeof markdown !== 'string') {
56
+ return { valid: false, error: 'Content not a string' };
57
+ }
58
+
59
+ const trimmed = markdown.trim();
60
+ if (trimmed.length === 0) {
61
+ return { valid: false, error: 'Content is empty' };
62
+ }
63
+
64
+ // Just check it has some readable text (not garbage)
65
+ if (!/[a-zA-Z0-9]{10,}/.test(markdown)) {
66
+ return { valid: false, error: 'Content not readable' };
67
+ }
68
+
69
+ return { valid: true };
70
+ }
71
+
72
+ /**
73
+ * Healthcheck uses the same web-research search/scrape helpers as the main tool.
74
+ * Ensure they are wired to the current lifecycle manager instead of relying on
75
+ * an external caller to have registered it beforehand.
76
+ */
77
+ function ensureHealthcheckManager(): void {
78
+ const manager = getManager();
79
+ if (!manager) {
80
+ throw new Error('SearXNG manager not initialized. Call initLifecycle() before runHealthCheck().');
81
+ }
82
+
83
+ setSearxngManager(manager);
84
+ }
85
+
86
+ /**
87
+ * Internal health check implementation
88
+ * Runs the actual health check tests
89
+ */
90
+ async function performHealthCheck(): Promise<HealthCheckResult> {
91
+ logger.log('[healthcheck] Starting network connectivity health check...');
92
+
93
+ const result: HealthCheckResult = {
94
+ success: false,
95
+ searchOk: false,
96
+ scrapeOk: false,
97
+ error: undefined,
98
+ details: {},
99
+ };
100
+
101
+ try {
102
+ // Skip health check if explicitly requested via env
103
+ if (process.env['PI_RESEARCH_SKIP_HEALTHCHECK'] === '1') {
104
+ logger.log('[healthcheck] Skipping health check (PI_RESEARCH_SKIP_HEALTHCHECK=1)');
105
+ result.success = true;
106
+ result.searchOk = true;
107
+ result.scrapeOk = true;
108
+ return result;
109
+ }
110
+
111
+ ensureHealthcheckManager();
112
+
113
+ // PHASE 1: Test general web search engines (Bing, DuckDuckGo, Brave — NOT Wikipedia)
114
+ logger.log('[healthcheck] Phase 1: Testing general web search engines (timeout: ' + SEARCH_TIMEOUT_MS + 'ms)...');
115
+ const searchQuery = 'open source software';
116
+ const searchStartTime = Date.now();
117
+
118
+ let queryResults;
119
+ try {
120
+ queryResults = await withTimeout(
121
+ search([searchQuery]),
122
+ SEARCH_TIMEOUT_MS,
123
+ 'Search'
124
+ );
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ result.error = `Search request failed: ${msg}. Network may be unreachable or SearXNG not responding.`;
128
+ logger.error('[healthcheck] Search error:', result.error);
129
+ return result;
130
+ }
131
+
132
+ const searchDurationMs = Date.now() - searchStartTime;
133
+
134
+ if (queryResults.length === 0 || !queryResults[0]) {
135
+ result.error = 'Search returned empty result object (SearXNG not initialized?)';
136
+ logger.error('[healthcheck]', result.error);
137
+ return result;
138
+ }
139
+
140
+ const qr = queryResults[0];
141
+ if (qr.error) {
142
+ const errMsg = qr.error.message || String(qr.error);
143
+ result.error = `Search request failed: ${errMsg}. SearXNG or engines are not responding.`;
144
+ logger.error('[healthcheck]', result.error);
145
+ return result;
146
+ }
147
+
148
+ // Check for at least 2 working general search engines (not Wikipedia)
149
+ // Wikipedia is an encyclopedic engine, not suitable for general web research
150
+ // Load the list of active engines from the actual SearXNG config (not hardcoded)
151
+ const generalEngines = getActiveSearxngEngines();
152
+ if (generalEngines.length === 0) {
153
+ result.error = 'No active general search engines found in SearXNG configuration';
154
+ logger.error('[healthcheck]', result.error);
155
+ return result;
156
+ }
157
+ logger.log(`[healthcheck] Checking for results from engines: [${generalEngines.join(', ')}]`);
158
+
159
+ const generalResults = qr.results.filter((r: SearXNGResult) =>
160
+ generalEngines.includes((r.engine || '').toLowerCase())
161
+ );
162
+
163
+ // Count how many engines returned at least one result
164
+ const engineCounts = new Map<string, number>();
165
+ generalEngines.forEach(e => engineCounts.set(e, 0));
166
+ generalResults.forEach((r: SearXNGResult) => {
167
+ const engine = (r.engine || '').toLowerCase();
168
+ if (engineCounts.has(engine)) {
169
+ engineCounts.set(engine, (engineCounts.get(engine) || 0) + 1);
170
+ }
171
+ });
172
+
173
+ const workingEngines = Array.from(engineCounts.entries()).filter(([_, count]) => count > 0);
174
+ const workingEngineCount = workingEngines.length;
175
+
176
+ // Require at least 1 engine to return results (local use may have rate limits)
177
+ if (workingEngineCount < 1) {
178
+ const engineReport = Array.from(engineCounts.entries())
179
+ .map(([e, c]) => `${e}: ${c}`)
180
+ .join(', ');
181
+ result.error = `No working engines found: 0/${generalEngines.length} returned results. Need at least 1 engine. Engine results: ${engineReport}. Your network or search engines may be blocked.`;
182
+ logger.error('[healthcheck] Search validation failed:', result.error);
183
+ return result;
184
+ }
185
+
186
+ result.searchOk = true;
187
+ result.details.searchQuery = searchQuery;
188
+ result.details.searchResultCount = generalResults.length;
189
+ result.details.searchDurationMs = searchDurationMs;
190
+ result.details.workingEngines = workingEngines.map(([e, _]) => e);
191
+ result.details.workingEngineCount = workingEngineCount;
192
+ const workingEnginesList = workingEngines.map(([e, _]) => e).join(', ');
193
+ logger.log(`[healthcheck] ✓ Search OK: ${generalResults.length} results from ${workingEngineCount} engines [${workingEnginesList}] in ${searchDurationMs}ms`);
194
+
195
+ // PHASE 2: Test scrape with timeout enforcement
196
+ logger.log('[healthcheck] Phase 2: Testing scrape tool (timeout: ' + SCRAPE_TIMEOUT_MS + 'ms)...');
197
+ const scrapeUrl = 'https://en.wikipedia.org/wiki/Python_(programming_language)';
198
+ const scrapeStartTime = Date.now();
199
+
200
+ let scrapeResult;
201
+ try {
202
+ scrapeResult = await withTimeout(
203
+ scrapeSingle(scrapeUrl),
204
+ SCRAPE_TIMEOUT_MS,
205
+ 'Scrape'
206
+ );
207
+ } catch (err) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ result.error = `Scrape request failed: ${msg}. Network timeout or target unreachable.`;
210
+ logger.error('[healthcheck]', result.error);
211
+ return result;
212
+ }
213
+
214
+ const scrapeDurationMs = Date.now() - scrapeStartTime;
215
+
216
+ if (!scrapeResult) {
217
+ result.error = 'Scrape returned null result (scraper crashed?)';
218
+ logger.error('[healthcheck]', result.error);
219
+ return result;
220
+ }
221
+
222
+ if (scrapeResult.source === 'failed') {
223
+ result.error = `Scrape failed: ${scrapeResult.error || 'unknown error'}. Check if target URL is accessible.`;
224
+ logger.error('[healthcheck]', result.error);
225
+ return result;
226
+ }
227
+
228
+ // ROBUST validation of scraped content
229
+ const markdown = scrapeResult.markdown || '';
230
+ const scrapeValidation = validateScrapeOutput(markdown);
231
+ if (!scrapeValidation.valid) {
232
+ result.error = `Scrape validation failed: ${scrapeValidation.error}`;
233
+ logger.error('[healthcheck]', result.error);
234
+ return result;
235
+ }
236
+
237
+ result.scrapeOk = true;
238
+ result.details.scrapedUrl = scrapeUrl;
239
+ result.details.scrapedContentLength = markdown.length;
240
+ result.details.scrapedDurationMs = scrapeDurationMs;
241
+ logger.log(`[healthcheck] ✓ Scrape OK: ${markdown.length} bytes in ${scrapeDurationMs}ms`);
242
+
243
+ // All checks passed
244
+ result.success = true;
245
+ const totalMs = searchDurationMs + scrapeDurationMs;
246
+ logger.log(`[healthcheck] ✓ HEALTH CHECK PASSED in ${totalMs}ms - all systems ready`);
247
+ return result;
248
+ } catch (err) {
249
+ result.error = `Unexpected error during health check: ${err instanceof Error ? err.message : String(err)}`;
250
+ logger.error('[healthcheck] Unexpected error:', err);
251
+ return result;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Run health check with caching to avoid concurrent executions
257
+ * If a health check is already in progress, returns the same promise
258
+ * If a recent successful health check is cached, returns the cached result
259
+ */
260
+ export async function runHealthCheck(): Promise<HealthCheckResult> {
261
+ const now = Date.now();
262
+
263
+ // Check if we have a recent cached result
264
+ if (cachedHealthCheck) {
265
+ const age = now - cachedHealthCheck.timestamp;
266
+ if (age < HEALTH_CHECK_CACHE_TTL_MS) {
267
+ logger.log(`[healthcheck] Using cached health check result (${Math.round(age / 1000)}s old)`);
268
+ return cachedHealthCheck.result;
269
+ } else {
270
+ logger.log(`[healthcheck] Cached health check result expired (${Math.round(age / 1000)}s old)`);
271
+ cachedHealthCheck = null;
272
+ }
273
+ }
274
+
275
+ // If a health check is already running, return the existing promise
276
+ if (healthCheckPromise) {
277
+ logger.log('[healthcheck] Health check already in progress, reusing existing promise');
278
+ return healthCheckPromise;
279
+ }
280
+
281
+ // Start a new health check
282
+ healthCheckPromise = (async () => {
283
+ try {
284
+ const result = await performHealthCheck();
285
+ // Cache successful results
286
+ if (result.success) {
287
+ cachedHealthCheck = {
288
+ result,
289
+ timestamp: now,
290
+ };
291
+ }
292
+ return result;
293
+ } finally {
294
+ // Clear the promise so a new health check can be started
295
+ healthCheckPromise = null;
296
+ }
297
+ })();
298
+
299
+ return healthCheckPromise;
300
+ }
301
+
302
+ /**
303
+ * Clear the health check cache
304
+ * Useful for testing or forcing a recheck
305
+ */
306
+ export function clearHealthCheckCache(): void {
307
+ cachedHealthCheck = null;
308
+ logger.log('[healthcheck] Health check cache cleared');
309
+ }
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * pi-research Extension
3
+ *
4
+ * Orchestrates multi-agent research using a state-driven deep mode architecture.
5
+ * Manages SearXNG as singleton (shared across all agents).
6
+ * SearXNG is initialized on first research() call, NOT on session_start.
7
+ * Extension-owned cleanup is delegated to pi's session lifecycle.
8
+ */
9
+
10
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
11
+ import { createResearchTool } from './tool.ts';
12
+ import { logger, isVerboseFromEnv, getDefaultDebugLogPathTemplate } from './logger.ts';
13
+ import { checkDockerAvailability } from './infrastructure/searxng-lifecycle.ts';
14
+ import { shutdownManager } from './utils/shutdown-manager.ts';
15
+ import { readFileSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+
22
+ /**
23
+ * Load prompt from markdown file
24
+ */
25
+ function loadPrompt(name: string): string {
26
+ const path = join(__dirname, 'prompts', `${name}.md`);
27
+ try {
28
+ return readFileSync(path, 'utf-8');
29
+ } catch (error) {
30
+ const msg = error instanceof Error ? error.message : String(error);
31
+ logger.error(`[pi-research] Failed to load prompt from ${path}: ${msg}`);
32
+ return '';
33
+ }
34
+ }
35
+
36
+ export default function (pi: ExtensionAPI) {
37
+ logger.log('[pi-research] Extension loading...');
38
+
39
+ // NOTE: SearXNG is still initialized lazily on first research() call in tool.ts.
40
+ // Cleanup runs on pi's session lifecycle instead of owning host process signals.
41
+
42
+ pi.on('session_shutdown', async (_event, _ctx) => {
43
+ await shutdownManager.runCleanup('session_shutdown');
44
+ });
45
+
46
+ // Notify user of Docker status and log file location when starting a new session
47
+ pi.on('session_start', async (_event, ctx) => {
48
+ // Check Docker availability on startup
49
+ const dockerCheck = await checkDockerAvailability();
50
+ if (!dockerCheck.running) {
51
+ ctx.ui.notify(`pi-research: ${dockerCheck.error}`, 'warning');
52
+ }
53
+
54
+ if (isVerboseFromEnv()) {
55
+ ctx.ui.notify(`pi-research: debug log → ${getDefaultDebugLogPathTemplate()}`, 'info');
56
+ }
57
+ });
58
+
59
+ // Register research tool
60
+ pi.registerTool(createResearchTool());
61
+
62
+ // Append research tool usage instructions to the system prompt
63
+ pi.on('before_agent_start', async (event) => {
64
+ const researchPrompt = loadPrompt('research-tool-usage');
65
+ return {
66
+ systemPrompt: event.systemPrompt + '\n\n' + researchPrompt
67
+ };
68
+ });
69
+
70
+ logger.log('[pi-research] Extension loaded');
71
+ }