@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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research Session State Management
|
|
3
|
+
*
|
|
4
|
+
* Tracks failures and state for multiple simultaneous research sessions,
|
|
5
|
+
* scoped by parent Pi session to prevent cross-context interference.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { generateSessionId as generateUniqueSessionId } from './shared-links.ts';
|
|
9
|
+
import { logger } from '../logger.ts';
|
|
10
|
+
import { getConfig } from '../config.ts';
|
|
11
|
+
import type { ResearchPanelState } from '../tui/research-panel.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* State container for a single Pi session
|
|
15
|
+
*/
|
|
16
|
+
interface PiSessionState {
|
|
17
|
+
/** Map of research run ID → array of failed researcher IDs */
|
|
18
|
+
failures: Map<string, string[]>;
|
|
19
|
+
/** Ordered list of research run IDs for TUI stacking (Index 0 = oldest/bottom-most) */
|
|
20
|
+
order: string[];
|
|
21
|
+
/** Registry of panel states for each research run */
|
|
22
|
+
panels: Map<string, ResearchPanelState>;
|
|
23
|
+
/** Subscribers for order changes in this Pi session */
|
|
24
|
+
subscribers: Array<() => void>;
|
|
25
|
+
/** Global debounce timer for this specific Pi session */
|
|
26
|
+
refreshTimeout: NodeJS.Timeout | null;
|
|
27
|
+
/** Single update function for the Master Widget of this Pi session */
|
|
28
|
+
masterUpdate: (() => void) | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Global map of Pi session ID → PiSessionState
|
|
33
|
+
*/
|
|
34
|
+
const piSessions = new Map<string, PiSessionState>();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get or create state for a specific Pi session
|
|
38
|
+
*/
|
|
39
|
+
function getPiState(piSessionId: string): PiSessionState {
|
|
40
|
+
let state = piSessions.get(piSessionId);
|
|
41
|
+
if (!state) {
|
|
42
|
+
state = {
|
|
43
|
+
failures: new Map(),
|
|
44
|
+
order: [],
|
|
45
|
+
panels: new Map(),
|
|
46
|
+
subscribers: [],
|
|
47
|
+
refreshTimeout: null,
|
|
48
|
+
masterUpdate: null,
|
|
49
|
+
};
|
|
50
|
+
piSessions.set(piSessionId, state);
|
|
51
|
+
}
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Maximum allowed unique failed researchers before stopping research
|
|
57
|
+
*/
|
|
58
|
+
const MAX_FAILED_RESEARCHERS = 2;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Subscribe to session order changes for a specific Pi session
|
|
62
|
+
*/
|
|
63
|
+
export function onSessionOrderChange(piSessionId: string, callback: () => void): () => void {
|
|
64
|
+
const state = getPiState(piSessionId);
|
|
65
|
+
state.subscribers.push(callback);
|
|
66
|
+
return () => {
|
|
67
|
+
const index = state.subscribers.indexOf(callback);
|
|
68
|
+
if (index !== -1) {
|
|
69
|
+
state.subscribers.splice(index, 1);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Notify subscribers of session order change
|
|
76
|
+
*/
|
|
77
|
+
function notifyOrderChange(piSessionId: string): void {
|
|
78
|
+
const state = getPiState(piSessionId);
|
|
79
|
+
for (const subscriber of state.subscribers) {
|
|
80
|
+
try {
|
|
81
|
+
subscriber();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error(`[session-state] Error in subscriber for ${piSessionId}:`, error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register a panel state for a research run
|
|
90
|
+
*/
|
|
91
|
+
export function registerSessionPanel(piSessionId: string, researchId: string, panel: ResearchPanelState): void {
|
|
92
|
+
const state = getPiState(piSessionId);
|
|
93
|
+
state.panels.set(researchId, panel);
|
|
94
|
+
|
|
95
|
+
if (!state.order.includes(researchId)) {
|
|
96
|
+
state.order.push(researchId);
|
|
97
|
+
notifyOrderChange(piSessionId);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Unregister a panel state
|
|
103
|
+
*/
|
|
104
|
+
export function unregisterSessionPanel(piSessionId: string, researchId: string): void {
|
|
105
|
+
const state = getPiState(piSessionId);
|
|
106
|
+
state.panels.delete(researchId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Register the update function for a Pi session's Master Widget
|
|
111
|
+
*/
|
|
112
|
+
export function registerMasterUpdate(piSessionId: string, update: () => void): void {
|
|
113
|
+
const state = getPiState(piSessionId);
|
|
114
|
+
state.masterUpdate = update;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Refresh the Master Widget for a Pi session
|
|
119
|
+
*/
|
|
120
|
+
export function refreshAllSessions(piSessionId: string): void {
|
|
121
|
+
const state = getPiState(piSessionId);
|
|
122
|
+
|
|
123
|
+
// Clear existing timeout for this specific Pi session
|
|
124
|
+
if (state.refreshTimeout) {
|
|
125
|
+
clearTimeout(state.refreshTimeout);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const debounceMs = getConfig().TUI_REFRESH_DEBOUNCE_MS;
|
|
129
|
+
state.refreshTimeout = setTimeout(() => {
|
|
130
|
+
try {
|
|
131
|
+
// Validate order integrity
|
|
132
|
+
const validIds = state.order.filter(id =>
|
|
133
|
+
state.panels.has(id) && state.failures.has(id)
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (validIds.length !== state.order.length) {
|
|
137
|
+
for (const id of state.order) {
|
|
138
|
+
if (!validIds.includes(id)) {
|
|
139
|
+
state.failures.delete(id);
|
|
140
|
+
state.panels.delete(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
state.order.length = 0;
|
|
144
|
+
state.order.push(...validIds);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Trigger the single Master Update for this Pi session
|
|
148
|
+
if (state.masterUpdate) {
|
|
149
|
+
try {
|
|
150
|
+
state.masterUpdate();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.error(`[session-state] Error updating Master Widget for ${piSessionId}:`, error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
state.refreshTimeout = null;
|
|
157
|
+
}
|
|
158
|
+
}, debounceMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Start a new research run within a Pi session
|
|
163
|
+
*/
|
|
164
|
+
export function startResearchSession(piSessionId: string): string {
|
|
165
|
+
const researchId = generateUniqueSessionId('research');
|
|
166
|
+
const state = getPiState(piSessionId);
|
|
167
|
+
state.failures.set(researchId, []);
|
|
168
|
+
return researchId;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Clear pending refreshes for a Pi session
|
|
173
|
+
*/
|
|
174
|
+
export function clearPendingRefresh(piSessionId: string): void {
|
|
175
|
+
const state = piSessions.get(piSessionId);
|
|
176
|
+
if (state?.refreshTimeout) {
|
|
177
|
+
clearTimeout(state.refreshTimeout);
|
|
178
|
+
state.refreshTimeout = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* End a research run
|
|
184
|
+
*/
|
|
185
|
+
export function endResearchSession(piSessionId: string, researchId: string): void {
|
|
186
|
+
const state = piSessions.get(piSessionId);
|
|
187
|
+
if (!state) return;
|
|
188
|
+
|
|
189
|
+
state.failures.delete(researchId);
|
|
190
|
+
state.panels.delete(researchId);
|
|
191
|
+
|
|
192
|
+
const index = state.order.indexOf(researchId);
|
|
193
|
+
if (index !== -1) {
|
|
194
|
+
state.order.splice(index, 1);
|
|
195
|
+
notifyOrderChange(piSessionId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// If this was the last research run in the Pi session, clean up the state
|
|
199
|
+
if (state.order.length === 0 && state.panels.size === 0 && state.subscribers.length === 0) {
|
|
200
|
+
clearPendingRefresh(piSessionId);
|
|
201
|
+
piSessions.delete(piSessionId);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check if a research run is the bottom-most in its Pi session
|
|
207
|
+
*/
|
|
208
|
+
export function isBottomMostSession(piSessionId: string, researchId: string): boolean {
|
|
209
|
+
const state = piSessions.get(piSessionId);
|
|
210
|
+
if (!state || state.order.length === 0) return false;
|
|
211
|
+
return state.order[0] === researchId;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Record a researcher failure
|
|
216
|
+
*/
|
|
217
|
+
export function recordResearcherFailure(piSessionId: string, researchId: string, researcherId: string): void {
|
|
218
|
+
const state = getPiState(piSessionId);
|
|
219
|
+
const failures = state.failures.get(researchId) || [];
|
|
220
|
+
failures.push(researcherId);
|
|
221
|
+
state.failures.set(researchId, failures);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get list of unique failed researchers in a research run
|
|
226
|
+
*/
|
|
227
|
+
export function getFailedResearchers(piSessionId: string, researchId: string): string[] {
|
|
228
|
+
const state = getPiState(piSessionId);
|
|
229
|
+
const failures = state.failures.get(researchId) || [];
|
|
230
|
+
return [...new Set(failures)];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Check if research should stop due to too many unique failures
|
|
235
|
+
*/
|
|
236
|
+
export function shouldStopResearch(piSessionId: string, researchId: string): boolean {
|
|
237
|
+
return getFailedResearchers(piSessionId, researchId).length >= MAX_FAILED_RESEARCHERS;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get formatted error message for research stoppage
|
|
242
|
+
*/
|
|
243
|
+
export function getResearchStopMessage(piSessionId: string, researchId: string): string {
|
|
244
|
+
const failed = getFailedResearchers(piSessionId, researchId);
|
|
245
|
+
const count = failed.length;
|
|
246
|
+
|
|
247
|
+
return [
|
|
248
|
+
`Research stopped: ${count} researcher(s) failed: ${failed.join(', ')}.`,
|
|
249
|
+
'',
|
|
250
|
+
'This indicates infrastructure failure — multiple researchers could not complete research.',
|
|
251
|
+
'Possible causes: network unavailable, SearXNG container not running, search engines returning errors.',
|
|
252
|
+
'',
|
|
253
|
+
'▎ If the health check passed (search and scrape verified), this failure is at the AI session layer —',
|
|
254
|
+
' check model availability, API key, and context settings.',
|
|
255
|
+
'',
|
|
256
|
+
'Troubleshooting:',
|
|
257
|
+
'• Verify network connection is active',
|
|
258
|
+
'• Check SearXNG container: `docker ps | grep searxng`',
|
|
259
|
+
'• View SearXNG logs: `docker logs pi-searxng` (check for engine HTTP errors like 503, 400)',
|
|
260
|
+
'• Verify PROXY_URL if configured (optional): should be socks5://host:port or http://host:port',
|
|
261
|
+
'• Check PI_RESEARCH_RESEARCHER_TIMEOUT_MS if set (default: 4 minutes)',
|
|
262
|
+
'',
|
|
263
|
+
'Partial results may be available below.',
|
|
264
|
+
].join('\n');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get all active research panels in a Pi session, in display order (newest first)
|
|
269
|
+
*/
|
|
270
|
+
export function getPiActivePanels(piSessionId: string): ResearchPanelState[] {
|
|
271
|
+
const state = piSessions.get(piSessionId);
|
|
272
|
+
if (!state) return [];
|
|
273
|
+
// Return in reverse order (newest first) for top-to-bottom stacking in a single widget
|
|
274
|
+
return [...state.order].reverse().map(id => state.panels.get(id)!).filter(Boolean);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get ordered list of active research runs in a Pi session
|
|
279
|
+
*/
|
|
280
|
+
export function getPiActiveSessionOrder(piSessionId: string): string[] {
|
|
281
|
+
const state = piSessions.get(piSessionId);
|
|
282
|
+
return state ? [...state.order] : [];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Reset all state (for testing only)
|
|
287
|
+
*/
|
|
288
|
+
export function resetAllPiSessions(): void {
|
|
289
|
+
for (const state of piSessions.values()) {
|
|
290
|
+
if (state.refreshTimeout) {
|
|
291
|
+
clearTimeout(state.refreshTimeout);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
piSessions.clear();
|
|
295
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Links Pool Management
|
|
3
|
+
*
|
|
4
|
+
* Centralized deterministic link state management system.
|
|
5
|
+
* Single source of truth: global allScrapedLinks array.
|
|
6
|
+
* No per-session state - everything flows through global state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a 4-character alphanumeric hash
|
|
13
|
+
*/
|
|
14
|
+
function generate4CharHash(): string {
|
|
15
|
+
// Use randomBytes for Node.js environments, fallback to Math.random for test environments
|
|
16
|
+
let hash: string;
|
|
17
|
+
try {
|
|
18
|
+
const bytes = crypto.randomBytes(2);
|
|
19
|
+
hash = bytes.toString('hex').substring(0, 4);
|
|
20
|
+
} catch {
|
|
21
|
+
// Fallback for test environments where crypto.randomBytes might not be available
|
|
22
|
+
// Generate a valid 4-character hex string
|
|
23
|
+
const randomHex = Math.floor(Math.random() * 65536).toString(16).padStart(4, '0');
|
|
24
|
+
hash = randomHex;
|
|
25
|
+
}
|
|
26
|
+
return hash;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate a unique session ID with 4-char hash
|
|
31
|
+
* @param baseId Base identifier (e.g., request ID)
|
|
32
|
+
* @returns Unique session ID (e.g., "abc123-x9k2")
|
|
33
|
+
*/
|
|
34
|
+
export function generateSessionId(baseId: string): string {
|
|
35
|
+
const hash = generate4CharHash();
|
|
36
|
+
return `${baseId}-${hash}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a URL for comparison (lowercase, remove trailing slash)
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeUrl(url: string): string {
|
|
43
|
+
try {
|
|
44
|
+
const normalized = url.toLowerCase().trim();
|
|
45
|
+
// Remove trailing slash except for root
|
|
46
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
47
|
+
return normalized.slice(0, -1);
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
} catch {
|
|
51
|
+
return url.toLowerCase().trim();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deduplicate a list of URLs against already-scraped URLs.
|
|
57
|
+
* Uses normalized comparison for accuracy.
|
|
58
|
+
*
|
|
59
|
+
* @param urls - URLs to check
|
|
60
|
+
* @param alreadyScraped - Array of already-scraped URLs
|
|
61
|
+
* @returns Object with kept URLs and removed duplicates
|
|
62
|
+
*/
|
|
63
|
+
export function deduplicateUrls(urls: string[], alreadyScraped: string[]): {
|
|
64
|
+
kept: string[];
|
|
65
|
+
duplicates: string[];
|
|
66
|
+
} {
|
|
67
|
+
const normalizedAlreadyScraped = new Set(
|
|
68
|
+
alreadyScraped.map(normalizeUrl)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const kept: string[] = [];
|
|
72
|
+
const duplicates: string[] = [];
|
|
73
|
+
|
|
74
|
+
for (const url of urls) {
|
|
75
|
+
const normalized = normalizeUrl(url);
|
|
76
|
+
if (normalizedAlreadyScraped.has(normalized)) {
|
|
77
|
+
duplicates.push(url);
|
|
78
|
+
} else {
|
|
79
|
+
kept.push(url);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { kept, duplicates };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Format all completed reports into a shared links section for researcher prompts.
|
|
88
|
+
* Extracts links from CITED LINKS and SCRAPE CANDIDATES sections.
|
|
89
|
+
*/
|
|
90
|
+
export function formatSharedLinksFromState(aspects: Record<string, { id: string; query: string; report?: string }>): string {
|
|
91
|
+
const completed = Object.values(aspects).filter(a => a.report);
|
|
92
|
+
if (completed.length === 0) return '';
|
|
93
|
+
|
|
94
|
+
let output = '## Shared Links from Previous Research\n\n';
|
|
95
|
+
output += 'The following links have already been examined by your siblings:\n\n';
|
|
96
|
+
|
|
97
|
+
for (const aspect of completed) {
|
|
98
|
+
const response = aspect.report!;
|
|
99
|
+
output += `### Aspect: ${aspect.query} (ID: ${aspect.id})\n\n`;
|
|
100
|
+
|
|
101
|
+
// Extract CITED LINKS
|
|
102
|
+
const citedMatch = response.match(/###?\s*CITED\s*LINKS?\s*\n([\s\S]*?)(?=\n###|$)/i);
|
|
103
|
+
if (citedMatch) {
|
|
104
|
+
output += '#### CITED LINKS\n';
|
|
105
|
+
output += citedMatch[1]!.trim() + '\n\n';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Extract SCRAPE CANDIDATES
|
|
109
|
+
const candidatesMatch = response.match(/###?\s*SCRAPE\s*CANDIDATES?\s*\n([\s\S]*?)(?=\n###|$)/i);
|
|
110
|
+
if (candidatesMatch) {
|
|
111
|
+
output += '#### SCRAPE CANDIDATES\n';
|
|
112
|
+
output += candidatesMatch[1]!.trim() + '\n\n';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
output += '**Review these links before scraping to avoid redundancy.**\n';
|
|
117
|
+
return output;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Legacy stub for compatibility
|
|
122
|
+
*/
|
|
123
|
+
export function cleanupSharedLinks(_sessionId: string): void {}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { logger } from '../logger.ts';
|
|
2
|
+
|
|
3
|
+
export type CleanupTask = () => Promise<void> | void;
|
|
4
|
+
|
|
5
|
+
export class ShutdownManager {
|
|
6
|
+
private tasks: CleanupTask[] = [];
|
|
7
|
+
private cleanupPromise: Promise<void> | null = null;
|
|
8
|
+
|
|
9
|
+
register(task: CleanupTask) {
|
|
10
|
+
if (this.tasks.includes(task)) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
this.tasks.push(task);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async runCleanup(reason: string): Promise<void> {
|
|
18
|
+
if (this.cleanupPromise !== null) {
|
|
19
|
+
return this.cleanupPromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const tasksToRun = [...this.tasks].reverse();
|
|
23
|
+
this.cleanupPromise = (async () => {
|
|
24
|
+
if (tasksToRun.length === 0) {
|
|
25
|
+
logger.log(`[ShutdownManager] No cleanup tasks registered (${reason}).`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
logger.log(`[ShutdownManager] Running cleanup tasks (${reason})...`);
|
|
30
|
+
|
|
31
|
+
// Detach the current task set so repeated cleanup calls are idempotent,
|
|
32
|
+
// while future sessions can register a fresh cleanup set.
|
|
33
|
+
this.tasks = [];
|
|
34
|
+
|
|
35
|
+
for (const task of tasksToRun) {
|
|
36
|
+
try {
|
|
37
|
+
await task();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
logger.error('[ShutdownManager] Error during cleanup task:', error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
logger.log('[ShutdownManager] Cleanup complete.');
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await this.cleanupPromise;
|
|
48
|
+
} finally {
|
|
49
|
+
this.cleanupPromise = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const shutdownManager = new ShutdownManager();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AgentSession } from '@mariozechner/pi-coding-agent';
|
|
2
|
+
import type { AssistantMessage } from '@mariozechner/pi-ai';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Text Utilities
|
|
6
|
+
*
|
|
7
|
+
* Shared text extraction and formatting functions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract text content from a message
|
|
12
|
+
*
|
|
13
|
+
* Handles various message content formats:
|
|
14
|
+
* - String content
|
|
15
|
+
* - Array of content blocks (TextContent, ThinkingContent, ToolCall, etc.)
|
|
16
|
+
* - Accepts any object with a content field for robustness
|
|
17
|
+
*
|
|
18
|
+
* @param message - Message object with content field, or undefined/null
|
|
19
|
+
* @returns Extracted text string, or empty string if invalid
|
|
20
|
+
*/
|
|
21
|
+
export function extractText(message: unknown): string {
|
|
22
|
+
// Validate message exists and is an object
|
|
23
|
+
if (!message || typeof message !== 'object') {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const msg = message as Record<string, unknown>;
|
|
28
|
+
const { content } = msg;
|
|
29
|
+
|
|
30
|
+
// Handle string content
|
|
31
|
+
if (typeof content === 'string') {
|
|
32
|
+
return content;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle array of content blocks
|
|
36
|
+
if (Array.isArray(content)) {
|
|
37
|
+
try {
|
|
38
|
+
return (content as unknown[])
|
|
39
|
+
.filter((b) => b && typeof b === 'object' && (b as Record<string, unknown>)['type'] === 'text')
|
|
40
|
+
.map((b) => {
|
|
41
|
+
const blockObj = b as Record<string, unknown>;
|
|
42
|
+
const text = blockObj['text'];
|
|
43
|
+
return typeof text === 'string' ? text : '';
|
|
44
|
+
})
|
|
45
|
+
.filter((t: string) => t.length > 0) // Filter empty strings
|
|
46
|
+
.join('\n');
|
|
47
|
+
} catch (_error) {
|
|
48
|
+
// If array processing fails, return empty string
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Unknown content format
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensures the assistant completed successfully and returns the text content.
|
|
58
|
+
* Throws an error if the assistant failed or stopped with an error.
|
|
59
|
+
*
|
|
60
|
+
* @param session - The agent session to check
|
|
61
|
+
* @param label - Label for error reporting
|
|
62
|
+
* @returns The extracted text content
|
|
63
|
+
* @throws Error if assistant failed
|
|
64
|
+
*/
|
|
65
|
+
export function ensureAssistantResponse(session: AgentSession, label: string): string {
|
|
66
|
+
const msgs = session.messages;
|
|
67
|
+
const last = [...msgs].reverse().find((m) => m.role === 'assistant') as AssistantMessage | undefined;
|
|
68
|
+
|
|
69
|
+
if (!last) {
|
|
70
|
+
throw new Error(`${label}: No assistant response found`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check for explicit error stop reason or error message
|
|
74
|
+
if (last.stopReason === 'error' || (last.errorMessage && last.stopReason !== 'aborted')) {
|
|
75
|
+
const msg = last.errorMessage || 'Unknown error';
|
|
76
|
+
if (msg.includes('429')) {
|
|
77
|
+
throw new Error(`Model API rate limit (429) — wait a moment and retry. Details: ${msg}`);
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`${label}: Provider error - ${msg}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return extractText(last);
|
|
83
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Usage Tracker
|
|
3
|
+
*
|
|
4
|
+
* Tracks and enforces limits on researcher tool usage.
|
|
5
|
+
* Prevents excessive calls to prevent rate limiting and control costs.
|
|
6
|
+
* Enforcement is code-based only — when a limit is hit, the tool throws.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { logger } from '../logger.ts';
|
|
10
|
+
import { MAX_GATHERING_CALLS, MAX_SCRAPE_CALLS } from '../constants.ts';
|
|
11
|
+
|
|
12
|
+
export interface ToolLimits {
|
|
13
|
+
// Combined gathering limit (search, security_search, stackexchange, grep)
|
|
14
|
+
gathering?: number;
|
|
15
|
+
// Scrape limit
|
|
16
|
+
scrape?: number;
|
|
17
|
+
// read tool (default pi file read) has no limit
|
|
18
|
+
read?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ToolUsage {
|
|
22
|
+
category: string;
|
|
23
|
+
callCount: number;
|
|
24
|
+
limit?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ToolUsageTracker {
|
|
28
|
+
private usage: Map<string, ToolUsage> = new Map();
|
|
29
|
+
private limits: ToolLimits;
|
|
30
|
+
|
|
31
|
+
constructor(limits: ToolLimits) {
|
|
32
|
+
this.limits = limits;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get category for a tool
|
|
37
|
+
*/
|
|
38
|
+
private getCategory(toolName: string): string {
|
|
39
|
+
const gatheringTools = ['search', 'security_search', 'stackexchange', 'grep'];
|
|
40
|
+
if (gatheringTools.includes(toolName)) {
|
|
41
|
+
return 'gathering';
|
|
42
|
+
}
|
|
43
|
+
return toolName;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record a tool call and enforce limits gracefully
|
|
48
|
+
* Returns true if call is allowed and recorded, false if limit is reached.
|
|
49
|
+
* On block: counter is NOT incremented, tool should return a limit message.
|
|
50
|
+
*/
|
|
51
|
+
recordCall(toolName: string): boolean {
|
|
52
|
+
const category = this.getCategory(toolName);
|
|
53
|
+
const limit = this.limits[category as keyof ToolLimits];
|
|
54
|
+
const usage = this.getUsage(category);
|
|
55
|
+
|
|
56
|
+
// Check limit — if reached, return false without incrementing
|
|
57
|
+
if (limit !== undefined && usage.callCount >= limit) {
|
|
58
|
+
logger.debug(
|
|
59
|
+
`[tool-usage] category=${category} blocked tool=${toolName} (limit=${limit})`
|
|
60
|
+
);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Allowed — increment and log
|
|
65
|
+
usage.callCount++;
|
|
66
|
+
logger.debug(
|
|
67
|
+
`[tool-usage] category=${category} calls=${usage.callCount}/${limit ?? 'unlimited'} tool=${toolName}`
|
|
68
|
+
);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get current call count for a tool
|
|
74
|
+
*/
|
|
75
|
+
getCallCount(toolName: string): number {
|
|
76
|
+
const category = this.getCategory(toolName);
|
|
77
|
+
return this.getUsage(category).callCount;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get limit-reached message for a blocked tool
|
|
82
|
+
*/
|
|
83
|
+
getLimitMessage(toolName: string): string {
|
|
84
|
+
const category = this.getCategory(toolName);
|
|
85
|
+
const usage = this.getUsage(category);
|
|
86
|
+
const limit = usage.limit;
|
|
87
|
+
|
|
88
|
+
if (category === 'scrape') {
|
|
89
|
+
return `SCRAPE PROTOCOL COMPLETE: You have completed all ${limit} scrape calls (handshake + 3 batches). ` +
|
|
90
|
+
`This tool cannot be used again. Proceed immediately to Phase 3: synthesize your findings and submit your report.`;
|
|
91
|
+
}
|
|
92
|
+
return `GATHERING LIMIT REACHED: All ${limit} gathering calls have been used. ` +
|
|
93
|
+
`This tool and all other gathering tools cannot be used again. ` +
|
|
94
|
+
`Proceed to Phase 2: call scrape with your collected URLs now.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get current usage for a category
|
|
99
|
+
*/
|
|
100
|
+
getUsage(category: string): ToolUsage {
|
|
101
|
+
if (!this.usage.has(category)) {
|
|
102
|
+
this.usage.set(category, {
|
|
103
|
+
category,
|
|
104
|
+
callCount: 0,
|
|
105
|
+
limit: this.limits[category as keyof ToolLimits],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return this.usage.get(category)!;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get usage statistics for all categories
|
|
113
|
+
*/
|
|
114
|
+
getStats(): Map<string, ToolUsage> {
|
|
115
|
+
return new Map(this.usage);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reset all usage
|
|
120
|
+
*/
|
|
121
|
+
reset(): void {
|
|
122
|
+
this.usage.clear();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create default tool limits for a researcher
|
|
128
|
+
*/
|
|
129
|
+
export function createDefaultToolLimits(): ToolLimits {
|
|
130
|
+
return {
|
|
131
|
+
gathering: MAX_GATHERING_CALLS,
|
|
132
|
+
scrape: MAX_SCRAPE_CALLS,
|
|
133
|
+
read: undefined,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Research Extension - Dependencies
|
|
3
|
+
*
|
|
4
|
+
* Dependencies are managed by npm via package.json.
|
|
5
|
+
* No custom install/uninstall commands needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getDependencyStatus } from './scrapers.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check all dependencies and return detailed status
|
|
12
|
+
*/
|
|
13
|
+
export function checkAllDependencies(): {
|
|
14
|
+
playwrightAvailable: boolean;
|
|
15
|
+
} {
|
|
16
|
+
const scrapersStatus = getDependencyStatus();
|
|
17
|
+
return {
|
|
18
|
+
playwrightAvailable: scrapersStatus.playwrightAvailable,
|
|
19
|
+
};
|
|
20
|
+
}
|