@lincoln504/pi-research 1.6.9 → 1.6.11
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/README.md +8 -1
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +48 -12
- package/dist/thread-worker.mjs +100 -22
- package/docs/CHANGELOG.md +52 -0
- package/docs/CONFIGURATION.md +7 -2
- package/docs/PI-EXTENSION.md +2 -1
- package/package.json +21 -19
- package/src/config.ts +10 -4
- package/src/core/pi-version.ts +2 -2
- package/src/core/planning-utils.ts +34 -2
- package/src/infrastructure/browser/config.ts +3 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +29 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +116 -19
- package/src/infrastructure/browser/thread-worker.mjs +100 -22
- package/src/infrastructure/browser/worker-pool-manager.ts +10 -1
- package/src/orchestration/deep-research-orchestrator.ts +12 -10
- package/src/research-config.ts +31 -7
- package/src/sdk.ts +38 -11
- package/src/tools/research-knowledge-search.ts +1 -1
- package/src/tui/tui-controller.ts +67 -8
- package/src/web-research/retry-utils.ts +11 -1
package/src/research-config.ts
CHANGED
|
@@ -89,12 +89,34 @@ const ENV_KEY_BY_SETTING_ID: Record<string, string> = {
|
|
|
89
89
|
* Main command handler for /research-config
|
|
90
90
|
*/
|
|
91
91
|
export async function handleResearchConfigCommand(
|
|
92
|
-
|
|
92
|
+
args: string,
|
|
93
93
|
ctx: ExtensionContext,
|
|
94
94
|
pi: ExtensionAPI
|
|
95
95
|
): Promise<void> {
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
const verb = args.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
97
|
+
if (verb === 'health') {
|
|
98
|
+
await runHealthCheckAction(ctx, pi);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (verb === 'knowledge-status' || verb === 'knowledge_status') {
|
|
102
|
+
await showKnowledgeStatusAction(ctx, pi);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (ctx.mode !== 'tui' || !ctx.hasUI) {
|
|
106
|
+
// The interactive menu needs pi's real TUI. In RPC/web-hub/print/json/SDK hosts
|
|
107
|
+
// ctx.hasUI is true (RPC) and the menu cannot render — awaiting it silently
|
|
108
|
+
// nudges the host toward a hang and at best does nothing visible. Give a clear
|
|
109
|
+
// non-interactive answer with the headless diagnostics that DO work here.
|
|
110
|
+
pi.sendMessage({
|
|
111
|
+
customType: 'research-config',
|
|
112
|
+
content: 'The interactive `/research-config` menu requires pi\'s terminal UI (TUI).\n\n' +
|
|
113
|
+
'In this host, use the headless diagnostics instead:\n' +
|
|
114
|
+
'- `/research-config health` — system health status\n' +
|
|
115
|
+
'- `/research-config knowledge-status` — knowledge store status\n\n' +
|
|
116
|
+
'Configuration is read from the environment and config files; set `PI_RESEARCH_*` ' +
|
|
117
|
+
'environment variables to change it.',
|
|
118
|
+
display: true,
|
|
119
|
+
});
|
|
98
120
|
return;
|
|
99
121
|
}
|
|
100
122
|
await showInteractiveMenu(ctx, pi);
|
|
@@ -498,6 +520,7 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
498
520
|
modelChangeConfirmed = !cancelled && await ctx.ui.confirm(
|
|
499
521
|
'Change Embedding Model',
|
|
500
522
|
`Switch the embedding model to ${config.EMBEDDING_MODEL.split('/').pop()}? This permanently clears the current knowledge store.`,
|
|
523
|
+
{ timeout: 60_000 },
|
|
501
524
|
);
|
|
502
525
|
if (modelChangeConfirmed) {
|
|
503
526
|
logger.info(`[research-config] Embedding model changed from ${initialConfig.EMBEDDING_MODEL} to ${config.EMBEDDING_MODEL}. Clearing knowledge store.`);
|
|
@@ -550,7 +573,7 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
550
573
|
await showKnowledgeStatusAction(ctx, pi);
|
|
551
574
|
break;
|
|
552
575
|
case 'knowledge_clear_global': {
|
|
553
|
-
const confirmed = await ctx.ui.confirm('Clear User Store', 'Permanently delete all user-scoped store data?');
|
|
576
|
+
const confirmed = await ctx.ui.confirm('Clear User Store', 'Permanently delete all user-scoped store data?', { timeout: 60_000 });
|
|
554
577
|
if (confirmed) {
|
|
555
578
|
try {
|
|
556
579
|
const service = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
|
|
@@ -563,7 +586,7 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
563
586
|
break;
|
|
564
587
|
}
|
|
565
588
|
case 'knowledge_clear_local': {
|
|
566
|
-
const confirmed = await ctx.ui.confirm('Clear Project Store', 'Permanently delete all project-scoped store data?');
|
|
589
|
+
const confirmed = await ctx.ui.confirm('Clear Project Store', 'Permanently delete all project-scoped store data?', { timeout: 60_000 });
|
|
567
590
|
if (confirmed) {
|
|
568
591
|
try {
|
|
569
592
|
const service = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
|
|
@@ -577,13 +600,13 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
577
600
|
}
|
|
578
601
|
case 'metrics':
|
|
579
602
|
await showMetricsAction(ctx, pi);
|
|
580
|
-
if (await ctx.ui.confirm('Session Metrics', 'Reset all session counters?')) {
|
|
603
|
+
if (await ctx.ui.confirm('Session Metrics', 'Reset all session counters?', { timeout: 60_000 })) {
|
|
581
604
|
metrics.clearSession();
|
|
582
605
|
ctx.ui.notify('Session metrics reset.', 'info');
|
|
583
606
|
}
|
|
584
607
|
break;
|
|
585
608
|
case 'logs_clear': {
|
|
586
|
-
const confirmed = await ctx.ui.confirm('Clear Logs', 'Delete the main diagnostic log file and all archived rotation files?');
|
|
609
|
+
const confirmed = await ctx.ui.confirm('Clear Logs', 'Delete the main diagnostic log file and all archived rotation files?', { timeout: 60_000 });
|
|
587
610
|
if (confirmed) {
|
|
588
611
|
logger.clear();
|
|
589
612
|
ctx.ui.notify('Diagnostic logs cleared.', 'info');
|
|
@@ -704,6 +727,7 @@ async function uninstallSkillAction(ctx: ExtensionContext, pi: ExtensionAPI): Pr
|
|
|
704
727
|
const confirmed = await ctx.ui.confirm(
|
|
705
728
|
'Uninstall Skill',
|
|
706
729
|
`Remove the pi-research skill from ${names}? (Only symlinks this extension created are removed.)`,
|
|
730
|
+
{ timeout: 60_000 },
|
|
707
731
|
);
|
|
708
732
|
if (!confirmed) return;
|
|
709
733
|
try {
|
package/src/sdk.ts
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
registerOrchestrationServices,
|
|
19
19
|
initializeOrchestrationServices
|
|
20
20
|
} from './orchestration/service-initialization.ts';
|
|
21
|
-
import { getService, resetServiceContainer, getServiceContainer } from './core/service-registry.ts';
|
|
21
|
+
import { getService, resetServiceContainer, getServiceContainer, tryGetService } from './core/service-registry.ts';
|
|
22
|
+
import { raceWithDeadline } from './utils/safe-unref.ts';
|
|
22
23
|
import type { ServiceContainer } from './core/service-registry.ts';
|
|
23
24
|
import { ServiceNames } from './core/service-interfaces.ts';
|
|
24
25
|
import type { IKnowledgeStoreService } from './core/interfaces/knowledge-interfaces.ts';
|
|
@@ -45,7 +46,7 @@ import { buildModelRegistry as sharedBuildModelRegistry, resolveModel } from './
|
|
|
45
46
|
import { scrapeSingle } from './web-research/web-scraper.ts';
|
|
46
47
|
import { validateInitialLinks } from './utils/url-utils.ts';
|
|
47
48
|
import { validateUrlForSSRF, disposeSsrfSafeFetcher } from './web-research/scraper-utils.ts';
|
|
48
|
-
import type { ScrapeResult } from './core/interfaces/scheduler-interfaces.ts';
|
|
49
|
+
import type { ScrapeResult, ISchedulerService } from './core/interfaces/scheduler-interfaces.ts';
|
|
49
50
|
import { randomUUID } from 'node:crypto';
|
|
50
51
|
import type { ResearchDepth } from './types/index.ts';
|
|
51
52
|
|
|
@@ -278,11 +279,11 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
278
279
|
globalApiKey = options.apiKey || process.env['PI_RESEARCH_API_KEY'];
|
|
279
280
|
let parsedProvider = options.provider || process.env['PI_RESEARCH_PROVIDER'];
|
|
280
281
|
|
|
281
|
-
// An EXPLICIT model option must govern the entire run —
|
|
282
|
-
// ctx.model
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
282
|
+
// An EXPLICIT model option must govern the entire run — the initial
|
|
283
|
+
// coordinator (via ctx.model, same provider as the parent chat for prompt
|
|
284
|
+
// cache continuity), the evaluator/synthesis rounds, and researchers/
|
|
285
|
+
// synthesis (which resolve through RESEARCH_MODEL). Pin the config to the
|
|
286
|
+
// explicit choice so the run is consistent. No-op when the option was itself
|
|
286
287
|
// seeded from the config (the CLI does that).
|
|
287
288
|
if (options.model) {
|
|
288
289
|
const explicit = typeof options.model === 'string'
|
|
@@ -316,10 +317,12 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
316
317
|
// Resolve the model. Precedence: explicit option (a Model object is used
|
|
317
318
|
// as-is; it need not exist in the registry) → configured RESEARCH_MODEL →
|
|
318
319
|
// provider default → registry fallback. Seeding the resolver with the
|
|
319
|
-
// configured model is what keeps the
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
320
|
+
// configured model is what keeps the evaluator/synthesis rounds and
|
|
321
|
+
// researchers (which read RESEARCH_MODEL themselves) on the same model —
|
|
322
|
+
// without it a configured model would govern only the sub-agents and
|
|
323
|
+
// planning would silently run on the fallback. The initial coordinator
|
|
324
|
+
// (round 1) always uses ctx.model (the parent chat's model) for prompt-
|
|
325
|
+
// cache continuity, regardless of this seeding.
|
|
323
326
|
if (effectiveModel && typeof effectiveModel === 'object' && (effectiveModel as any).id) {
|
|
324
327
|
globalModel = effectiveModel as Model<any>;
|
|
325
328
|
} else {
|
|
@@ -355,6 +358,30 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
355
358
|
isInitialized = true;
|
|
356
359
|
logger.log('[SDK] Research SDK initialized successfully');
|
|
357
360
|
|
|
361
|
+
// Stop the browser scheduler as the FIRST teardown step of a shutdown. This
|
|
362
|
+
// runs on EVERY teardown path — signal handlers (SIGINT/SIGTERM) and the
|
|
363
|
+
// cooperative shutdownResearchSDK() alike — which is intended: without it the
|
|
364
|
+
// sdk_shutdown cleanup list is empty ("No cleanup tasks registered"), and the scheduler's cluster workers died mid-DAG-disposal —
|
|
365
|
+
// with code null — while the worker pool was still live, which the pool's
|
|
366
|
+
// exitHandler counted as unexpected deaths ("3 consecutive exits → pool
|
|
367
|
+
// unhealthy") on a process that was exiting anyway. Shutting down here puts
|
|
368
|
+
// those deaths inside the pool's intentional-destroy window instead. Both
|
|
369
|
+
// teardown paths are idempotent (the later DAG dispose no-ops), and the task
|
|
370
|
+
// is bounded well inside the 15s signal force-exit deadline. Registered per
|
|
371
|
+
// init: runCleanup detaches the task set after running, so re-init gets a
|
|
372
|
+
// fresh registration.
|
|
373
|
+
shutdownManager.register(async () => {
|
|
374
|
+
if (!globalContainer) return;
|
|
375
|
+
const schedulerService = tryGetService<ISchedulerService>(ServiceNames.SCHEDULER, globalContainer);
|
|
376
|
+
const scheduler = schedulerService?.getScheduler?.();
|
|
377
|
+
if (!scheduler) return;
|
|
378
|
+
try {
|
|
379
|
+
await raceWithDeadline(scheduler.shutdown(), 12000);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.error('[SDK] Error stopping scheduler during shutdown cleanup:', err);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
358
385
|
// Register graceful-shutdown signal handlers (SIGINT/SIGTERM).
|
|
359
386
|
// These are a safety net for scripts and long-running embedders —
|
|
360
387
|
// the cooperative shutdownResearchSDK() API is still the primary path.
|
|
@@ -343,7 +343,7 @@ import { safeGetApiKeyAndHeaders } from '../core/llm/model-registry-factory.ts';
|
|
|
343
343
|
|
|
344
344
|
/**
|
|
345
345
|
* Resolve the model to use for the background synthesis LLM.
|
|
346
|
-
* Priority: RESEARCH_MODEL → ctx.model
|
|
346
|
+
* Priority: explicit modelId → RESEARCH_MODEL → ctx.model → fallback
|
|
347
347
|
*/
|
|
348
348
|
function resolveSynthesisModel(ctx: ExtensionContext, config: Config): { model?: Model<any>; error?: string } {
|
|
349
349
|
try {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
11
|
-
import { matchesKey } from '@earendil-works/pi-tui';
|
|
11
|
+
import { isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
|
|
12
12
|
import { getActiveSessionCount, abortAllSessions, refreshAllSessions, hideMasterWidget } from '../orchestration/session-state.ts';
|
|
13
13
|
import { logger } from '../logger.ts';
|
|
14
14
|
|
|
@@ -56,6 +56,26 @@ export function initGlobalTuiController(ui: ExtensionUIContext, piSessionId?: st
|
|
|
56
56
|
|
|
57
57
|
*/
|
|
58
58
|
const handleTerminalInput = (data: string) => {
|
|
59
|
+
// Key-RELEASE events must be ignored. Pi runs the Kitty keyboard protocol
|
|
60
|
+
// with flag 2 (report event types), so every physical key sends TWO
|
|
61
|
+
// sequences: the press, then a release ~100-200ms later
|
|
62
|
+
// (e.g. ctrl+c press \x1b[99;5u, release \x1b[99;5:3u). Tui.handleTerminalInput
|
|
63
|
+
// filters releases — but ONLY for the focused component, AFTER extension
|
|
64
|
+
// input listeners run, and matchesKey cannot tell them apart
|
|
65
|
+
// (matchesKittySequence parses codepoint+modifier but never the event type,
|
|
66
|
+
// so a RELEASE matches matchesKey(data, 'ctrl+c') just like a press).
|
|
67
|
+
// Observed live 2026-09-03: one physical Ctrl+C with text in the editor
|
|
68
|
+
// logged "with editor text — clearing only" and then, 150ms later, the
|
|
69
|
+
// RELEASE event matched ctrl+c, saw the editor pi had just cleared, and
|
|
70
|
+
// aborted the research run. The same double-fire misclassified old
|
|
71
|
+
// ctrl+c releases as "Esc" aborts in the logs. pi's own components never
|
|
72
|
+
// see releases (tui filters them), so dropping them here matches pi's
|
|
73
|
+
// semantics exactly. Key REPEATS (\x1b[99;5:2u) are genuine held-key intent
|
|
74
|
+
// and keep flowing, like they do to pi's own components.
|
|
75
|
+
if (isKeyRelease(data)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
// If an interactive TUI is active (like the config menu),
|
|
60
80
|
// let it handle ALL input. We don't want to trigger a global abort
|
|
61
81
|
// while the user is navigating a menu.
|
|
@@ -66,12 +86,54 @@ export function initGlobalTuiController(ui: ExtensionUIContext, piSessionId?: st
|
|
|
66
86
|
// Check for specific global cancel keys (Escape and Ctrl+C)
|
|
67
87
|
// We only trigger abort if research is actually running.
|
|
68
88
|
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) {
|
|
89
|
+
if (matchesKey(data, 'ctrl+c')) {
|
|
90
|
+
// Ctrl+C is pi's app.clear ("Clear editor"), NOT its cancel key — Esc is
|
|
91
|
+
// app.interrupt. But pi's handleCtrlC() is TIME-based, not content-based:
|
|
92
|
+
// first press clears the editor and arms a window, and a SECOND press
|
|
93
|
+
// within 500ms calls shutdown() — exiting the entire pi process. There is
|
|
94
|
+
// no "abort research" step anywhere in it. Extension input listeners run
|
|
95
|
+
// BEFORE the focused editor and may consume keys, so this handler fully
|
|
96
|
+
// owns the Ctrl+C policy while a run is active:
|
|
97
|
+
//
|
|
98
|
+
// editor has text → fall through: pi clears the text, run continues
|
|
99
|
+
// (a follow-up press sees an empty editor → abort
|
|
100
|
+
// branch below — the two-step cancel the user asked
|
|
101
|
+
// for: clear first, second press aborts the run)
|
|
102
|
+
// editor empty AND → abort the run AND CONSUME the key. Consuming is
|
|
103
|
+
// run active the fix: without it the abort fell through to pi's
|
|
104
|
+
// handleCtrlC, and a double-press within 500ms (the
|
|
105
|
+
// natural way to "abort now") shut down pi itself.
|
|
106
|
+
// editor empty AND → fall through untouched: pi's native behavior
|
|
107
|
+
// no run active (clear/arm, double-press exit) must keep working.
|
|
108
|
+
let editorText = '';
|
|
109
|
+
try {
|
|
110
|
+
editorText = typeof ui.getEditorText === 'function' ? (ui.getEditorText() ?? '') : '';
|
|
111
|
+
} catch { /* editor gone mid-run — treat as empty (cancel) */ }
|
|
112
|
+
if (editorText.length > 0) {
|
|
113
|
+
logger.debug('[TUI] Ctrl+C with editor text — clearing only, research keeps running.');
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const activeCount = getActiveSessionCount();
|
|
117
|
+
if (activeCount > 0) {
|
|
118
|
+
logger.info('[TUI] Ctrl+C with empty editor — aborting active research (key consumed so pi\'s double-press exit cannot fire).');
|
|
119
|
+
try {
|
|
120
|
+
// Scope to this Pi session to avoid cross-session interference
|
|
121
|
+
abortAllSessions(state.piSessionId);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger.error('[TUI] Failed to abort sessions on Ctrl+C:', err);
|
|
124
|
+
}
|
|
125
|
+
return { consume: true };
|
|
126
|
+
}
|
|
127
|
+
// No research running — native pi Ctrl+C (clear editor / double-press exit).
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Esc (pi's app.interrupt): panic-cancel regardless of editor text. Abort the
|
|
132
|
+
// active run, then let the key fall through so Pi can also respond
|
|
133
|
+
// (cancelling its own agent loop) and other TUIs can react.
|
|
69
134
|
const activeCount = getActiveSessionCount();
|
|
70
135
|
if (activeCount > 0) {
|
|
71
|
-
|
|
72
|
-
// instead of clearAllSessionState() which nukes ALL sessions across ALL Pi instances.
|
|
73
|
-
logger.info(`[TUI] Global cancel requested (${data === '\x03' ? 'Ctrl+C' : 'Esc'}). Aborting active sessions...`);
|
|
74
|
-
|
|
136
|
+
logger.info('[TUI] Global cancel requested (Esc). Aborting active sessions...');
|
|
75
137
|
try {
|
|
76
138
|
// Scope to this Pi session to avoid cross-session interference
|
|
77
139
|
abortAllSessions(state.piSessionId);
|
|
@@ -79,9 +141,6 @@ export function initGlobalTuiController(ui: ExtensionUIContext, piSessionId?: st
|
|
|
79
141
|
logger.error('[TUI] Failed to abort sessions on cancel:', err);
|
|
80
142
|
}
|
|
81
143
|
}
|
|
82
|
-
|
|
83
|
-
// Let the key fall through so Pi can handle its own cancellation
|
|
84
|
-
// or other TUIs can respond.
|
|
85
144
|
return undefined;
|
|
86
145
|
}
|
|
87
146
|
|
|
@@ -205,11 +205,21 @@ function messageIsTransient(message: string): boolean {
|
|
|
205
205
|
// here on purpose — the generic classifier keeps "timeout"/"etimedout"; the LLM
|
|
206
206
|
// wrapper's "cancelled or timed out" wording is handled by the planning-service
|
|
207
207
|
// classifier, so a security/youtube caller's abort string isn't reinterpreted.
|
|
208
|
+
//
|
|
209
|
+
// "connection error" is the OpenAI SDK's APIConnectionError default message (the
|
|
210
|
+
// shape pi-ai surfaces for openai-completions providers like OpenRouter when the
|
|
211
|
+
// fetch itself fails before any response — refused / reset / DNS / TLS). The SDK
|
|
212
|
+
// only throws this class for fetch-level transport failures (its own 2 internal
|
|
213
|
+
// retries already exhausted), so the string is a reliable transient signal. Seen
|
|
214
|
+
// live 2026-09-02: OpenRouter planning call died in 4s with zero retries because
|
|
215
|
+
// this pattern was missing — and the same miss made isDegradableLlmError throw
|
|
216
|
+
// instead of degrading to the fallback plan. Both layers gate on this matcher.
|
|
208
217
|
if (
|
|
209
218
|
m.includes('econnrefused') || m.includes('enotfound') || m.includes('timeout') ||
|
|
210
219
|
m.includes('etimedout') || m.includes('econnreset') || m.includes('fetch failed') ||
|
|
211
220
|
m.includes('terminated') || m.includes('other side closed') ||
|
|
212
|
-
m.includes('socket hang up') || m.includes('und_err')
|
|
221
|
+
m.includes('socket hang up') || m.includes('und_err') ||
|
|
222
|
+
m.includes('connection error')
|
|
213
223
|
) {
|
|
214
224
|
return true;
|
|
215
225
|
}
|