@lincoln504/pi-research 1.0.10 → 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 +444 -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/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()) {
|
|
@@ -21211,6 +21514,8 @@ var DeepResearchOrchestrator = class {
|
|
|
21211
21514
|
} catch (obsErr) {
|
|
21212
21515
|
logger.debug("[DeepOrchestrator] onComplete observer threw:", obsErr);
|
|
21213
21516
|
}
|
|
21517
|
+
logger.debug(`[DeepOrchestrator] Final synthesis for ${researchId} (${result.length} chars):
|
|
21518
|
+
${result}`);
|
|
21214
21519
|
return result;
|
|
21215
21520
|
} catch (error) {
|
|
21216
21521
|
const sessionDuration = Date.now() - this.sessionStart;
|
|
@@ -21234,6 +21539,8 @@ var DeepResearchOrchestrator = class {
|
|
|
21234
21539
|
} catch (obsErr) {
|
|
21235
21540
|
logger.debug("[DeepOrchestrator] fallback onComplete observer threw:", obsErr);
|
|
21236
21541
|
}
|
|
21542
|
+
logger.debug(`[DeepOrchestrator] Fallback synthesis for ${researchId} (${result.length} chars):
|
|
21543
|
+
${result}`);
|
|
21237
21544
|
return result;
|
|
21238
21545
|
}
|
|
21239
21546
|
} catch (fallbackErr) {
|
|
@@ -21300,6 +21607,17 @@ var ResearchOrchestrationService = class {
|
|
|
21300
21607
|
const selectedModel = await this.resolveResearchModel(options);
|
|
21301
21608
|
logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
|
|
21302
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
|
+
}
|
|
21303
21621
|
let result;
|
|
21304
21622
|
try {
|
|
21305
21623
|
if (depth === 0) {
|
|
@@ -21342,6 +21660,14 @@ var ResearchOrchestrationService = class {
|
|
|
21342
21660
|
metrics.observe("research_manager_latency_ms", researchDuration, { depth: String(depth), status: "error", source: "extension" });
|
|
21343
21661
|
metrics.increment("research_manager_requests_total", 1, { depth: String(depth), status: "error", source: "extension" });
|
|
21344
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
|
+
}
|
|
21345
21671
|
}
|
|
21346
21672
|
}
|
|
21347
21673
|
/**
|
|
@@ -21782,10 +22108,63 @@ var ResearchSessionService = class {
|
|
|
21782
22108
|
// src/orchestration/research-synthesis-service.ts
|
|
21783
22109
|
init_text_utils();
|
|
21784
22110
|
init_text_utils();
|
|
22111
|
+
init_url_utils();
|
|
21785
22112
|
init_logger();
|
|
21786
22113
|
init_service_registry();
|
|
21787
22114
|
init_service_names();
|
|
21788
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
|
+
}
|
|
21789
22168
|
var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
21790
22169
|
name = ServiceNames.RESEARCH_SYNTHESIS_SERVICE;
|
|
21791
22170
|
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
@@ -21939,35 +22318,42 @@ Research performed using ${modelId}`;
|
|
|
21939
22318
|
}
|
|
21940
22319
|
if (globalCitations.length > 0) {
|
|
21941
22320
|
const verifiedLinksSection = formatCitedLinks(globalCitations);
|
|
22321
|
+
const trustedUrls = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
|
|
21942
22322
|
const citedHeaderIdx = lastCitedLinksHeaderIndex(synthesis);
|
|
21943
22323
|
if (citedHeaderIdx >= 0) {
|
|
21944
22324
|
logger.debug("[ResearchSynthesisService] Replacing existing CITED LINKS with verified version");
|
|
21945
22325
|
const ownCitations = parseCitations(synthesis);
|
|
21946
22326
|
const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
|
|
21947
22327
|
const localToGlobal = /* @__PURE__ */ new Map();
|
|
22328
|
+
const droppedNumbers = /* @__PURE__ */ new Set();
|
|
21948
22329
|
ownCitations.forEach((cit, index) => {
|
|
22330
|
+
const localKey = cit.number ?? index + 1;
|
|
21949
22331
|
const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
|
|
21950
|
-
if (globalId !== void 0) localToGlobal.set(
|
|
22332
|
+
if (globalId !== void 0) localToGlobal.set(localKey, globalId);
|
|
22333
|
+
else droppedNumbers.add(localKey);
|
|
21951
22334
|
});
|
|
21952
22335
|
let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
|
|
21953
|
-
|
|
21954
|
-
|
|
21955
|
-
body = body.replace(/\[(\d+)\]/g, (match, p1) => {
|
|
21956
|
-
const globalId = localToGlobal.get(parseInt(p1, 10));
|
|
21957
|
-
return globalId !== void 0 ? `[${globalId}]` : match;
|
|
21958
|
-
});
|
|
22336
|
+
if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
|
|
22337
|
+
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
21959
22338
|
}
|
|
22339
|
+
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
21960
22340
|
return `${body}
|
|
21961
22341
|
|
|
21962
22342
|
${verifiedLinksSection}`;
|
|
21963
22343
|
}
|
|
21964
22344
|
logger.warn("[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version");
|
|
21965
|
-
|
|
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}
|
|
21966
22349
|
|
|
21967
22350
|
${verifiedLinksSection}`;
|
|
21968
22351
|
}
|
|
21969
22352
|
if (/CITED LINKS/i.test(synthesis)) {
|
|
21970
|
-
|
|
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}`;
|
|
21971
22357
|
}
|
|
21972
22358
|
logger.warn("[ResearchSynthesisService] No citations and no scrape provenance \u2014 appending explicit no-sources note.");
|
|
21973
22359
|
return `${synthesis.trim()}
|
|
@@ -22669,7 +23055,7 @@ init_logger();
|
|
|
22669
23055
|
init_constants();
|
|
22670
23056
|
import { promises as fs14 } from "node:fs";
|
|
22671
23057
|
import { homedir as homedir4, tmpdir as tmpdir4, platform as platform3 } from "node:os";
|
|
22672
|
-
import { join as
|
|
23058
|
+
import { join as join18, resolve as resolve3, normalize, sep as sep2 } from "node:path";
|
|
22673
23059
|
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
22674
23060
|
var PREFERRED_SUBDIRS = [
|
|
22675
23061
|
"research",
|
|
@@ -22740,7 +23126,7 @@ async function findPreferredSubdir(cwd) {
|
|
|
22740
23126
|
for (const target of PREFERRED_SUBDIRS) {
|
|
22741
23127
|
const match = entries.find((e) => e.toLowerCase() === target);
|
|
22742
23128
|
if (match === void 0) continue;
|
|
22743
|
-
const candidate =
|
|
23129
|
+
const candidate = join18(cwd, match);
|
|
22744
23130
|
try {
|
|
22745
23131
|
const stat5 = await fs14.stat(candidate);
|
|
22746
23132
|
if (stat5.isDirectory()) return candidate;
|
|
@@ -22785,7 +23171,7 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
|
|
|
22785
23171
|
for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
|
|
22786
23172
|
const hash = generateHash();
|
|
22787
23173
|
const filename = `${baseFilename}-${hash}.md`;
|
|
22788
|
-
const filepath =
|
|
23174
|
+
const filepath = join18(targetDir, filename);
|
|
22789
23175
|
try {
|
|
22790
23176
|
await fs14.writeFile(filepath, result, { flag: "wx" });
|
|
22791
23177
|
logger.log(`[export] Research report saved to: ${filepath}`);
|