@lincoln504/pi-research 1.0.4 → 1.0.6
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 +1 -1
- package/dist/cli.mjs +207 -40
- package/dist/prompts/researcher.md +1 -1
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/CONFIGURATION.md +1 -1
- package/docs/KNOWLEDGE-STORE.md +5 -3
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +27 -15
- package/src/knowledge/store.ts +17 -7
- package/src/orchestration/deep-research-orchestrator.ts +37 -26
- package/src/orchestration/headless-observer.ts +63 -0
- package/src/orchestration/quick-research-orchestrator.ts +5 -4
- package/src/orchestration/research-synthesis-service.ts +11 -3
- package/src/prompts/researcher.md +1 -1
- package/src/sdk.ts +7 -1
- package/src/security/cisa-kev.ts +3 -0
- package/src/security/github-advisories.ts +5 -0
- package/src/security/nvd-client.ts +3 -0
- package/src/security/osv-client.ts +3 -0
- package/src/tools/youtube-transcript.ts +6 -2
- package/src/utils/shared-links.ts +26 -0
- package/src/web-research/scraper-utils.ts +10 -3
- package/src/web-research/web-scraper.ts +95 -4
- package/src/youtube/index.ts +7 -3
- package/src/youtube/transcript-client.ts +2 -0
package/src/cli.ts
CHANGED
|
@@ -259,7 +259,7 @@ export interface CredentialDetection {
|
|
|
259
259
|
apiKeyConfigured: boolean;
|
|
260
260
|
provider?: string;
|
|
261
261
|
model?: string;
|
|
262
|
-
modelFrom: 'PI_RESEARCH_MODEL' | 'config.env' | 'unset';
|
|
262
|
+
modelFrom: '--model' | 'PI_RESEARCH_MODEL' | 'config.env' | 'unset';
|
|
263
263
|
piAuthPresent: boolean;
|
|
264
264
|
piModelsPresent: boolean;
|
|
265
265
|
/** A short, human-readable problem string, or undefined when ready. */
|
|
@@ -269,8 +269,17 @@ export interface CredentialDetection {
|
|
|
269
269
|
/**
|
|
270
270
|
* Detect how the SDK will authenticate WITHOUT initializing it. Used both for
|
|
271
271
|
* the `status` command and for the fast-fail pre-flight before every run.
|
|
272
|
+
*
|
|
273
|
+
* `explicitModel` is a per-run `--model` flag. It must participate in the
|
|
274
|
+
* pre-flight: it outranks PI_RESEARCH_MODEL/config for the run, so (a) it
|
|
275
|
+
* satisfies the model-required check on its own, and (b) when no explicit
|
|
276
|
+
* PI_RESEARCH_PROVIDER is set, provider-aware key detection derives the
|
|
277
|
+
* provider from the FLAG's model — otherwise a configured model on provider X
|
|
278
|
+
* greenlights a `--model` run on keyless provider Y (or vice versa wrongly
|
|
279
|
+
* blocks a valid `--model` run). An explicit PI_RESEARCH_PROVIDER still wins:
|
|
280
|
+
* it names the provider the configured key belongs to.
|
|
272
281
|
*/
|
|
273
|
-
function detectCredentials(): CredentialDetection {
|
|
282
|
+
function detectCredentials(explicitModel?: string): CredentialDetection {
|
|
274
283
|
const paths = resolvedConfigPaths();
|
|
275
284
|
const piAuthPresent = fileExists(paths.piAuth);
|
|
276
285
|
const piModelsPresent = fileExists(paths.piModels);
|
|
@@ -280,8 +289,8 @@ function detectCredentials(): CredentialDetection {
|
|
|
280
289
|
const modelEnv = process.env['PI_RESEARCH_MODEL'];
|
|
281
290
|
const modelConfig = getConfig(process.cwd(), 'cli').RESEARCH_MODEL;
|
|
282
291
|
|
|
283
|
-
const model = modelEnv ?? modelConfig;
|
|
284
|
-
const modelFrom = modelEnv ? 'PI_RESEARCH_MODEL' : modelConfig ? 'config.env' : 'unset';
|
|
292
|
+
const model = explicitModel ?? modelEnv ?? modelConfig;
|
|
293
|
+
const modelFrom = explicitModel ? '--model' : modelEnv ? 'PI_RESEARCH_MODEL' : modelConfig ? 'config.env' : 'unset';
|
|
285
294
|
// Provider is either explicit, or inferred from a "provider/id" model string.
|
|
286
295
|
const provider =
|
|
287
296
|
providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
|
|
@@ -316,7 +325,7 @@ function detectCredentials(): CredentialDetection {
|
|
|
316
325
|
piModelsPresent,
|
|
317
326
|
problem:
|
|
318
327
|
'PI_RESEARCH_API_KEY is set but no model is configured. Set PI_RESEARCH_MODEL to a ' +
|
|
319
|
-
'"provider/model-id" (e.g. openai/gpt-4o).',
|
|
328
|
+
'"provider/model-id" (e.g. openai/gpt-4o), or pass --model <provider/id> for a single run.',
|
|
320
329
|
};
|
|
321
330
|
}
|
|
322
331
|
return {
|
|
@@ -350,7 +359,8 @@ function detectCredentials(): CredentialDetection {
|
|
|
350
359
|
'pi credentials were found (in pi\'s configuration), but no research model is configured. ' +
|
|
351
360
|
'The standalone CLI / agent skill run only on an explicitly configured model — they do not ' +
|
|
352
361
|
'follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a ' +
|
|
353
|
-
'"provider/model-id" (in the environment, or in config.env / cli.env below)
|
|
362
|
+
'"provider/model-id" (in the environment, or in config.env / cli.env below), or pass ' +
|
|
363
|
+
'--model <provider/id> for a single run.',
|
|
354
364
|
};
|
|
355
365
|
}
|
|
356
366
|
return {
|
|
@@ -401,8 +411,8 @@ function configBlock(det: CredentialDetection, extraNote?: string): string {
|
|
|
401
411
|
lines.push(` • api key configured: ${det.apiKeyConfigured}`);
|
|
402
412
|
lines.push(` • provider: ${det.provider ?? '(unset — inferred from a provider/model-id model)'}`);
|
|
403
413
|
lines.push(` • model: ${det.model ? `${det.model} [from: ${det.modelFrom}]` : '(not set — set PI_RESEARCH_MODEL, required)'}`);
|
|
404
|
-
lines.push(` • pi auth.json:
|
|
405
|
-
lines.push(` • pi models.json:
|
|
414
|
+
lines.push(` • pi config (auth.json): ${det.piAuthPresent ? 'present' : 'absent'}`);
|
|
415
|
+
lines.push(` • pi config (models.json): ${det.piModelsPresent ? 'present' : 'absent'}`);
|
|
406
416
|
if (extraNote) lines.push(` • note: ${extraNote}`);
|
|
407
417
|
return lines.join('\n');
|
|
408
418
|
}
|
|
@@ -425,7 +435,9 @@ interface ResearchArgs {
|
|
|
425
435
|
* markdown report to stdout, and always shuts down (even on failure).
|
|
426
436
|
*/
|
|
427
437
|
async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
428
|
-
|
|
438
|
+
// Pre-flight with the per-run --model flag folded in: it satisfies the
|
|
439
|
+
// model-required check and steers the provider-aware key detection.
|
|
440
|
+
const det = detectCredentials(args.model);
|
|
429
441
|
if (det.problem) {
|
|
430
442
|
if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
|
|
431
443
|
else toStderr(`\nError: ${det.problem}\n\n${configBlock(det)}\n`);
|
|
@@ -447,8 +459,8 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
447
459
|
|
|
448
460
|
const depth: 0 | 1 | 2 | 3 =
|
|
449
461
|
(args.depth ?? getConfig(process.cwd(), 'cli').DEFAULT_RESEARCH_DEPTH) as 0 | 1 | 2 | 3;
|
|
450
|
-
//
|
|
451
|
-
const runModel =
|
|
462
|
+
// det.model already reflects --model-first precedence (detectCredentials above).
|
|
463
|
+
const runModel = det.model;
|
|
452
464
|
toStderr(`[pi-research] starting research (depth ${depth})${runModel ? ` with ${runModel}` : ''}…\n`);
|
|
453
465
|
|
|
454
466
|
// Stream concise lifecycle progress to stderr so a long run is never silent.
|
|
@@ -877,7 +889,7 @@ USAGE
|
|
|
877
889
|
COMMANDS
|
|
878
890
|
research "<query>" Run multi-agent web research (default depth: 1).
|
|
879
891
|
--depth <0-3> 0 = quick, 1 = normal (default), 2 = deep, 3 = ultra.
|
|
880
|
-
--model <provider/id>
|
|
892
|
+
--model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
|
|
881
893
|
--exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
|
|
882
894
|
--initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
|
|
883
895
|
--config <path> Use this config file instead of the base config.env.
|
|
@@ -914,9 +926,9 @@ CONFIGURE
|
|
|
914
926
|
pi config (keys): ${p.piAuth}
|
|
915
927
|
pi config (models): ${p.piModels}
|
|
916
928
|
|
|
917
|
-
A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id"
|
|
918
|
-
the agent skill run only on that configured
|
|
919
|
-
selected inside the pi extension.
|
|
929
|
+
A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id" (or pass
|
|
930
|
+
--model per run). This CLI and the agent skill run only on that configured
|
|
931
|
+
model — they do not follow the model selected inside the pi extension.
|
|
920
932
|
|
|
921
933
|
The pi extension has its own optional overlay file:
|
|
922
934
|
pi extension: ${p.piIfaceEnv}
|
package/src/knowledge/store.ts
CHANGED
|
@@ -267,10 +267,12 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
267
267
|
if (strategy !== 'drop' && strategy !== 'backup') {
|
|
268
268
|
logger.warn('[store] Falling back to backup strategy after migration failure');
|
|
269
269
|
await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'backup');
|
|
270
|
-
} else if (strategy === 'backup') {
|
|
271
|
-
logger.warn('[store] Falling back to drop strategy after backup failure');
|
|
272
|
-
await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'drop');
|
|
273
270
|
} else {
|
|
271
|
+
// 'backup' failure must ABORT, never escalate to 'drop': backup is the
|
|
272
|
+
// data-preserving strategy, and silently destroying every row after a
|
|
273
|
+
// (possibly transient) failure is the opposite of what the user asked
|
|
274
|
+
// for. The store stays on the old model; the next open() retries.
|
|
275
|
+
// 'drop' failure has nowhere further to fall.
|
|
274
276
|
throw new Error(errorMsg, { cause: err });
|
|
275
277
|
}
|
|
276
278
|
}
|
|
@@ -332,8 +334,16 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
332
334
|
throw new Error('Table not connected');
|
|
333
335
|
}
|
|
334
336
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
+
// Best-effort: the count is only used for logging and the result summary, so a
|
|
338
|
+
// transient countRows() failure must not abort the backup (previously it threw
|
|
339
|
+
// while the fallback ladder could still escalate to a data-destroying drop).
|
|
340
|
+
let count = -1;
|
|
341
|
+
try {
|
|
342
|
+
count = await this.table.countRows();
|
|
343
|
+
} catch (err) {
|
|
344
|
+
logger.warn(`[store] countRows failed before backup (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
337
347
|
// Close current table handle
|
|
338
348
|
this.table = null;
|
|
339
349
|
|
|
@@ -355,9 +365,9 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
355
365
|
this.table = await this.createTable();
|
|
356
366
|
// Table name unchanged (still canonical 'knowledge'), but persist to be safe.
|
|
357
367
|
await this.saveManifest();
|
|
358
|
-
logger.info(`[store] Migration complete: ${count} documents backed up, fresh table created with model ${newModel}`);
|
|
368
|
+
logger.info(`[store] Migration complete: ${count >= 0 ? count : 'unknown count of'} documents backed up, fresh table created with model ${newModel}`);
|
|
359
369
|
|
|
360
|
-
return { strategy: 'backup', success: true, documentsProcessed: count };
|
|
370
|
+
return { strategy: 'backup', success: true, documentsProcessed: Math.max(0, count) };
|
|
361
371
|
}
|
|
362
372
|
|
|
363
373
|
private async migrationDrop(_oldModel: string, newModel: string): Promise<MigrationResult> {
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import type {
|
|
18
18
|
ResearchObserver,
|
|
19
19
|
} from '../core/interfaces/observer-interfaces.ts';
|
|
20
|
-
import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
20
|
+
import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
21
21
|
import {
|
|
22
22
|
ServiceNames,
|
|
23
23
|
type IResearchOrchestration,
|
|
@@ -61,11 +61,12 @@ export class DeepResearchOrchestrator {
|
|
|
61
61
|
constructor(private options: DeepResearchOrchestratorOptions) {
|
|
62
62
|
this.config = options.config || getConfig(options.ctx.cwd);
|
|
63
63
|
|
|
64
|
-
// Resolve observer: if options were provided instead of an instance, create the instance
|
|
64
|
+
// Resolve observer: if options were provided instead of an instance, create the instance.
|
|
65
|
+
// Either way, wrap it so a throwing observer callback can never fail the run.
|
|
65
66
|
if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
|
|
66
|
-
this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
|
|
67
|
+
this.observer = makeSafeObserver(new HeadlessObserver(options.observer as HeadlessObserverOptions));
|
|
67
68
|
} else {
|
|
68
|
-
this.observer = options.observer as ResearchObserver
|
|
69
|
+
this.observer = options.observer ? makeSafeObserver(options.observer as ResearchObserver) : undefined;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
if (options.orchestrationService) {
|
|
@@ -478,30 +479,40 @@ export class DeepResearchOrchestrator {
|
|
|
478
479
|
}
|
|
479
480
|
|
|
480
481
|
let result = finalReport.content || '';
|
|
481
|
-
|
|
482
|
-
//
|
|
482
|
+
|
|
483
|
+
// ZERO reports collected AND researchers actually failed — the run attempted
|
|
484
|
+
// research and produced nothing groundable. This gate is deliberately
|
|
485
|
+
// independent of the synthesis text: with the default single-researcher plan
|
|
486
|
+
// the fast-stop threshold (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone
|
|
487
|
+
// researcher that exhausts its retries (e.g. a provider rate limit) reaches
|
|
488
|
+
// synthesis with no source material. Whether the LLM then returns an empty
|
|
489
|
+
// string OR chatty refusal prose ("no information was found"), that is a
|
|
490
|
+
// FAILED run: throw, and surface the recorded root causes so the user sees
|
|
491
|
+
// the 429/model error, not a shrug (and never a source-free report shipped
|
|
492
|
+
// as success). The failed-researcher condition keeps the designed
|
|
493
|
+
// immediate-synthesis path intact: a coordinator that chose to answer
|
|
494
|
+
// directly (no delegation, no failures) is not a failed run.
|
|
495
|
+
const synthesisCheckService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
496
|
+
const hasReports = synthesisCheckService.hasReports(researchId);
|
|
497
|
+
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
498
|
+
if (!hasReports && failedIds.length > 0) {
|
|
499
|
+
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
500
|
+
const causes = failedIds
|
|
501
|
+
.map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
|
|
502
|
+
.join('; ');
|
|
503
|
+
throw new Error(
|
|
504
|
+
`Research produced no report — no source material was collected. Causes: ${causes}`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
483
508
|
if (!result.trim()) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${
|
|
487
|
-
result =
|
|
509
|
+
if (hasReports) {
|
|
510
|
+
// Content is empty but reports exist — build a fallback synthesis.
|
|
511
|
+
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
|
|
512
|
+
result = synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound);
|
|
488
513
|
} else {
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
// (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone researcher that
|
|
492
|
-
// exhausts its retries (e.g. a provider rate limit) used to land here
|
|
493
|
-
// and return a hollow "no summary was generated" string on the SUCCESS
|
|
494
|
-
// path — exit 0. That is a FAILED run: throw, and surface the recorded
|
|
495
|
-
// root causes so the user sees the 429/model error, not a shrug.
|
|
496
|
-
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
497
|
-
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
498
|
-
const causes = failedIds
|
|
499
|
-
.map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
|
|
500
|
-
.join('; ');
|
|
501
|
-
throw new Error(
|
|
502
|
-
'Research produced no report — no source material was collected' +
|
|
503
|
-
(causes ? `. Causes: ${causes}` : '.')
|
|
504
|
-
);
|
|
514
|
+
// No reports, no recorded failures, and nothing to ship either.
|
|
515
|
+
throw new Error('Research produced no report — no source material was collected.');
|
|
505
516
|
}
|
|
506
517
|
}
|
|
507
518
|
|
|
@@ -12,6 +12,69 @@ import type { ResearchPlan } from '../core/interfaces/research-plan-types.ts';
|
|
|
12
12
|
|
|
13
13
|
export type { HeadlessObserverOptions };
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Wrap an observer so a throwing (or rejecting) callback can never corrupt a
|
|
17
|
+
* research run. Observer methods are fire-and-forget notifications, but the
|
|
18
|
+
* orchestrators invoke them inline at load-bearing points (researcher
|
|
19
|
+
* completion, evaluation decisions, synthesis start) — an exception from
|
|
20
|
+
* user-supplied SDK/TUI observer code would otherwise surface as a *research*
|
|
21
|
+
* failure. HeadlessObserver already isolates its own onProgress callback; this
|
|
22
|
+
* extends the same isolation to raw ResearchObserver implementations. Sync
|
|
23
|
+
* throws are logged and swallowed; returned promises get a .catch so
|
|
24
|
+
* rejections never become unhandled.
|
|
25
|
+
*/
|
|
26
|
+
export function makeSafeObserver<T extends object>(observer: T): T {
|
|
27
|
+
// Proxy a FRESH empty object and close over the real observer instead of
|
|
28
|
+
// proxying the observer directly: a `get` trap on the observer itself may
|
|
29
|
+
// not substitute a wrapper for a non-writable non-configurable own data
|
|
30
|
+
// property (Proxy invariant), so a frozen observer — a common defensive
|
|
31
|
+
// pattern for SDK callback bags — would make the engine throw TypeError at
|
|
32
|
+
// the property ACCESS, outside every try/catch. An empty target has no own
|
|
33
|
+
// properties, so no invariant can ever bind the trap.
|
|
34
|
+
return new Proxy({} as T, {
|
|
35
|
+
get(_target, prop) {
|
|
36
|
+
// Guard the ACCESS too, and resolve getters against the observer (not
|
|
37
|
+
// the proxy): an observer exposing a method via an accessor that touches
|
|
38
|
+
// a private field would otherwise throw at `observer?.onX?.(...)`
|
|
39
|
+
// property access — outside the call-site try/catch below — which is
|
|
40
|
+
// exactly the failure this wrapper exists to absorb.
|
|
41
|
+
let value: unknown;
|
|
42
|
+
try {
|
|
43
|
+
value = Reflect.get(observer, prop, observer);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
logger.warn(`[observer] Ignored error reading observer.${String(prop)}:`, err);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value !== 'function') return value;
|
|
49
|
+
return (...args: unknown[]) => {
|
|
50
|
+
try {
|
|
51
|
+
const out = value.apply(observer, args);
|
|
52
|
+
if (out && typeof (out as Promise<unknown>).then === 'function') {
|
|
53
|
+
return (out as Promise<unknown>).catch((err: unknown) => {
|
|
54
|
+
logger.warn(`[observer] Ignored rejection from observer.${String(prop)}:`, err);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logger.warn(`[observer] Ignored error from observer.${String(prop)}:`, err);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
has(_target, prop) {
|
|
65
|
+
try {
|
|
66
|
+
return Reflect.has(observer, prop);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
// Keep `instanceof` transparent (legal on an extensible target).
|
|
72
|
+
getPrototypeOf() {
|
|
73
|
+
return Reflect.getPrototypeOf(observer);
|
|
74
|
+
},
|
|
75
|
+
}) as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
15
78
|
export class HeadlessObserver implements ResearchObserver {
|
|
16
79
|
constructor(private options: HeadlessObserverOptions = {}) {}
|
|
17
80
|
|
|
@@ -20,7 +20,7 @@ import { createResearcherSession } from './researcher.ts';
|
|
|
20
20
|
import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
|
|
21
21
|
import { getMaxScrapeBatches } from '../constants.ts';
|
|
22
22
|
import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
|
|
23
|
-
import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
23
|
+
import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
24
24
|
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
25
25
|
import { ServiceNames, type IWriterQueue, type IKnowledgeStoreService, type IResearchSynthesisService, type IResearchOrchestration } from '../core/service-interfaces.ts';
|
|
26
26
|
import type { ResearchSessionService } from './research-session-service.ts';
|
|
@@ -51,11 +51,12 @@ export class QuickResearchOrchestrator {
|
|
|
51
51
|
constructor(private options: QuickResearchOrchestratorOptions) {
|
|
52
52
|
this.config = options.config || getConfig(options.ctx.cwd);
|
|
53
53
|
|
|
54
|
-
// Resolve observer: if options were provided instead of an instance, create the instance
|
|
54
|
+
// Resolve observer: if options were provided instead of an instance, create the instance.
|
|
55
|
+
// Either way, wrap it so a throwing observer callback can never fail the run.
|
|
55
56
|
if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
|
|
56
|
-
this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
|
|
57
|
+
this.observer = makeSafeObserver(new HeadlessObserver(options.observer as HeadlessObserverOptions));
|
|
57
58
|
} else {
|
|
58
|
-
this.observer = options.observer as ResearchObserver
|
|
59
|
+
this.observer = options.observer ? makeSafeObserver(options.observer as ResearchObserver) : undefined;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
13
13
|
import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
14
|
import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
|
|
15
|
-
import { getScrapedLinks, normalizeUrl } from '../utils/shared-links.ts';
|
|
15
|
+
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
16
|
import { logger } from '../logger.ts';
|
|
17
17
|
import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
|
|
18
18
|
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
@@ -188,9 +188,17 @@ export class ResearchSynthesisService implements IService {
|
|
|
188
188
|
if (globalCitations.length === 0) {
|
|
189
189
|
const scraped = getScrapedLinks(sessionId);
|
|
190
190
|
if (scraped.length > 0) {
|
|
191
|
-
|
|
191
|
+
// Label per actual provenance: the pool also contains transcript watch
|
|
192
|
+
// URLs the youtube_transcript tool registered — calling those "Scrape"
|
|
193
|
+
// misattributes the report's real source material.
|
|
194
|
+
globalCitations = scraped.map((url, i) => ({
|
|
195
|
+
id: i + 1,
|
|
196
|
+
url,
|
|
197
|
+
description: '',
|
|
198
|
+
source: isTranscribedLink(sessionId, url) ? 'YouTube Transcript' : 'Scrape',
|
|
199
|
+
}));
|
|
192
200
|
logger.warn(
|
|
193
|
-
`[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape provenance.`,
|
|
201
|
+
`[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`,
|
|
194
202
|
);
|
|
195
203
|
}
|
|
196
204
|
}
|
|
@@ -58,7 +58,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
|
|
|
58
58
|
## Guidelines
|
|
59
59
|
|
|
60
60
|
- **Available Tools**:
|
|
61
|
-
- `scrape`: Fetch and read web pages (primary tool). Focus your energy here.
|
|
61
|
+
- `scrape`: Fetch and read web pages (primary tool). Focus your energy here. Do NOT scrape YouTube video links (watch / youtu.be / shorts) — a watch page returns YouTube's app shell, not the video's content; read videos with `youtube_transcript`. Scraping a YouTube channel or playlist page to DISCOVER video links is fine.
|
|
62
62
|
- `youtube_transcript`: Read the captions/transcript of YouTube videos from your source list — it is the `scrape` for video. ONE call only, batching up to a few of the most relevant YouTube links. Cite these as `Source: YouTube Transcript`.
|
|
63
63
|
- `stackexchange`: Use ONLY for Stack Overflow or Stack Exchange URLs.
|
|
64
64
|
- `security_search`: Query NVD, CVE, OSV, CISA databases.
|
package/src/sdk.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type {
|
|
|
30
30
|
import type { Model } from '@earendil-works/pi-ai';
|
|
31
31
|
import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
32
32
|
import { logger, createLogger, setLogger } from './logger.ts';
|
|
33
|
-
import { getConfig, createConfig, validateConfig, type Config } from './config.ts';
|
|
33
|
+
import { getConfig, createConfig, validateConfig, resetConfig, type Config } from './config.ts';
|
|
34
34
|
import { metrics, MetricsRegistry, runWithRunRegistry } from './utils/metrics.ts';
|
|
35
35
|
import type { IMetricsSnapshot, RunSummary } from './utils/metrics.ts';
|
|
36
36
|
import { extractRunStats, type ResearchStats } from './utils/metrics-summary.ts';
|
|
@@ -833,6 +833,12 @@ async function _doShutdown(): Promise<void> {
|
|
|
833
833
|
_lastResearchId = null;
|
|
834
834
|
_lastRunSummary = null;
|
|
835
835
|
_lastErrorReport = null;
|
|
836
|
+
// The module-level config cache survives the globals above. Without this, a
|
|
837
|
+
// shutdown → env/config-file change → re-init cycle in one process silently
|
|
838
|
+
// reuses the first init's config. (The CLI/skill are unaffected — they pass
|
|
839
|
+
// ignoreGlobalConfig and their own config — but the SDK contract is that a
|
|
840
|
+
// fresh init reads fresh config.)
|
|
841
|
+
resetConfig();
|
|
836
842
|
|
|
837
843
|
// Remove signal handlers — caller is shutting down cooperatively.
|
|
838
844
|
_removeSignalHandlers();
|
package/src/security/cisa-kev.ts
CHANGED
|
@@ -122,6 +122,9 @@ async function fetchWithRetryImpl(url: string, options: RequestInit, signal?: Ab
|
|
|
122
122
|
async () => {
|
|
123
123
|
const response = await fetch(url, options);
|
|
124
124
|
if (!response.ok) {
|
|
125
|
+
// Drain before throwing: an unconsumed error body pins its socket until
|
|
126
|
+
// GC, and this path runs on every retry of a failed sweep.
|
|
127
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
125
128
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as Error & { status?: number };
|
|
126
129
|
error.status = response.status;
|
|
127
130
|
metrics.increment('cisa_kev_errors_total', 1, { endpoint, status_code: response.status.toString() });
|
|
@@ -116,6 +116,9 @@ export async function searchGitHubAdvisories(
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
if (!resp.ok) {
|
|
119
|
+
// Drain before throwing: an unconsumed error body pins its socket until
|
|
120
|
+
// GC, and the 403/5xx branches below are retried with backoff.
|
|
121
|
+
void resp.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
119
122
|
if (resp.status === 404) {
|
|
120
123
|
throw new Error(`Repository "${owner}/${name}" not found or no access to security advisories.`);
|
|
121
124
|
}
|
|
@@ -206,6 +209,8 @@ export async function searchGitHubAdvisories(
|
|
|
206
209
|
});
|
|
207
210
|
|
|
208
211
|
if (!resp.ok) {
|
|
212
|
+
// Same drain-before-throw as the repo-advisories fetch above.
|
|
213
|
+
void resp.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
209
214
|
if (resp.status === 404) {
|
|
210
215
|
throw new Error('Advisory not found (HTTP 404)');
|
|
211
216
|
}
|
|
@@ -327,6 +327,9 @@ function handleFetchError(error: unknown): never {
|
|
|
327
327
|
|
|
328
328
|
function handleResponseStatus(response: Response): void {
|
|
329
329
|
if (!response.ok) {
|
|
330
|
+
// Drain before throwing: an unconsumed error body pins its socket until GC,
|
|
331
|
+
// and this path runs on every retry of a rate-limited/5xx sweep.
|
|
332
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
330
333
|
if (response.status === 429) {
|
|
331
334
|
throw new Error('NVD API rate limit exceeded (HTTP 429). Retrying with backoff...');
|
|
332
335
|
}
|
|
@@ -44,6 +44,9 @@ async function fetchWithRetry(
|
|
|
44
44
|
async () => {
|
|
45
45
|
const response = await fetch(url, options);
|
|
46
46
|
if (!response.ok) {
|
|
47
|
+
// Drain before throwing: an unconsumed error body pins its socket until
|
|
48
|
+
// GC, and this path runs on every retry of a rate-limited/5xx sweep.
|
|
49
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
47
50
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as Error & { status?: number };
|
|
48
51
|
error.status = response.status;
|
|
49
52
|
metrics.increment('osv_errors_total', 1, { endpoint, status_code: response.status.toString() });
|
|
@@ -20,7 +20,7 @@ import { Value } from 'typebox/value';
|
|
|
20
20
|
import { youtubeTranscriptCommand } from '../youtube/index.ts';
|
|
21
21
|
import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
22
22
|
import type { SystemResearchState } from '../orchestration/deep-research-types.ts';
|
|
23
|
-
import { cacheScrapedContent, registerScrapedLinks, registerResearcherScrapes } from '../utils/shared-links.ts';
|
|
23
|
+
import { cacheScrapedContent, registerScrapedLinks, registerTranscribedLinks, registerResearcherScrapes } from '../utils/shared-links.ts';
|
|
24
24
|
import { type Config, getConfig } from '../config.ts';
|
|
25
25
|
import { logger } from '../logger.ts';
|
|
26
26
|
import { metrics } from '../utils/metrics.ts';
|
|
@@ -64,7 +64,7 @@ export function createYoutubeTranscriptTool(options: {
|
|
|
64
64
|
'You may call this tool only ONCE, so choose the videos carefully before calling.',
|
|
65
65
|
'Pass full watch URLs, youtu.be links, or shorts/embed/live links. Non-YouTube links are ignored.',
|
|
66
66
|
'Videos without captions, or that are private/age-restricted, are reported as unavailable — the rest still succeed.',
|
|
67
|
-
"When you cite a YouTube source in CITED LINKS,
|
|
67
|
+
"When you cite a YouTube source in CITED LINKS, the entry's link MUST be the video's watch URL (shown in this tool's output) — a title-only entry loses the source from the final report. Put the video's ACTUAL title and channel in the Description — e.g. \"'<exact title>' by <channel>\". Do NOT paraphrase or invent a title.",
|
|
68
68
|
],
|
|
69
69
|
parameters: YoutubeParamsSchema,
|
|
70
70
|
executionMode: 'parallel',
|
|
@@ -139,6 +139,10 @@ export function createYoutubeTranscriptTool(options: {
|
|
|
139
139
|
|
|
140
140
|
if (researchId && cachedUrls.length > 0) {
|
|
141
141
|
registerScrapedLinks(researchId, cachedUrls);
|
|
142
|
+
// Record the provenance subset: if the researcher's CITED LINKS block is
|
|
143
|
+
// unparseable, the synthesis fallback rebuilds sources from the link pool
|
|
144
|
+
// and must label these "YouTube Transcript", not "Scrape".
|
|
145
|
+
registerTranscribedLinks(researchId, cachedUrls);
|
|
142
146
|
options.updateGlobalLinks?.(cachedUrls);
|
|
143
147
|
if (options.researcherId) {
|
|
144
148
|
registerResearcherScrapes(researchId, options.researcherId, cachedUrls);
|
|
@@ -17,6 +17,11 @@ const sessionLinks = new Map<string, Set<string>>();
|
|
|
17
17
|
const sessionScrapedContent = new Map<string, Map<string, string>>();
|
|
18
18
|
// Per-researcher scrape tracking: researchId → researcherId → Set<normalizedUrl>
|
|
19
19
|
const researcherScrapes = new Map<string, Map<string, Set<string>>>();
|
|
20
|
+
// URLs whose content came from a YouTube TRANSCRIPT (not a page scrape). The
|
|
21
|
+
// transcript tool registers its watch URLs in the same sessionLinks pool so the
|
|
22
|
+
// post-round writer and citation fallback see them; this subset lets the
|
|
23
|
+
// fallback label them "YouTube Transcript" instead of "Scrape".
|
|
24
|
+
const transcribedUrls = new Map<string, Set<string>>();
|
|
20
25
|
|
|
21
26
|
// FIX (Issue 12): Track creation timestamps for orphaned-entry cleanup.
|
|
22
27
|
const sessionTimestamps = new Map<string, number>();
|
|
@@ -38,6 +43,7 @@ function startCleanupTimer(): void {
|
|
|
38
43
|
sessionScrapedContent.delete(researchId);
|
|
39
44
|
sessionTimestamps.delete(researchId);
|
|
40
45
|
researcherScrapes.delete(researchId);
|
|
46
|
+
transcribedUrls.delete(researchId);
|
|
41
47
|
cleaned++;
|
|
42
48
|
}
|
|
43
49
|
}
|
|
@@ -111,6 +117,24 @@ export function getScrapedLinks(researchId: string): string[] {
|
|
|
111
117
|
return Array.from(sessionLinks.get(researchId) || []);
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Mark links as sourced from a YouTube transcript (call IN ADDITION to
|
|
122
|
+
* registerScrapedLinks — this only records the provenance subset).
|
|
123
|
+
*/
|
|
124
|
+
export function registerTranscribedLinks(researchId: string, links: string[]) {
|
|
125
|
+
if (!transcribedUrls.has(researchId)) {
|
|
126
|
+
transcribedUrls.set(researchId, new Set());
|
|
127
|
+
}
|
|
128
|
+
sessionTimestamps.set(researchId, Date.now()); // last-activity TTL refresh (parity with registerScrapedLinks)
|
|
129
|
+
const pool = transcribedUrls.get(researchId)!;
|
|
130
|
+
links.forEach(l => pool.add(normalizeUrl(l)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a URL's content came from a YouTube transcript in this session. */
|
|
134
|
+
export function isTranscribedLink(researchId: string, url: string): boolean {
|
|
135
|
+
return transcribedUrls.get(researchId)?.has(normalizeUrl(url)) ?? false;
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
/**
|
|
115
139
|
* Deduplicate a list of candidate URLs against already scraped links.
|
|
116
140
|
*/
|
|
@@ -140,6 +164,7 @@ export function cleanupSharedLinks(researchId: string) {
|
|
|
140
164
|
sessionScrapedContent.delete(researchId);
|
|
141
165
|
sessionTimestamps.delete(researchId);
|
|
142
166
|
researcherScrapes.delete(researchId);
|
|
167
|
+
transcribedUrls.delete(researchId);
|
|
143
168
|
logger.debug(`[Shared Links] Cleaned up session: ${researchId}`);
|
|
144
169
|
}
|
|
145
170
|
|
|
@@ -156,6 +181,7 @@ export function clearAllSharedLinks(): void {
|
|
|
156
181
|
sessionScrapedContent.clear();
|
|
157
182
|
sessionTimestamps.clear();
|
|
158
183
|
researcherScrapes.clear();
|
|
184
|
+
transcribedUrls.clear();
|
|
159
185
|
logger.debug('[Shared Links] All global state cleared');
|
|
160
186
|
}
|
|
161
187
|
|
|
@@ -150,7 +150,10 @@ export function validateUrlForSSRFSync(url: string): boolean {
|
|
|
150
150
|
*
|
|
151
151
|
* FIX (#8): In addition to hostname pattern checks, resolves the hostname
|
|
152
152
|
* via DNS and rejects any address that resolves to a private/reserved IP.
|
|
153
|
-
* This defends against DNS rebinding and
|
|
153
|
+
* This defends against DNS rebinding and AAAA-only private hosts. (Decimal/
|
|
154
|
+
* octal/hex IP encodings are handled earlier: WHATWG URL parsing normalizes
|
|
155
|
+
* them to dotted-quad, so the sync IP-literal gate blocks them — see the
|
|
156
|
+
* comment on the DNS pass below.)
|
|
154
157
|
*/
|
|
155
158
|
export async function validateUrlForSSRF(url: string): Promise<void> {
|
|
156
159
|
// Synchronous protocol/localhost/internal-pattern/IP-literal screen first.
|
|
@@ -161,8 +164,12 @@ export async function validateUrlForSSRF(url: string): Promise<void> {
|
|
|
161
164
|
const hostname = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
162
165
|
|
|
163
166
|
// Resolve hostname via DNS (both IPv4 and IPv6) and check all resulting addresses.
|
|
164
|
-
// This catches DNS rebinding,
|
|
165
|
-
//
|
|
167
|
+
// This catches DNS rebinding, AAAA-only hosts (::1, fc00::/7) and other
|
|
168
|
+
// hostname-based bypasses. (It does NOT catch numeric-encoding forms like
|
|
169
|
+
// decimal 2130706433 — dns.resolve4/6 reject numeric hosts with EBADNAME — but
|
|
170
|
+
// it doesn't need to: WHATWG URL parsing normalizes decimal/octal/hex IPv4
|
|
171
|
+
// forms to dotted-quad before this function ever sees them, so the IP-literal
|
|
172
|
+
// gate in validateUrlForSSRFSync blocks them. Pinned by a regression test.)
|
|
166
173
|
const [v4Results, v6Results] = await Promise.allSettled([
|
|
167
174
|
dns.resolve4(hostname),
|
|
168
175
|
dns.resolve6(hostname),
|