@lincoln504/pi-research 1.0.9 → 1.0.12
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/dist/cli.mjs +451 -58
- package/dist/thread-worker.mjs +39 -1
- package/docs/CHANGELOG.md +26 -0
- package/package.json +5 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/core/interfaces/service-names.ts +2 -0
- package/src/infrastructure/browser/browser-client.ts +19 -1
- package/src/infrastructure/browser/browser-error-utils.ts +24 -1
- package/src/infrastructure/browser/browser-server.ts +70 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +19 -3
- package/src/infrastructure/browser/scheduler-factory.ts +1 -1
- package/src/infrastructure/browser/thread-worker.mjs +39 -1
- package/src/infrastructure/process-lifecycle-service.ts +21 -3
- package/src/infrastructure/research-run-semaphore.ts +290 -0
- package/src/infrastructure/service-initialization.ts +20 -0
- package/src/orchestration/deep-research-orchestrator.ts +11 -0
- package/src/orchestration/research-orchestration-service.ts +35 -0
- package/src/orchestration/research-synthesis-service.ts +171 -15
- package/src/utils/make-resource-loader.ts +7 -0
- package/src/web-research/retry-utils.ts +9 -3
- package/src/web-research/scraper-utils.ts +43 -0
- package/src/web-research/web-scraper.ts +67 -40
package/dist/cli.mjs
CHANGED
|
@@ -1394,7 +1394,9 @@ var init_service_names = __esm({
|
|
|
1394
1394
|
WORKER_POOL_MANAGER: "worker-pool-manager",
|
|
1395
1395
|
// Research session services
|
|
1396
1396
|
RESEARCH_SESSION_SERVICE: "research-session-service",
|
|
1397
|
-
RESEARCH_SYNTHESIS_SERVICE: "research-synthesis-service"
|
|
1397
|
+
RESEARCH_SYNTHESIS_SERVICE: "research-synthesis-service",
|
|
1398
|
+
// Cross-process cap on concurrent research runs (Phase 1 run-cap)
|
|
1399
|
+
RESEARCH_RUN_SEMAPHORE: "research-run-semaphore"
|
|
1398
1400
|
};
|
|
1399
1401
|
}
|
|
1400
1402
|
});
|
|
@@ -3370,7 +3372,7 @@ var init_model_registry_factory = __esm({
|
|
|
3370
3372
|
});
|
|
3371
3373
|
|
|
3372
3374
|
// src/web-research/retry-utils.ts
|
|
3373
|
-
function abortableDelay(ms, signal) {
|
|
3375
|
+
function abortableDelay(ms, signal, keepAlive = false) {
|
|
3374
3376
|
return new Promise((resolve8, reject) => {
|
|
3375
3377
|
if (signal?.aborted) {
|
|
3376
3378
|
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
@@ -3380,7 +3382,7 @@ function abortableDelay(ms, signal) {
|
|
|
3380
3382
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
3381
3383
|
resolve8();
|
|
3382
3384
|
}, ms);
|
|
3383
|
-
timeoutId.unref?.();
|
|
3385
|
+
if (!keepAlive) timeoutId.unref?.();
|
|
3384
3386
|
const onAbort = signal ? () => {
|
|
3385
3387
|
clearTimeout(timeoutId);
|
|
3386
3388
|
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
@@ -10668,7 +10670,18 @@ var BrowserClient = class {
|
|
|
10668
10670
|
const duration = Date.now() - start;
|
|
10669
10671
|
try {
|
|
10670
10672
|
const parsed = JSON.parse(body);
|
|
10671
|
-
if (res.statusCode
|
|
10673
|
+
if (res.statusCode === 503 && parsed?.error === "draining") {
|
|
10674
|
+
const error = new Error(
|
|
10675
|
+
`Browser pool leader is draining (${parsed.reason || "leadership-lost"}) \u2014 re-elect and retry`
|
|
10676
|
+
);
|
|
10677
|
+
logger.debug(`[BrowserClient] Leader draining on ${path19}; re-election required.`);
|
|
10678
|
+
errorTracker.trackError(error, {
|
|
10679
|
+
component: "browser-manager",
|
|
10680
|
+
operation,
|
|
10681
|
+
errorType: "draining"
|
|
10682
|
+
});
|
|
10683
|
+
reject(error);
|
|
10684
|
+
} else if (res.statusCode !== 200) {
|
|
10672
10685
|
const error = new Error(parsed.error || `HTTP ${res.statusCode}`);
|
|
10673
10686
|
errorTracker.trackError(error, {
|
|
10674
10687
|
component: "browser-manager",
|
|
@@ -10777,7 +10790,10 @@ function isTransientSocketError(error) {
|
|
|
10777
10790
|
err.message.includes("Worker pool is shutting down") || // Poolifier throws this when pool.execute() is called while the pool is being
|
|
10778
10791
|
// destroyed (e.g., during scheduler restart). Treat as transient — the pool will
|
|
10779
10792
|
// be ready for new tasks once destruction completes and a new pool is initialized.
|
|
10780
|
-
err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
10793
|
+
err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // The leader answered 503 because it is shutting down after losing
|
|
10794
|
+
// leadership. Retrying re-elects and reaches the new leader, so this is
|
|
10795
|
+
// transient in exactly the same sense as a pool drain.
|
|
10796
|
+
err.message.includes("Browser pool leader is draining"));
|
|
10781
10797
|
}
|
|
10782
10798
|
function isCloudflareBlockError(error) {
|
|
10783
10799
|
if (!error || typeof error !== "object") return false;
|
|
@@ -10789,6 +10805,11 @@ function isTaskTimeoutError(error) {
|
|
|
10789
10805
|
const err = error;
|
|
10790
10806
|
return typeof err.message === "string" && (err.message.includes("timed out after") || err.message.includes("Search task timed out") || err.message.includes("Scrape task timed out") || err.message.includes("Health check timed out") || err.message.includes("[BrowserClient] Request to"));
|
|
10791
10807
|
}
|
|
10808
|
+
function isServerDrainingError(error) {
|
|
10809
|
+
if (!error || typeof error !== "object") return false;
|
|
10810
|
+
const err = error;
|
|
10811
|
+
return typeof err.message === "string" && err.message.includes("Browser pool leader is draining");
|
|
10812
|
+
}
|
|
10792
10813
|
function isPoolShutdownError(error) {
|
|
10793
10814
|
if (!error || typeof error !== "object") return false;
|
|
10794
10815
|
const err = error;
|
|
@@ -10807,6 +10828,7 @@ var DEFAULT_BREAKER_CONFIG = {
|
|
|
10807
10828
|
name: "BrowserPool",
|
|
10808
10829
|
isTransientError: (error) => {
|
|
10809
10830
|
if (isPoolShutdownError(error)) return false;
|
|
10831
|
+
if (isServerDrainingError(error)) return false;
|
|
10810
10832
|
if (isCloudflareBlockError(error)) return false;
|
|
10811
10833
|
if (isTaskTimeoutError(error)) return false;
|
|
10812
10834
|
return isTransientSocketError(error);
|
|
@@ -10847,6 +10869,50 @@ var BrowserServer = class {
|
|
|
10847
10869
|
options;
|
|
10848
10870
|
server = null;
|
|
10849
10871
|
port = 0;
|
|
10872
|
+
/**
|
|
10873
|
+
* Live client sockets. Tracked so teardown can tell whether anyone is still
|
|
10874
|
+
* attached (`hasActiveClients`) rather than guessing, and so drain has a
|
|
10875
|
+
* meaningful count to report.
|
|
10876
|
+
*/
|
|
10877
|
+
connections = /* @__PURE__ */ new Set();
|
|
10878
|
+
draining = false;
|
|
10879
|
+
/**
|
|
10880
|
+
* Why this server stopped accepting work. Reported to the client so a log line
|
|
10881
|
+
* says which teardown it hit — a lost election, an idle timeout, or process
|
|
10882
|
+
* shutdown all reach the same code path but are very different incidents.
|
|
10883
|
+
*/
|
|
10884
|
+
drainReason = "shutting-down";
|
|
10885
|
+
/**
|
|
10886
|
+
* Enter draining mode: every *subsequent* request is answered `503` instead of
|
|
10887
|
+
* being served or left to hit a closed socket. Requests already past this
|
|
10888
|
+
* check run to completion, so drain never truncates in-flight work.
|
|
10889
|
+
*
|
|
10890
|
+
* This exists for leadership-loss teardown. Without it a client that is
|
|
10891
|
+
* mid-conversation with a leader that just lost the election waits out a
|
|
10892
|
+
* connect timeout or gets a bare ECONNREFUSED/RST once the listener closes,
|
|
10893
|
+
* and only then re-elects. An explicit 503 turns that silent stall into an
|
|
10894
|
+
* immediate, unambiguous "this leader is gone — re-elect now".
|
|
10895
|
+
*
|
|
10896
|
+
* Idempotent: calling it while already draining is a no-op.
|
|
10897
|
+
*/
|
|
10898
|
+
beginDrain(reason = "shutting-down") {
|
|
10899
|
+
if (this.draining) return;
|
|
10900
|
+
this.draining = true;
|
|
10901
|
+
this.drainReason = reason;
|
|
10902
|
+
logger.log(`[BrowserServer] Draining (${reason}) \u2014 refusing new requests with 503 (${this.connections.size} client connection(s) attached).`);
|
|
10903
|
+
}
|
|
10904
|
+
/** True once {@link beginDrain} has been called. */
|
|
10905
|
+
isDraining() {
|
|
10906
|
+
return this.draining;
|
|
10907
|
+
}
|
|
10908
|
+
/** Whether any client socket is currently attached. */
|
|
10909
|
+
hasActiveClients() {
|
|
10910
|
+
return this.connections.size > 0;
|
|
10911
|
+
}
|
|
10912
|
+
/** Number of attached client sockets (observability / teardown decisions). */
|
|
10913
|
+
getConnectionCount() {
|
|
10914
|
+
return this.connections.size;
|
|
10915
|
+
}
|
|
10850
10916
|
async start() {
|
|
10851
10917
|
return new Promise((resolve8, reject) => {
|
|
10852
10918
|
this.server = http3.createServer(async (req, res) => {
|
|
@@ -10859,6 +10925,14 @@ var BrowserServer = class {
|
|
|
10859
10925
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
10860
10926
|
return;
|
|
10861
10927
|
}
|
|
10928
|
+
if (this.draining) {
|
|
10929
|
+
res.writeHead(503, {
|
|
10930
|
+
"Content-Type": "application/json",
|
|
10931
|
+
"Connection": "close"
|
|
10932
|
+
});
|
|
10933
|
+
res.end(JSON.stringify({ error: "draining", reason: this.drainReason }));
|
|
10934
|
+
return;
|
|
10935
|
+
}
|
|
10862
10936
|
if (req.method !== "POST") {
|
|
10863
10937
|
res.writeHead(405);
|
|
10864
10938
|
res.end("Method Not Allowed");
|
|
@@ -10928,6 +11002,12 @@ var BrowserServer = class {
|
|
|
10928
11002
|
}
|
|
10929
11003
|
});
|
|
10930
11004
|
});
|
|
11005
|
+
this.server.on("connection", (socket) => {
|
|
11006
|
+
this.connections.add(socket);
|
|
11007
|
+
socket.on("close", () => {
|
|
11008
|
+
this.connections.delete(socket);
|
|
11009
|
+
});
|
|
11010
|
+
});
|
|
10931
11011
|
this.server.listen(0, "127.0.0.1", () => {
|
|
10932
11012
|
const addr = this.server?.address();
|
|
10933
11013
|
if (addr && typeof addr === "object") {
|
|
@@ -10950,6 +11030,7 @@ var BrowserServer = class {
|
|
|
10950
11030
|
return new Promise((resolve8) => {
|
|
10951
11031
|
this.server?.close(() => {
|
|
10952
11032
|
this.server = null;
|
|
11033
|
+
this.connections.clear();
|
|
10953
11034
|
resolve8();
|
|
10954
11035
|
});
|
|
10955
11036
|
});
|
|
@@ -11394,7 +11475,7 @@ var BrowserTaskScheduler = class {
|
|
|
11394
11475
|
if (this.consecutiveLeadershipMisses >= this.LEADERSHIP_MISS_THRESHOLD) {
|
|
11395
11476
|
metrics.increment("browser_leadership_lost_total", 1);
|
|
11396
11477
|
logger.error(`[Scheduler] Leadership threshold exceeded (${this.consecutiveLeadershipMisses} misses), shutting down pool...`);
|
|
11397
|
-
await this.shutdown();
|
|
11478
|
+
await this.shutdown("leadership-lost");
|
|
11398
11479
|
return;
|
|
11399
11480
|
}
|
|
11400
11481
|
} else {
|
|
@@ -11422,7 +11503,7 @@ var BrowserTaskScheduler = class {
|
|
|
11422
11503
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
11423
11504
|
this.idleTimer = setTimeout(() => {
|
|
11424
11505
|
logger.log("[Scheduler] Browser pool idle timeout reached, shutting down...");
|
|
11425
|
-
this.shutdown().catch((err) => logger.warn("[Scheduler] Idle-timeout shutdown failed (non-fatal):", err));
|
|
11506
|
+
this.shutdown("idle-timeout").catch((err) => logger.warn("[Scheduler] Idle-timeout shutdown failed (non-fatal):", err));
|
|
11426
11507
|
}, this.IDLE_TIMEOUT_MS);
|
|
11427
11508
|
if (this.idleTimer.unref) this.idleTimer.unref();
|
|
11428
11509
|
}
|
|
@@ -11615,9 +11696,18 @@ var BrowserTaskScheduler = class {
|
|
|
11615
11696
|
}
|
|
11616
11697
|
return result;
|
|
11617
11698
|
}
|
|
11618
|
-
|
|
11699
|
+
/**
|
|
11700
|
+
* @param reason why the pool is going away. Reported verbatim to clients in the
|
|
11701
|
+
* drain 503, so a lost election, an idle timeout and a process shutdown are
|
|
11702
|
+
* distinguishable in a client-side log rather than all reading as one.
|
|
11703
|
+
*/
|
|
11704
|
+
async shutdown(reason = "shutting-down") {
|
|
11619
11705
|
if (this.isShuttingDown) return;
|
|
11620
11706
|
this.isShuttingDown = true;
|
|
11707
|
+
if (this.server?.hasActiveClients()) {
|
|
11708
|
+
logger.log(`[Scheduler] Draining ${this.server.getConnectionCount()} client connection(s) before shutdown (${reason}).`);
|
|
11709
|
+
}
|
|
11710
|
+
this.server?.beginDrain(reason);
|
|
11621
11711
|
if (this.idleTimer) {
|
|
11622
11712
|
clearTimeout(this.idleTimer);
|
|
11623
11713
|
this.idleTimer = null;
|
|
@@ -11771,7 +11861,7 @@ async function forceSchedulerRestart(forceClearRemoteState = false, container =
|
|
|
11771
11861
|
}
|
|
11772
11862
|
if (oldScheduler && "schedulerId" in oldScheduler && oldScheduler.schedulerId) {
|
|
11773
11863
|
if (oldScheduler instanceof BrowserTaskScheduler) {
|
|
11774
|
-
const shutdownPromise = oldScheduler.shutdown().catch((err) => {
|
|
11864
|
+
const shutdownPromise = oldScheduler.shutdown("leadership-lost").catch((err) => {
|
|
11775
11865
|
logger.warn("[Scheduler] Error during old scheduler shutdown after restart:", err);
|
|
11776
11866
|
});
|
|
11777
11867
|
schedulerService.setPendingShutdownPromise(shutdownPromise);
|
|
@@ -12962,7 +13052,8 @@ var ProcessLifecycleService = class {
|
|
|
12962
13052
|
if (expectedStartTime !== void 0 && expectedStartTime !== null) {
|
|
12963
13053
|
const actualStartTime = await this.getProcessStartTime(pid);
|
|
12964
13054
|
if (actualStartTime === null) {
|
|
12965
|
-
|
|
13055
|
+
process.kill(pid, 0);
|
|
13056
|
+
return true;
|
|
12966
13057
|
}
|
|
12967
13058
|
return Math.abs(actualStartTime - expectedStartTime) <= 1;
|
|
12968
13059
|
}
|
|
@@ -14130,6 +14221,180 @@ var StatePathConfiguration = class {
|
|
|
14130
14221
|
// src/infrastructure/service-initialization.ts
|
|
14131
14222
|
init_file_lock_service();
|
|
14132
14223
|
|
|
14224
|
+
// src/infrastructure/research-run-semaphore.ts
|
|
14225
|
+
init_logger();
|
|
14226
|
+
init_metrics();
|
|
14227
|
+
init_file_lock_service();
|
|
14228
|
+
init_service_registry();
|
|
14229
|
+
init_service_names();
|
|
14230
|
+
import * as pathmod2 from "node:path";
|
|
14231
|
+
var DEFAULT_MAX_CONCURRENT_RUNS = 2;
|
|
14232
|
+
var DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
|
|
14233
|
+
var POLL_DELAY_MS = 250;
|
|
14234
|
+
var ResearchRunCapacityError = class extends Error {
|
|
14235
|
+
slots;
|
|
14236
|
+
constructor(slots, maxWaitMs) {
|
|
14237
|
+
super(
|
|
14238
|
+
`Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after waiting ${maxWaitMs}ms` : ""}; wait for an in-progress run to finish and retry. (Override with PI_RESEARCH_MAX_CONCURRENT_RUNS.)`
|
|
14239
|
+
);
|
|
14240
|
+
this.name = "ResearchRunCapacityError";
|
|
14241
|
+
this.slots = slots;
|
|
14242
|
+
}
|
|
14243
|
+
};
|
|
14244
|
+
var ResearchRunSemaphore = class {
|
|
14245
|
+
name = ServiceNames.RESEARCH_RUN_SEMAPHORE;
|
|
14246
|
+
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
14247
|
+
slotDir;
|
|
14248
|
+
processLifecycle;
|
|
14249
|
+
maxSlots;
|
|
14250
|
+
defaultMaxWaitMs;
|
|
14251
|
+
/** Rotating start index so contending processes don't all hammer slot 0 first. */
|
|
14252
|
+
nextStartIndex = 0;
|
|
14253
|
+
_initialized = false;
|
|
14254
|
+
/**
|
|
14255
|
+
* @param slotDir Directory holding the `research-run-slot-<i>.lock` files.
|
|
14256
|
+
* @param processLifecycle PID+startTime liveness source (PID-reuse-safe reclamation).
|
|
14257
|
+
* @param maxSlots N — maximum concurrent runs. Defaults from env then {@link DEFAULT_MAX_CONCURRENT_RUNS}.
|
|
14258
|
+
* @param defaultMaxWaitMs Default acquire wait. Defaults from env then {@link DEFAULT_ACQUIRE_TIMEOUT_MS}.
|
|
14259
|
+
*/
|
|
14260
|
+
constructor(slotDir, processLifecycle, maxSlots, defaultMaxWaitMs) {
|
|
14261
|
+
this.slotDir = slotDir;
|
|
14262
|
+
this.processLifecycle = processLifecycle;
|
|
14263
|
+
this.maxSlots = Math.max(1, Math.floor(
|
|
14264
|
+
maxSlots ?? numberFromEnv("PI_RESEARCH_MAX_CONCURRENT_RUNS") ?? DEFAULT_MAX_CONCURRENT_RUNS
|
|
14265
|
+
));
|
|
14266
|
+
this.defaultMaxWaitMs = Math.max(0, Math.floor(
|
|
14267
|
+
defaultMaxWaitMs ?? numberFromEnv("PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS") ?? DEFAULT_ACQUIRE_TIMEOUT_MS
|
|
14268
|
+
));
|
|
14269
|
+
}
|
|
14270
|
+
/** Number of slots (max concurrent runs). */
|
|
14271
|
+
getMaxSlots() {
|
|
14272
|
+
return this.maxSlots;
|
|
14273
|
+
}
|
|
14274
|
+
async initialize() {
|
|
14275
|
+
if (this._initialized) return;
|
|
14276
|
+
this.lifecycle = "initializing" /* INITIALIZING */;
|
|
14277
|
+
try {
|
|
14278
|
+
await import("node:fs/promises").then((fs17) => fs17.mkdir(this.slotDir, { recursive: true, mode: 448 }));
|
|
14279
|
+
} catch (err) {
|
|
14280
|
+
logger.warn(`[ResearchRunSemaphore] Could not create slot dir ${this.slotDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
14281
|
+
}
|
|
14282
|
+
this._initialized = true;
|
|
14283
|
+
this.lifecycle = "initialized" /* INITIALIZED */;
|
|
14284
|
+
logger.log(`[ResearchRunSemaphore] Initialized with ${this.maxSlots} run slot(s) (acquire timeout ${this.defaultMaxWaitMs}ms).`);
|
|
14285
|
+
}
|
|
14286
|
+
async dispose() {
|
|
14287
|
+
this.lifecycle = "disposed" /* DISPOSED */;
|
|
14288
|
+
}
|
|
14289
|
+
/**
|
|
14290
|
+
* Try to win one slot NON-BLOCKING. Builds a fresh {@link FileLockService}
|
|
14291
|
+
* configured as a try-take (see class doc: "fresh instance per attempt").
|
|
14292
|
+
* Returns an acquisition on success, or null if the slot is busy / lost a race.
|
|
14293
|
+
*/
|
|
14294
|
+
async trySlot(slotIndex) {
|
|
14295
|
+
const slot = new FileLockService({
|
|
14296
|
+
lockFilePath: pathmod2.join(this.slotDir, `research-run-slot-${slotIndex}.lock`),
|
|
14297
|
+
lockTimeout: 0,
|
|
14298
|
+
// fail immediately on a live-contended slot (try-take)
|
|
14299
|
+
lockRetries: 24,
|
|
14300
|
+
// enough iterations to complete a dead-owner reclaim + reacquire
|
|
14301
|
+
lockRetryDelay: 50,
|
|
14302
|
+
lockStaleThreshold: 15e3,
|
|
14303
|
+
// legacy (no-pid) locks: reclaim once 15s old
|
|
14304
|
+
// A live process holding a run slot for the entire (minutes-long) run is the
|
|
14305
|
+
// NORMAL case, not a stall. Never steal from a live owner — doing so would
|
|
14306
|
+
// admit an (N+1)th concurrent run, the exact condition this guard prevents.
|
|
14307
|
+
// Dead owners are reclaimed immediately regardless of age.
|
|
14308
|
+
liveOwnerStaleThreshold: Number.MAX_SAFE_INTEGER,
|
|
14309
|
+
processLifecycle: this.processLifecycle ?? void 0
|
|
14310
|
+
});
|
|
14311
|
+
try {
|
|
14312
|
+
await slot.initialize();
|
|
14313
|
+
await slot.acquireLock();
|
|
14314
|
+
} catch {
|
|
14315
|
+
return null;
|
|
14316
|
+
}
|
|
14317
|
+
return {
|
|
14318
|
+
slotIndex,
|
|
14319
|
+
release: async () => {
|
|
14320
|
+
try {
|
|
14321
|
+
await slot.releaseLock();
|
|
14322
|
+
} catch (err) {
|
|
14323
|
+
logger.warn(`[ResearchRunSemaphore] Failed to release slot ${slotIndex}: ${err instanceof Error ? err.message : String(err)}`);
|
|
14324
|
+
} finally {
|
|
14325
|
+
metrics.setGauge("research_run_slots_held", 0, { slot: String(slotIndex) });
|
|
14326
|
+
}
|
|
14327
|
+
}
|
|
14328
|
+
};
|
|
14329
|
+
}
|
|
14330
|
+
/**
|
|
14331
|
+
* Acquire one run slot, waiting up to `maxWaitMs` (defaults to the constructed
|
|
14332
|
+
* value) when all slots are held by live processes. Resolves with a handle that
|
|
14333
|
+
* must be released when the run ends.
|
|
14334
|
+
*
|
|
14335
|
+
* @param signal aborts the wait. Without it a run started under a long
|
|
14336
|
+
* `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
|
|
14337
|
+
* passed, because the wait happens before any orchestrator exists to cancel.
|
|
14338
|
+
* @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
|
|
14339
|
+
* @throws {Error} named `AbortError` if `signal` aborts while waiting.
|
|
14340
|
+
*/
|
|
14341
|
+
async acquire(maxWaitMs = this.defaultMaxWaitMs, signal) {
|
|
14342
|
+
if (!this._initialized) await this.initialize();
|
|
14343
|
+
if (signal?.aborted) {
|
|
14344
|
+
metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
|
|
14345
|
+
throwIfAborted(signal);
|
|
14346
|
+
}
|
|
14347
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
14348
|
+
for (; ; ) {
|
|
14349
|
+
const start = this.nextStartIndex;
|
|
14350
|
+
this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
|
|
14351
|
+
for (let offset = 0; offset < this.maxSlots; offset++) {
|
|
14352
|
+
const i = (start + offset) % this.maxSlots;
|
|
14353
|
+
const got = await this.trySlot(i);
|
|
14354
|
+
if (got) {
|
|
14355
|
+
metrics.setGauge("research_run_slots_held", 1, { slot: String(i) });
|
|
14356
|
+
metrics.increment("research_run_acquire_total", 1, { status: "acquired" });
|
|
14357
|
+
return got;
|
|
14358
|
+
}
|
|
14359
|
+
}
|
|
14360
|
+
if (Date.now() >= deadline) {
|
|
14361
|
+
metrics.increment("research_run_acquire_total", 1, { status: "exhausted" });
|
|
14362
|
+
throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
|
|
14363
|
+
}
|
|
14364
|
+
try {
|
|
14365
|
+
await sleep(POLL_DELAY_MS, signal);
|
|
14366
|
+
} catch (err) {
|
|
14367
|
+
metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
|
|
14368
|
+
throw err;
|
|
14369
|
+
}
|
|
14370
|
+
}
|
|
14371
|
+
}
|
|
14372
|
+
};
|
|
14373
|
+
function throwIfAborted(signal) {
|
|
14374
|
+
if (signal?.aborted) {
|
|
14375
|
+
throw Object.assign(new Error("Research run slot acquisition aborted"), { name: "AbortError" });
|
|
14376
|
+
}
|
|
14377
|
+
}
|
|
14378
|
+
function numberFromEnv(key) {
|
|
14379
|
+
const raw = process.env[key];
|
|
14380
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
14381
|
+
const n = Number(raw);
|
|
14382
|
+
return Number.isFinite(n) ? n : void 0;
|
|
14383
|
+
}
|
|
14384
|
+
function sleep(ms, signal) {
|
|
14385
|
+
return new Promise((resolve8, reject) => {
|
|
14386
|
+
const timer = setTimeout(() => {
|
|
14387
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
14388
|
+
resolve8();
|
|
14389
|
+
}, ms);
|
|
14390
|
+
const onAbort = signal ? () => {
|
|
14391
|
+
clearTimeout(timer);
|
|
14392
|
+
reject(Object.assign(new Error("Research run slot acquisition aborted"), { name: "AbortError" }));
|
|
14393
|
+
} : void 0;
|
|
14394
|
+
if (onAbort) signal?.addEventListener("abort", onAbort, { once: true });
|
|
14395
|
+
});
|
|
14396
|
+
}
|
|
14397
|
+
|
|
14133
14398
|
// src/infrastructure/state/state-backup-manager.ts
|
|
14134
14399
|
init_logger();
|
|
14135
14400
|
init_service_registry();
|
|
@@ -14389,6 +14654,19 @@ function registerInfrastructureServices(container = getServiceContainer()) {
|
|
|
14389
14654
|
},
|
|
14390
14655
|
container
|
|
14391
14656
|
);
|
|
14657
|
+
registerService(
|
|
14658
|
+
ServiceNames.RESEARCH_RUN_SEMAPHORE,
|
|
14659
|
+
async () => {
|
|
14660
|
+
const pathConfig = await getService(ServiceNames.STATE_PATH_CONFIGURATION, void 0, container);
|
|
14661
|
+
const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE, void 0, container);
|
|
14662
|
+
return new ResearchRunSemaphore(pathConfig.getStateDir(), processLifecycle);
|
|
14663
|
+
},
|
|
14664
|
+
{
|
|
14665
|
+
allowOverwrite: false,
|
|
14666
|
+
enableLogging: true
|
|
14667
|
+
},
|
|
14668
|
+
container
|
|
14669
|
+
);
|
|
14392
14670
|
registerService(
|
|
14393
14671
|
ServiceNames.STATE_BACKUP_MANAGER,
|
|
14394
14672
|
async () => {
|
|
@@ -15538,6 +15816,34 @@ async function getSsrfSafeDispatcher() {
|
|
|
15538
15816
|
}
|
|
15539
15817
|
return cachedSsrfDispatcher;
|
|
15540
15818
|
}
|
|
15819
|
+
function formatErrorWithCause(err) {
|
|
15820
|
+
try {
|
|
15821
|
+
const parts = [];
|
|
15822
|
+
let current = err;
|
|
15823
|
+
for (let depth = 0; current !== null && current !== void 0 && depth < 5; depth++) {
|
|
15824
|
+
let segment;
|
|
15825
|
+
if (current instanceof Error) {
|
|
15826
|
+
segment = current.message || current.name;
|
|
15827
|
+
const code = current.code;
|
|
15828
|
+
if (typeof code === "string" && code && !segment.includes(code)) {
|
|
15829
|
+
segment = `${segment} [${code}]`;
|
|
15830
|
+
}
|
|
15831
|
+
} else if (typeof current === "string") {
|
|
15832
|
+
segment = current;
|
|
15833
|
+
} else {
|
|
15834
|
+
segment = String(current);
|
|
15835
|
+
}
|
|
15836
|
+
if (segment && !parts.includes(segment)) parts.push(segment);
|
|
15837
|
+
const next = current.cause;
|
|
15838
|
+
if (next === current) break;
|
|
15839
|
+
current = next;
|
|
15840
|
+
}
|
|
15841
|
+
if (parts.length === 0) return String(err);
|
|
15842
|
+
return parts.length === 1 ? parts[0] : parts.join(" \u2190 ");
|
|
15843
|
+
} catch {
|
|
15844
|
+
return String(err);
|
|
15845
|
+
}
|
|
15846
|
+
}
|
|
15541
15847
|
function isPrivateIp(ip) {
|
|
15542
15848
|
const parts = ip.split(".").map(Number);
|
|
15543
15849
|
if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
|
|
@@ -15641,8 +15947,10 @@ function validateContent(html, markdown, url) {
|
|
|
15641
15947
|
}
|
|
15642
15948
|
|
|
15643
15949
|
// src/web-research/web-scraper.ts
|
|
15950
|
+
init_retry_utils();
|
|
15644
15951
|
init_safe_unref();
|
|
15645
15952
|
init_service_registry();
|
|
15953
|
+
var FETCH_RETRY_DELAY_MS = 250;
|
|
15646
15954
|
var playwrightAvailable = false;
|
|
15647
15955
|
var markdownConverterPromise = null;
|
|
15648
15956
|
function initScraperDependencies() {
|
|
@@ -15779,6 +16087,17 @@ async function readBodyCapped(response, maxBytes) {
|
|
|
15779
16087
|
}
|
|
15780
16088
|
return out;
|
|
15781
16089
|
}
|
|
16090
|
+
function trackFetchLayerFailure(url, error) {
|
|
16091
|
+
const isSsrf = error instanceof Error && error.message.includes("not allowed");
|
|
16092
|
+
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
16093
|
+
component: "scrapers",
|
|
16094
|
+
operation: "fetch",
|
|
16095
|
+
url,
|
|
16096
|
+
domain: extractDomain(url),
|
|
16097
|
+
layer: "fetch",
|
|
16098
|
+
...isSsrf ? { errorType: "ssrf_blocked" } : {}
|
|
16099
|
+
});
|
|
16100
|
+
}
|
|
15782
16101
|
async function scrapeWithFetch(url, signal) {
|
|
15783
16102
|
await validateUrlForSSRF(url);
|
|
15784
16103
|
const dispatcher = await getSsrfSafeDispatcher();
|
|
@@ -15885,14 +16204,6 @@ async function scrapeWithFetch(url, signal) {
|
|
|
15885
16204
|
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
15886
16205
|
logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
|
|
15887
16206
|
metrics.increment("scrape_errors_total", 1, { error_type: "size_exceeded", content_type: "html" });
|
|
15888
|
-
errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
|
|
15889
|
-
component: "scrapers",
|
|
15890
|
-
operation: "fetch",
|
|
15891
|
-
url,
|
|
15892
|
-
domain: extractDomain(url),
|
|
15893
|
-
layer: "fetch",
|
|
15894
|
-
contentType: "html"
|
|
15895
|
-
});
|
|
15896
16207
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
|
|
15897
16208
|
}
|
|
15898
16209
|
throw e;
|
|
@@ -15901,14 +16212,6 @@ async function scrapeWithFetch(url, signal) {
|
|
|
15901
16212
|
const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
|
|
15902
16213
|
logger.warn(`[Scrapers] HTML response too large (actual: ${sizeMB}MB, max 25MB), skipping`);
|
|
15903
16214
|
metrics.increment("scrape_errors_total", 1, { error_type: "size_exceeded", content_type: "html" });
|
|
15904
|
-
errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
|
|
15905
|
-
component: "scrapers",
|
|
15906
|
-
operation: "fetch",
|
|
15907
|
-
url,
|
|
15908
|
-
domain: extractDomain(url),
|
|
15909
|
-
layer: "fetch",
|
|
15910
|
-
contentType: "html"
|
|
15911
|
-
});
|
|
15912
16215
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
|
|
15913
16216
|
}
|
|
15914
16217
|
const html = decodeHtmlBody(bodyBytes, contentType);
|
|
@@ -15927,24 +16230,9 @@ async function scrapeWithFetch(url, signal) {
|
|
|
15927
16230
|
if (error instanceof Error && error.message.includes("not allowed")) {
|
|
15928
16231
|
metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "ssrf_blocked" });
|
|
15929
16232
|
metrics.observe("scrape_latency_ms", Date.now() - fetchStart, { layer: "fetch", status: "ssrf_blocked" });
|
|
15930
|
-
errorTracker.trackError(error, {
|
|
15931
|
-
component: "scrapers",
|
|
15932
|
-
operation: "fetch",
|
|
15933
|
-
url,
|
|
15934
|
-
domain: extractDomain(url),
|
|
15935
|
-
layer: "fetch",
|
|
15936
|
-
errorType: "ssrf_blocked"
|
|
15937
|
-
});
|
|
15938
16233
|
throw error;
|
|
15939
16234
|
}
|
|
15940
16235
|
metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "error" });
|
|
15941
|
-
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
15942
|
-
component: "scrapers",
|
|
15943
|
-
operation: "fetch",
|
|
15944
|
-
url,
|
|
15945
|
-
domain: extractDomain(url),
|
|
15946
|
-
layer: "fetch"
|
|
15947
|
-
});
|
|
15948
16236
|
throw error;
|
|
15949
16237
|
} finally {
|
|
15950
16238
|
clearTimeout(timeoutId);
|
|
@@ -16017,7 +16305,21 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
|
|
|
16017
16305
|
}
|
|
16018
16306
|
const start = Date.now();
|
|
16019
16307
|
try {
|
|
16020
|
-
const res = await
|
|
16308
|
+
const res = await (async () => {
|
|
16309
|
+
try {
|
|
16310
|
+
return await scrapeWithFetch(url, signal);
|
|
16311
|
+
} catch (firstErr) {
|
|
16312
|
+
if (!isTransientError(firstErr) || signal?.aborted) throw firstErr;
|
|
16313
|
+
logger.debug(`[Scrapers] fetch transient failure for ${url}, retrying once: ${formatErrorWithCause(firstErr)}`);
|
|
16314
|
+
metrics.increment("scrape_fetch_retries_total", 1, { layer: "fetch" });
|
|
16315
|
+
try {
|
|
16316
|
+
await abortableDelay(FETCH_RETRY_DELAY_MS, signal, true);
|
|
16317
|
+
} catch {
|
|
16318
|
+
throw firstErr;
|
|
16319
|
+
}
|
|
16320
|
+
return await scrapeWithFetch(url, signal);
|
|
16321
|
+
}
|
|
16322
|
+
})();
|
|
16021
16323
|
const duration = Date.now() - start;
|
|
16022
16324
|
logger.log(`[Scrapers] fetch success for ${url} in ${duration}ms`);
|
|
16023
16325
|
metrics.increment("scrape_results_total", 1, { outcome: "fetch_success" });
|
|
@@ -16027,7 +16329,8 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
|
|
|
16027
16329
|
return { ...res, url, success: true };
|
|
16028
16330
|
} catch (e1) {
|
|
16029
16331
|
const fetchDuration = Date.now() - start;
|
|
16030
|
-
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${
|
|
16332
|
+
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
|
|
16333
|
+
trackFetchLayerFailure(url, e1);
|
|
16031
16334
|
if (playwrightAvailable) {
|
|
16032
16335
|
try {
|
|
16033
16336
|
const browserStart = Date.now();
|
|
@@ -16044,18 +16347,18 @@ async function scrapeSingle(url, signal, config, sessionId, container = getServi
|
|
|
16044
16347
|
} catch (e2) {
|
|
16045
16348
|
const totalDuration = Date.now() - start;
|
|
16046
16349
|
if (isBenignScrapeFailure(e2)) {
|
|
16047
|
-
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${
|
|
16350
|
+
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${formatErrorWithCause(e2)}`);
|
|
16048
16351
|
} else {
|
|
16049
16352
|
logger.error(`[Scrapers] Browser fallback failed for ${url} in ${totalDuration}ms:`, e2);
|
|
16050
16353
|
}
|
|
16051
16354
|
metrics.increment("scrape_errors_total", 1, { error_type: "fallback_failed", layer: "playwright" });
|
|
16052
16355
|
metrics.increment("scrape_results_total", 1, { outcome: "total_failure" });
|
|
16053
|
-
return { url, success: false, error:
|
|
16356
|
+
return { url, success: false, error: formatErrorWithCause(e2), markdown: "" };
|
|
16054
16357
|
}
|
|
16055
16358
|
}
|
|
16056
16359
|
metrics.increment("scrape_errors_total", 1, { error_type: "no_fallback_available", layer: "fetch" });
|
|
16057
16360
|
metrics.increment("scrape_results_total", 1, { outcome: "total_failure" });
|
|
16058
|
-
return { url, success: false, error:
|
|
16361
|
+
return { url, success: false, error: formatErrorWithCause(e1), markdown: "" };
|
|
16059
16362
|
}
|
|
16060
16363
|
}
|
|
16061
16364
|
async function scrape(urls, maxConcurrency = 5, signal, config, sessionId, onUrlComplete, container = getServiceContainer()) {
|
|
@@ -19652,7 +19955,14 @@ function makeResourceLoader(systemPromptText) {
|
|
|
19652
19955
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
19653
19956
|
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
19654
19957
|
getSystemPrompt: () => systemPromptText,
|
|
19958
|
+
// pi 0.83.0 added these to the ResourceLoader contract (used by interactive
|
|
19959
|
+
// mode's startup context listing to show file-backed SYSTEM.md /
|
|
19960
|
+
// APPEND_SYSTEM.md paths). Our sub-session prompts are in-memory text with
|
|
19961
|
+
// no backing file, so "no source" is the accurate answer. Harmless extras
|
|
19962
|
+
// on pre-0.83 hosts.
|
|
19963
|
+
getSystemPromptSource: () => void 0,
|
|
19655
19964
|
getAppendSystemPrompt: () => [],
|
|
19965
|
+
getAppendSystemPromptSources: () => [],
|
|
19656
19966
|
extendResources: () => {
|
|
19657
19967
|
},
|
|
19658
19968
|
reload: async () => {
|
|
@@ -21204,6 +21514,8 @@ var DeepResearchOrchestrator = class {
|
|
|
21204
21514
|
} catch (obsErr) {
|
|
21205
21515
|
logger.debug("[DeepOrchestrator] onComplete observer threw:", obsErr);
|
|
21206
21516
|
}
|
|
21517
|
+
logger.debug(`[DeepOrchestrator] Final synthesis for ${researchId} (${result.length} chars):
|
|
21518
|
+
${result}`);
|
|
21207
21519
|
return result;
|
|
21208
21520
|
} catch (error) {
|
|
21209
21521
|
const sessionDuration = Date.now() - this.sessionStart;
|
|
@@ -21227,6 +21539,8 @@ var DeepResearchOrchestrator = class {
|
|
|
21227
21539
|
} catch (obsErr) {
|
|
21228
21540
|
logger.debug("[DeepOrchestrator] fallback onComplete observer threw:", obsErr);
|
|
21229
21541
|
}
|
|
21542
|
+
logger.debug(`[DeepOrchestrator] Fallback synthesis for ${researchId} (${result.length} chars):
|
|
21543
|
+
${result}`);
|
|
21230
21544
|
return result;
|
|
21231
21545
|
}
|
|
21232
21546
|
} catch (fallbackErr) {
|
|
@@ -21293,6 +21607,17 @@ var ResearchOrchestrationService = class {
|
|
|
21293
21607
|
const selectedModel = await this.resolveResearchModel(options);
|
|
21294
21608
|
logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
|
|
21295
21609
|
const researchStart = Date.now();
|
|
21610
|
+
let runSlot = null;
|
|
21611
|
+
try {
|
|
21612
|
+
const container = tryGetServiceContainerFromCtx(ctx);
|
|
21613
|
+
const semaphore = await getService(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
|
|
21614
|
+
runSlot = await semaphore.acquire(void 0, signal);
|
|
21615
|
+
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
21616
|
+
} catch (err) {
|
|
21617
|
+
if (err instanceof ResearchRunCapacityError) throw err;
|
|
21618
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
21619
|
+
logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
21620
|
+
}
|
|
21296
21621
|
let result;
|
|
21297
21622
|
try {
|
|
21298
21623
|
if (depth === 0) {
|
|
@@ -21335,6 +21660,14 @@ var ResearchOrchestrationService = class {
|
|
|
21335
21660
|
metrics.observe("research_manager_latency_ms", researchDuration, { depth: String(depth), status: "error", source: "extension" });
|
|
21336
21661
|
metrics.increment("research_manager_requests_total", 1, { depth: String(depth), status: "error", source: "extension" });
|
|
21337
21662
|
throw error;
|
|
21663
|
+
} finally {
|
|
21664
|
+
if (runSlot) {
|
|
21665
|
+
await runSlot.release().catch((relErr) => {
|
|
21666
|
+
logger.warn(
|
|
21667
|
+
`[ResearchOrchestrationService] Failed to release run slot ${runSlot?.slotIndex}; capacity is reduced until this process exits: ${relErr instanceof Error ? relErr.message : String(relErr)}`
|
|
21668
|
+
);
|
|
21669
|
+
});
|
|
21670
|
+
}
|
|
21338
21671
|
}
|
|
21339
21672
|
}
|
|
21340
21673
|
/**
|
|
@@ -21775,10 +22108,63 @@ var ResearchSessionService = class {
|
|
|
21775
22108
|
// src/orchestration/research-synthesis-service.ts
|
|
21776
22109
|
init_text_utils();
|
|
21777
22110
|
init_text_utils();
|
|
22111
|
+
init_url_utils();
|
|
21778
22112
|
init_logger();
|
|
21779
22113
|
init_service_registry();
|
|
21780
22114
|
init_service_names();
|
|
21781
22115
|
var NO_SOURCES_NOTE = "CITED LINKS\n_No web sources were successfully retrieved for this report \u2014 searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._";
|
|
22116
|
+
var UNVERIFIED_LINKS_NOTE = "_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented \u2014 check each one before relying on it._";
|
|
22117
|
+
var MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
22118
|
+
function rewriteCitationMarkers(text, remap, isDangling) {
|
|
22119
|
+
const inFence = fencedCodeMask(text);
|
|
22120
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
|
|
22121
|
+
const n = parseInt(digits, 10);
|
|
22122
|
+
const mapped = remap.get(n);
|
|
22123
|
+
if (mapped !== void 0) return `${pad}[${mapped}]`;
|
|
22124
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
22125
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
22126
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22127
|
+
return text.slice(lineStart, offset).trim() === "" ? pad : "";
|
|
22128
|
+
});
|
|
22129
|
+
}
|
|
22130
|
+
function fencedCodeMask(text) {
|
|
22131
|
+
const ranges = [];
|
|
22132
|
+
let open4 = -1;
|
|
22133
|
+
let pos = 0;
|
|
22134
|
+
for (const line of text.split("\n")) {
|
|
22135
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
22136
|
+
if (open4 < 0) open4 = pos;
|
|
22137
|
+
else {
|
|
22138
|
+
ranges.push([open4, pos + line.length]);
|
|
22139
|
+
open4 = -1;
|
|
22140
|
+
}
|
|
22141
|
+
}
|
|
22142
|
+
pos += line.length + 1;
|
|
22143
|
+
}
|
|
22144
|
+
if (open4 >= 0) ranges.push([open4, text.length]);
|
|
22145
|
+
return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
22146
|
+
}
|
|
22147
|
+
function inInlineCodeSpan(text, offset) {
|
|
22148
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22149
|
+
let ticks = 0;
|
|
22150
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
|
|
22151
|
+
return ticks % 2 === 1;
|
|
22152
|
+
}
|
|
22153
|
+
function redactUnverifiedProseUrls(text, trusted) {
|
|
22154
|
+
if (trusted.size === 0) return text;
|
|
22155
|
+
let removed = 0;
|
|
22156
|
+
const result = text.replace(/https?:\/\/[^\s<>"']+/gi, (token) => {
|
|
22157
|
+
const core = stripTrailingLlmPunctuation(token);
|
|
22158
|
+
const tail = token.slice(core.length);
|
|
22159
|
+
if (!core || trusted.has(normalizeUrl(core))) return token;
|
|
22160
|
+
removed++;
|
|
22161
|
+
return `[link removed: not found in retrieved sources]${tail}`;
|
|
22162
|
+
});
|
|
22163
|
+
if (removed > 0) {
|
|
22164
|
+
logger.warn(`[ResearchSynthesisService] Redacted ${removed} unverified inline URL(s) from synthesis prose (not present in retrieved sources).`);
|
|
22165
|
+
}
|
|
22166
|
+
return result;
|
|
22167
|
+
}
|
|
21782
22168
|
var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
21783
22169
|
name = ServiceNames.RESEARCH_SYNTHESIS_SERVICE;
|
|
21784
22170
|
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
@@ -21932,35 +22318,42 @@ Research performed using ${modelId}`;
|
|
|
21932
22318
|
}
|
|
21933
22319
|
if (globalCitations.length > 0) {
|
|
21934
22320
|
const verifiedLinksSection = formatCitedLinks(globalCitations);
|
|
22321
|
+
const trustedUrls = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
|
|
21935
22322
|
const citedHeaderIdx = lastCitedLinksHeaderIndex(synthesis);
|
|
21936
22323
|
if (citedHeaderIdx >= 0) {
|
|
21937
22324
|
logger.debug("[ResearchSynthesisService] Replacing existing CITED LINKS with verified version");
|
|
21938
22325
|
const ownCitations = parseCitations(synthesis);
|
|
21939
22326
|
const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
|
|
21940
22327
|
const localToGlobal = /* @__PURE__ */ new Map();
|
|
22328
|
+
const droppedNumbers = /* @__PURE__ */ new Set();
|
|
21941
22329
|
ownCitations.forEach((cit, index) => {
|
|
22330
|
+
const localKey = cit.number ?? index + 1;
|
|
21942
22331
|
const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
|
|
21943
|
-
if (globalId !== void 0) localToGlobal.set(
|
|
22332
|
+
if (globalId !== void 0) localToGlobal.set(localKey, globalId);
|
|
22333
|
+
else droppedNumbers.add(localKey);
|
|
21944
22334
|
});
|
|
21945
22335
|
let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
|
|
21946
|
-
|
|
21947
|
-
|
|
21948
|
-
body = body.replace(/\[(\d+)\]/g, (match, p1) => {
|
|
21949
|
-
const globalId = localToGlobal.get(parseInt(p1, 10));
|
|
21950
|
-
return globalId !== void 0 ? `[${globalId}]` : match;
|
|
21951
|
-
});
|
|
22336
|
+
if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
|
|
22337
|
+
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
21952
22338
|
}
|
|
22339
|
+
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
21953
22340
|
return `${body}
|
|
21954
22341
|
|
|
21955
22342
|
${verifiedLinksSection}`;
|
|
21956
22343
|
}
|
|
21957
22344
|
logger.warn("[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version");
|
|
21958
|
-
|
|
22345
|
+
const validIds = new Set(globalCitations.map((c) => c.id));
|
|
22346
|
+
let prose = rewriteCitationMarkers(synthesis.trim(), /* @__PURE__ */ new Map(), (n) => !validIds.has(n));
|
|
22347
|
+
prose = redactUnverifiedProseUrls(prose, trustedUrls);
|
|
22348
|
+
return `${prose}
|
|
21959
22349
|
|
|
21960
22350
|
${verifiedLinksSection}`;
|
|
21961
22351
|
}
|
|
21962
22352
|
if (/CITED LINKS/i.test(synthesis)) {
|
|
21963
|
-
|
|
22353
|
+
logger.warn("[ResearchSynthesisService] Preserving an unparseable CITED LINKS block from a run that retrieved no sources \u2014 flagging it as unverified.");
|
|
22354
|
+
return `${synthesis.trimEnd()}
|
|
22355
|
+
|
|
22356
|
+
${UNVERIFIED_LINKS_NOTE}`;
|
|
21964
22357
|
}
|
|
21965
22358
|
logger.warn("[ResearchSynthesisService] No citations and no scrape provenance \u2014 appending explicit no-sources note.");
|
|
21966
22359
|
return `${synthesis.trim()}
|
|
@@ -22662,7 +23055,7 @@ init_logger();
|
|
|
22662
23055
|
init_constants();
|
|
22663
23056
|
import { promises as fs14 } from "node:fs";
|
|
22664
23057
|
import { homedir as homedir4, tmpdir as tmpdir4, platform as platform3 } from "node:os";
|
|
22665
|
-
import { join as
|
|
23058
|
+
import { join as join18, resolve as resolve3, normalize, sep as sep2 } from "node:path";
|
|
22666
23059
|
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
22667
23060
|
var PREFERRED_SUBDIRS = [
|
|
22668
23061
|
"research",
|
|
@@ -22733,7 +23126,7 @@ async function findPreferredSubdir(cwd) {
|
|
|
22733
23126
|
for (const target of PREFERRED_SUBDIRS) {
|
|
22734
23127
|
const match = entries.find((e) => e.toLowerCase() === target);
|
|
22735
23128
|
if (match === void 0) continue;
|
|
22736
|
-
const candidate =
|
|
23129
|
+
const candidate = join18(cwd, match);
|
|
22737
23130
|
try {
|
|
22738
23131
|
const stat5 = await fs14.stat(candidate);
|
|
22739
23132
|
if (stat5.isDirectory()) return candidate;
|
|
@@ -22778,7 +23171,7 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
|
|
|
22778
23171
|
for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
|
|
22779
23172
|
const hash = generateHash();
|
|
22780
23173
|
const filename = `${baseFilename}-${hash}.md`;
|
|
22781
|
-
const filepath =
|
|
23174
|
+
const filepath = join18(targetDir, filename);
|
|
22782
23175
|
try {
|
|
22783
23176
|
await fs14.writeFile(filepath, result, { flag: "wx" });
|
|
22784
23177
|
logger.log(`[export] Research report saved to: ${filepath}`);
|