@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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Research Run Semaphore
3
+ *
4
+ * Cross-process cap on the number of concurrent research runs, built on the
5
+ * verified {@link FileLockService}. This is the Phase 1 "run-cap" mitigation for
6
+ * the parallel-runs degradation documented in
7
+ * INVESTIGATION-2026-08-02-parallel-runs-sigabrt.md: when more than N research
8
+ * runs pile onto a single leader-elected browser/embedding pool, the shared
9
+ * priority queue saturates, runs churn through leader elections, and research
10
+ * degrades into ~60-error failures. Capping concurrency at the orchestration
11
+ * entry stops that at the source.
12
+ *
13
+ * ## Design
14
+ *
15
+ * A counting semaphore of N slots is realized as N well-known slot files
16
+ * (`research-run-slot-<i>.lock`) in the shared state directory, coordinated with
17
+ * {@link FileLockService}.
18
+ *
19
+ * - **Acquire** tries each slot once (round-robin from a rotating start index to
20
+ * spread contention). Each per-slot attempt builds a **fresh** `FileLockService`
21
+ * configured as a *try-take* (`lockTimeout: 0`): it either wins the slot (free,
22
+ * or reclaimed from a *dead* owner) immediately or throws as soon as it sees a
23
+ * *live* owner. If every slot is held by a live process, the loop waits
24
+ * `pollDelayMs` and retries until `maxWaitMs` elapses, then rejects with
25
+ * {@link ResearchRunCapacityError} (fail-fast with a clear message rather than
26
+ * a 60-error degraded run).
27
+ * - **Release** unlinks the slot file via the captured slot service.
28
+ * - **Crash recovery** is automatic and PID-reuse-safe: a slot held by a process
29
+ * that has died (killed, crashed, SIGKILLed by earlyoom) is reclaimed
30
+ * immediately on the next acquire attempt, because `FileLockService` checks the
31
+ * owner's PID+startTime liveness before treating a lock as stale.
32
+ *
33
+ * ### Why a *fresh* FileLockService per attempt
34
+ * `FileLockService.acquireLock()` serializes callers on a per-instance FIFO queue
35
+ * and only releases that queue turn in `releaseLock()` — i.e. after the run ends.
36
+ * Reusing a persistent per-slot instance would therefore **deadlock concurrent
37
+ * research starts within one process**: the first holder keeps slot[i]'s queue
38
+ * turn for minutes, blocking a second acquire on that slot forever (it would
39
+ * never reach the other slots). A throwaway instance per attempt has an empty
40
+ * queue, so concurrent attempts never block on stale queue state — while still
41
+ * reusing all of FileLockService's verified atomic-create / liveness / reclaim
42
+ * logic. Two concurrent attempts on the *same* slot simply race on `fs.open(wx)`:
43
+ * one wins, the other reads a live owner and fails fast to the next slot.
44
+ *
45
+ * ### Why `liveOwnerStaleThreshold` is effectively infinite
46
+ * A research run legitimately holds its slot for *minutes*. `FileLockService`
47
+ * would normally steal a lock from a *live* owner once it ages past
48
+ * `liveOwnerStaleThreshold` (to recover a process stalled mid-critical-section).
49
+ * For a run-cap that is exactly wrong: stealing a live run's slot would silently
50
+ * admit an (N+1)th concurrent run — reintroducing the saturation this guard
51
+ * exists to prevent. So slots are configured to *never* steal from a live owner;
52
+ * only provably-dead owners are reclaimed (immediately, regardless of age).
53
+ *
54
+ * ### Concurrency within one process
55
+ * `acquire()` returns an independent {@link RunSlotAcquisition} handle capturing
56
+ * its own slot service; it stores *no* shared "held slot" state. Two concurrent
57
+ * `runResearch` calls on the same singleton service each acquire a distinct slot
58
+ * safely (see "fresh instance per attempt" above), with no risk of one release
59
+ * clobbering the other's tracking.
60
+ */
61
+
62
+ import * as pathmod from 'node:path';
63
+ import { logger } from '../logger.ts';
64
+ import { metrics } from '../utils/metrics.ts';
65
+ import { FileLockService } from './file-lock-service.ts';
66
+ import { ServiceLifecycle } from '../core/service-registry.ts';
67
+ import { ServiceNames } from '../core/interfaces/service-names.ts';
68
+ import type { IService } from '../core/service-registry.ts';
69
+ import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts';
70
+
71
+ /** Default maximum number of concurrent research runs across all processes. */
72
+ export const DEFAULT_MAX_CONCURRENT_RUNS = 2;
73
+ /** Default per-acquire wait before failing fast when all slots are busy (0 = fail-fast). */
74
+ export const DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
75
+ /** Poll interval between full slot sweeps while waiting for a slot. */
76
+ const POLL_DELAY_MS = 250;
77
+
78
+ /**
79
+ * Thrown when every run slot is held by a live process and the acquire timeout
80
+ * has elapsed. Distinct from internal/IO errors so callers can treat
81
+ * "capacity exhausted" (retry later) differently from "the semaphore is broken".
82
+ */
83
+ export class ResearchRunCapacityError extends Error {
84
+ readonly slots: number;
85
+ constructor(slots: number, maxWaitMs: number) {
86
+ super(
87
+ `Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after waiting ${maxWaitMs}ms` : ''}; ` +
88
+ `wait for an in-progress run to finish and retry. ` +
89
+ `(Override with PI_RESEARCH_MAX_CONCURRENT_RUNS.)`,
90
+ );
91
+ this.name = 'ResearchRunCapacityError';
92
+ this.slots = slots;
93
+ }
94
+ }
95
+
96
+ /** A held run slot. Call {@link RunSlotAcquisition.release} exactly once when the run ends. */
97
+ export interface RunSlotAcquisition {
98
+ /** The 0-based slot index that was acquired (observability). */
99
+ readonly slotIndex: number;
100
+ /** Release the slot back to the pool. Safe to call once; subsequent calls are no-ops. */
101
+ release(): Promise<void>;
102
+ }
103
+
104
+ /**
105
+ * Counting semaphore over N slot files coordinated with {@link FileLockService}.
106
+ */
107
+ export class ResearchRunSemaphore implements IService {
108
+ readonly name = ServiceNames.RESEARCH_RUN_SEMAPHORE;
109
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
110
+
111
+ private readonly slotDir: string;
112
+ private readonly processLifecycle: IProcessLifecycle | null;
113
+ private readonly maxSlots: number;
114
+ private readonly defaultMaxWaitMs: number;
115
+ /** Rotating start index so contending processes don't all hammer slot 0 first. */
116
+ private nextStartIndex = 0;
117
+ private _initialized = false;
118
+
119
+ /**
120
+ * @param slotDir Directory holding the `research-run-slot-<i>.lock` files.
121
+ * @param processLifecycle PID+startTime liveness source (PID-reuse-safe reclamation).
122
+ * @param maxSlots N — maximum concurrent runs. Defaults from env then {@link DEFAULT_MAX_CONCURRENT_RUNS}.
123
+ * @param defaultMaxWaitMs Default acquire wait. Defaults from env then {@link DEFAULT_ACQUIRE_TIMEOUT_MS}.
124
+ */
125
+ constructor(slotDir: string, processLifecycle: IProcessLifecycle | null, maxSlots?: number, defaultMaxWaitMs?: number) {
126
+ this.slotDir = slotDir;
127
+ this.processLifecycle = processLifecycle;
128
+ this.maxSlots = Math.max(1, Math.floor(
129
+ maxSlots ?? numberFromEnv('PI_RESEARCH_MAX_CONCURRENT_RUNS') ?? DEFAULT_MAX_CONCURRENT_RUNS,
130
+ ));
131
+ this.defaultMaxWaitMs = Math.max(0, Math.floor(
132
+ defaultMaxWaitMs ?? numberFromEnv('PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS') ?? DEFAULT_ACQUIRE_TIMEOUT_MS,
133
+ ));
134
+ }
135
+
136
+ /** Number of slots (max concurrent runs). */
137
+ getMaxSlots(): number {
138
+ return this.maxSlots;
139
+ }
140
+
141
+ async initialize(): Promise<void> {
142
+ if (this._initialized) return;
143
+ this.lifecycle = ServiceLifecycle.INITIALIZING;
144
+ // Ensure the slot directory exists; slot files are created lazily on acquire.
145
+ try {
146
+ await import('node:fs/promises').then((fs) => fs.mkdir(this.slotDir, { recursive: true, mode: 0o700 }));
147
+ } catch (err) {
148
+ logger.warn(`[ResearchRunSemaphore] Could not create slot dir ${this.slotDir}: ${err instanceof Error ? err.message : String(err)}`);
149
+ }
150
+ this._initialized = true;
151
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
152
+ logger.log(`[ResearchRunSemaphore] Initialized with ${this.maxSlots} run slot(s) (acquire timeout ${this.defaultMaxWaitMs}ms).`);
153
+ }
154
+
155
+ async dispose(): Promise<void> {
156
+ // Held slots are released by their RunSlotAcquisition handles (the runResearch
157
+ // finally block). A hard process exit leaves slot files behind, which the next
158
+ // acquire reclaims via PID+startTime liveness — so dispose has nothing to do.
159
+ this.lifecycle = ServiceLifecycle.DISPOSED;
160
+ }
161
+
162
+ /**
163
+ * Try to win one slot NON-BLOCKING. Builds a fresh {@link FileLockService}
164
+ * configured as a try-take (see class doc: "fresh instance per attempt").
165
+ * Returns an acquisition on success, or null if the slot is busy / lost a race.
166
+ */
167
+ private async trySlot(slotIndex: number): Promise<RunSlotAcquisition | null> {
168
+ const slot = new FileLockService({
169
+ lockFilePath: pathmod.join(this.slotDir, `research-run-slot-${slotIndex}.lock`),
170
+ lockTimeout: 0, // fail immediately on a live-contended slot (try-take)
171
+ lockRetries: 24, // enough iterations to complete a dead-owner reclaim + reacquire
172
+ lockRetryDelay: 50,
173
+ lockStaleThreshold: 15000, // legacy (no-pid) locks: reclaim once 15s old
174
+ // A live process holding a run slot for the entire (minutes-long) run is the
175
+ // NORMAL case, not a stall. Never steal from a live owner — doing so would
176
+ // admit an (N+1)th concurrent run, the exact condition this guard prevents.
177
+ // Dead owners are reclaimed immediately regardless of age.
178
+ liveOwnerStaleThreshold: Number.MAX_SAFE_INTEGER,
179
+ processLifecycle: this.processLifecycle ?? undefined,
180
+ });
181
+ try {
182
+ await slot.initialize(); // resolves own startTime (cached) + startup stale cleanup
183
+ await slot.acquireLock(); // try-take: wins, reclaims a dead owner, or throws fast
184
+ } catch {
185
+ return null; // busy (live owner) or lost a reclaim race — try the next slot
186
+ }
187
+ return {
188
+ slotIndex,
189
+ release: async () => {
190
+ try {
191
+ await slot.releaseLock();
192
+ } catch (err) {
193
+ logger.warn(`[ResearchRunSemaphore] Failed to release slot ${slotIndex}: ${err instanceof Error ? err.message : String(err)}`);
194
+ } finally {
195
+ // Clear the gauge even if the unlink failed: the slot is no longer held
196
+ // by THIS process either way, and a gauge that only ever ratchets to 1
197
+ // reports permanent saturation for the life of the process.
198
+ metrics.setGauge('research_run_slots_held', 0, { slot: String(slotIndex) });
199
+ }
200
+ },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Acquire one run slot, waiting up to `maxWaitMs` (defaults to the constructed
206
+ * value) when all slots are held by live processes. Resolves with a handle that
207
+ * must be released when the run ends.
208
+ *
209
+ * @param signal aborts the wait. Without it a run started under a long
210
+ * `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
211
+ * passed, because the wait happens before any orchestrator exists to cancel.
212
+ * @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
213
+ * @throws {Error} named `AbortError` if `signal` aborts while waiting.
214
+ */
215
+ async acquire(maxWaitMs: number = this.defaultMaxWaitMs, signal?: AbortSignal): Promise<RunSlotAcquisition> {
216
+ if (!this._initialized) await this.initialize();
217
+ if (signal?.aborted) {
218
+ metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
219
+ throwIfAborted(signal);
220
+ }
221
+
222
+ const deadline = Date.now() + Math.max(0, maxWaitMs);
223
+ for (;;) {
224
+ // Round-robin from a rotating start so contending processes don't synchronize on slot 0.
225
+ const start = this.nextStartIndex;
226
+ this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
227
+ for (let offset = 0; offset < this.maxSlots; offset++) {
228
+ const i = (start + offset) % this.maxSlots;
229
+ const got = await this.trySlot(i);
230
+ if (got) {
231
+ metrics.setGauge('research_run_slots_held', 1, { slot: String(i) });
232
+ metrics.increment('research_run_acquire_total', 1, { status: 'acquired' });
233
+ return got;
234
+ }
235
+ }
236
+ // Every slot is held by a live process.
237
+ if (Date.now() >= deadline) {
238
+ metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
239
+ throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
240
+ }
241
+ // Count the aborted outcome too, so acquired + exhausted + aborted accounts for
242
+ // every acquire attempt. A cancel that vanished from the counter would make the
243
+ // capacity metrics quietly under-report demand.
244
+ try {
245
+ await sleep(POLL_DELAY_MS, signal);
246
+ } catch (err) {
247
+ metrics.increment('research_run_acquire_total', 1, { status: 'aborted' });
248
+ throw err;
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ function throwIfAborted(signal?: AbortSignal): void {
255
+ if (signal?.aborted) {
256
+ throw Object.assign(new Error('Research run slot acquisition aborted'), { name: 'AbortError' });
257
+ }
258
+ }
259
+
260
+ function numberFromEnv(key: string): number | undefined {
261
+ const raw = process.env[key];
262
+ if (raw === undefined || raw === '') return undefined;
263
+ const n = Number(raw);
264
+ return Number.isFinite(n) ? n : undefined;
265
+ }
266
+
267
+ /**
268
+ * Poll delay that resolves early (rejecting) on abort.
269
+ *
270
+ * The timer is deliberately NOT `unref`'d. Waiting for a run slot is foreground work: a process
271
+ * whose only pending handle is this timer must stay alive until the slot frees, and an unref'd
272
+ * timer would let Node drain the loop and exit mid-wait (observed as an early exit in the
273
+ * multi-process harness). The timer is always settled — it either fires or is cleared on abort —
274
+ * so keeping it referenced cannot outlive the acquire.
275
+ */
276
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
277
+ return new Promise((resolve, reject) => {
278
+ const timer = setTimeout(() => {
279
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
280
+ resolve();
281
+ }, ms);
282
+ const onAbort = signal
283
+ ? () => {
284
+ clearTimeout(timer);
285
+ reject(Object.assign(new Error('Research run slot acquisition aborted'), { name: 'AbortError' }));
286
+ }
287
+ : undefined;
288
+ if (onAbort) signal?.addEventListener('abort', onAbort, { once: true });
289
+ });
290
+ }
@@ -22,6 +22,7 @@ import { StateValidator } from './state/state-validator.ts';
22
22
  import { WorkerPoolManager } from './browser/worker-pool-manager.ts';
23
23
  import { StatePathConfiguration } from './state/state-path-configuration.ts';
24
24
  import { FileLockService } from './file-lock-service.ts';
25
+ import { ResearchRunSemaphore } from './research-run-semaphore.ts';
25
26
  import { StateBackupManager } from './state/state-backup-manager.ts';
26
27
  import { logger } from '../logger.ts';
27
28
 
@@ -73,6 +74,25 @@ export function registerInfrastructureServices(container: ServiceContainer = get
73
74
  container
74
75
  );
75
76
 
77
+ // Register Research Run Semaphore (Phase 1 cross-process run-cap).
78
+ // Depends on StatePathConfiguration (slot dir) and ProcessLifecycle (PID-reuse-safe
79
+ // reclamation). Lazy: only built on first research run. Acquired at runResearch()
80
+ // entry; released in its finally. A bug here fails OPEN (research proceeds) so it
81
+ // can never break research; genuine capacity exhaustion fails fast with a clear error.
82
+ registerService(
83
+ ServiceNames.RESEARCH_RUN_SEMAPHORE,
84
+ async () => {
85
+ const pathConfig = await getService<StatePathConfiguration>(ServiceNames.STATE_PATH_CONFIGURATION, undefined, container);
86
+ const processLifecycle = await getService<ProcessLifecycleService>(ServiceNames.PROCESS_LIFECYCLE, undefined, container);
87
+ return new ResearchRunSemaphore(pathConfig.getStateDir(), processLifecycle);
88
+ },
89
+ {
90
+ allowOverwrite: false,
91
+ enableLogging: true,
92
+ },
93
+ container
94
+ );
95
+
76
96
  // Register State Backup Manager Service
77
97
  // Depends on StatePathConfiguration
78
98
  registerService(
@@ -555,6 +555,16 @@ export class DeepResearchOrchestrator {
555
555
  logger.debug('[DeepOrchestrator] onComplete observer threw:', obsErr);
556
556
  }
557
557
 
558
+ // Observability: log the FULL final synthesis body. Every other layer —
559
+ // researcher prompts, researcher final responses, searches, scrapes — is logged
560
+ // at DEBUG; the synthesis itself (the actual bytes returned to the caller) was
561
+ // the one conspicuous exception, which made post-hoc diagnosis of citation /
562
+ // output issues impossible (a reported "the engine returned placeholder X"
563
+ // could not be confirmed or refuted from the logs). Mirrors the DEBUG logging
564
+ // of researcher final responses. Truncation is deliberately NOT applied: the
565
+ // raw bytes are exactly what's needed to diagnose grounding failures.
566
+ logger.debug(`[DeepOrchestrator] Final synthesis for ${researchId} (${result.length} chars):\n${result}`);
567
+
558
568
  return result;
559
569
 
560
570
  } catch (error) {
@@ -589,6 +599,7 @@ export class DeepResearchOrchestrator {
589
599
  } catch (obsErr) {
590
600
  logger.debug('[DeepOrchestrator] fallback onComplete observer threw:', obsErr);
591
601
  }
602
+ logger.debug(`[DeepOrchestrator] Fallback synthesis for ${researchId} (${result.length} chars):\n${result}`);
592
603
  return result;
593
604
  }
594
605
  } catch (fallbackErr) {
@@ -37,6 +37,7 @@ import type { Config } from '../config.ts';
37
37
  import { getConfig, DEFAULTS } from '../config.ts';
38
38
  import { getCachedScrapedContent, normalizeUrl, cleanupSharedLinks } from '../utils/shared-links.ts';
39
39
  import { runResearcher } from './researcher-executor.ts';
40
+ import { ResearchRunSemaphore, ResearchRunCapacityError } from '../infrastructure/research-run-semaphore.ts';
40
41
  import { recordResearcherFailure, shouldStopResearch, createResearchStopError } from './session-state.ts';
41
42
  import type { ResearchSessionService } from './research-session-service.ts';
42
43
  import { QuickResearchOrchestrator } from './quick-research-orchestrator.ts';
@@ -107,6 +108,26 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
107
108
 
108
109
  const researchStart = Date.now();
109
110
 
111
+ // Cross-process run-cap (Phase 1): acquire one of N slots so concurrent
112
+ // research runs can't saturate the shared leader-elected browser/embedding
113
+ // pool and degrade into ~60-error failures. Fail-OPEN on internal/IO errors
114
+ // (a semaphore bug must never break research); only a genuine
115
+ // ResearchRunCapacityError (all slots held by live processes) propagates,
116
+ // failing the run fast with a clear message. The slot is released in finally.
117
+ let runSlot: { slotIndex: number; release(): Promise<void> } | null = null;
118
+ try {
119
+ const container = tryGetServiceContainerFromCtx(ctx);
120
+ const semaphore = await getService<ResearchRunSemaphore>(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
121
+ runSlot = await semaphore.acquire(undefined, signal);
122
+ logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
123
+ } catch (err) {
124
+ if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
125
+ // A cancel while queueing for a slot must stay cancelled. Fail-open here would
126
+ // start the very run the user just aborted.
127
+ if (err instanceof Error && err.name === 'AbortError') throw err;
128
+ logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
129
+ }
130
+
110
131
  let result: string;
111
132
  try {
112
133
  if (depth === 0) {
@@ -149,6 +170,20 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
149
170
  metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status: 'error', source: 'extension' });
150
171
  metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status: 'error', source: 'extension' });
151
172
  throw error;
173
+ } finally {
174
+ // Always release the run-cap slot — on success, error, or abort (the
175
+ // orchestrator throws on AbortSignal, hitting this finally before rethrow).
176
+ if (runSlot) {
177
+ // Never let a release failure mask the run's own outcome — but do report it.
178
+ // A slot that fails to release stays held for the lifetime of THIS process
179
+ // (liveness reclaim only fires once the owner is gone), so it silently costs
180
+ // one unit of capacity for every subsequent run in the session.
181
+ await runSlot.release().catch((relErr) => {
182
+ logger.warn(
183
+ `[ResearchOrchestrationService] Failed to release run slot ${runSlot?.slotIndex}; capacity is reduced until this process exits: ${relErr instanceof Error ? relErr.message : String(relErr)}`,
184
+ );
185
+ });
186
+ }
152
187
  }
153
188
  }
154
189
 
@@ -13,6 +13,7 @@ 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
15
  import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
16
+ import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
16
17
  import { logger } from '../logger.ts';
17
18
  import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
18
19
  import { ServiceNames } from '../core/interfaces/service-names.ts';
@@ -26,6 +27,125 @@ import type { SteeringMessage } from './session-state.ts';
26
27
  const NO_SOURCES_NOTE =
27
28
  'CITED LINKS\n_No web sources were successfully retrieved for this report — 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._';
28
29
 
30
+ // Appended when the model wrote its own links but the run retrieved nothing we could
31
+ // verify them against. The block is kept (it may still be a useful lead) but must not
32
+ // read as though the engine checked it.
33
+ const UNVERIFIED_LINKS_NOTE =
34
+ '_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 — check each one before relying on it._';
35
+
36
+ /**
37
+ * Largest `[N]` treated as a citation marker. Inline markers index a source list
38
+ * that is realistically well under this; the bound exists so an ordinary bracketed
39
+ * number in prose — a year (`[2023]`), an issue id, an array index — is left alone
40
+ * instead of being silently deleted as a "dangling citation".
41
+ */
42
+ const MAX_PLAUSIBLE_CITATION_MARKER = 200;
43
+
44
+ /**
45
+ * Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
46
+ * map, delete the ones known to dangle, leave everything else untouched.
47
+ *
48
+ * Deletion is whitespace-aware rather than followed by a global space collapse.
49
+ * A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
50
+ * silently flattens nested list levels into one and turns 4-space indented code
51
+ * blocks into paragraphs — corrupting the structure of every report that contains
52
+ * them. So the pad preceding a deleted marker is consumed only when the marker is
53
+ * mid-line (i.e. real prose spacing); line-leading indentation is preserved.
54
+ *
55
+ * Code is skipped entirely. A report may quote code containing bracketed integers
56
+ * (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
57
+ * as dangling — so without this guard a quoted array index would be silently deleted
58
+ * from the user's code sample. Fenced blocks are tracked across lines; single-backtick
59
+ * spans are detected by an odd backtick count earlier on the same line.
60
+ *
61
+ * @param remap local marker number → verified global id
62
+ * @param isDangling whether a marker not present in `remap` should be deleted
63
+ */
64
+ function rewriteCitationMarkers(
65
+ text: string,
66
+ remap: Map<number, number>,
67
+ isDangling: (n: number) => boolean,
68
+ ): string {
69
+ const inFence = fencedCodeMask(text);
70
+ return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
71
+ const n = parseInt(digits, 10);
72
+ const mapped = remap.get(n);
73
+ if (mapped !== undefined) return `${pad}[${mapped}]`;
74
+ // Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
75
+ if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
76
+ if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
77
+ // Preserve indentation when nothing but whitespace precedes on this line;
78
+ // otherwise drop the pad too so no double space is left behind.
79
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
80
+ return text.slice(lineStart, offset).trim() === '' ? pad : '';
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Build an `offset → is inside a ``` fenced block` predicate for `text`.
86
+ *
87
+ * Computed once per rewrite (a single line scan) rather than per marker, so marker
88
+ * rewriting stays linear in the body length.
89
+ */
90
+ function fencedCodeMask(text: string): (offset: number) => boolean {
91
+ const ranges: Array<[number, number]> = [];
92
+ let open = -1;
93
+ let pos = 0;
94
+ for (const line of text.split('\n')) {
95
+ if (/^\s{0,3}(```|~~~)/.test(line)) {
96
+ if (open < 0) open = pos;
97
+ else {
98
+ ranges.push([open, pos + line.length]);
99
+ open = -1;
100
+ }
101
+ }
102
+ pos += line.length + 1; // +1 for the '\n' consumed by split
103
+ }
104
+ // An unterminated fence runs to the end of the body — treat the remainder as code.
105
+ if (open >= 0) ranges.push([open, text.length]);
106
+ return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
107
+ }
108
+
109
+ /** Whether `offset` sits inside a single-backtick code span on its own line. */
110
+ function inInlineCodeSpan(text: string, offset: number): boolean {
111
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
112
+ let ticks = 0;
113
+ for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
114
+ return ticks % 2 === 1;
115
+ }
116
+
117
+ /**
118
+ * Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
119
+ * this session (absent from `trusted`, a set of normalized verified-source URLs).
120
+ *
121
+ * The synthesis body is otherwise passed through verbatim, so an unverified inline
122
+ * URL is the one channel by which a fabricated source can reach the user — and
123
+ * inline URLs violate the required format (links belong in CITED LINKS only).
124
+ * Legitimate inline references to real sources are preserved (they are present in
125
+ * `trusted`). Returns the (possibly) redacted text.
126
+ */
127
+ function redactUnverifiedProseUrls(text: string, trusted: Set<string>): string {
128
+ if (trusted.size === 0) return text;
129
+ let removed = 0;
130
+ // Brackets are NOT excluded from the token: a bare `[^\s)\]>"']+` truncates
131
+ // `.../Python_(programming_language)` at the first `)`, so the truncated form
132
+ // never matches its own entry in `trusted` and a perfectly good Wikipedia link
133
+ // gets redacted. Instead take the greedy token and split off exactly the
134
+ // trailing punctuation `normalizeUrl` itself discards — so the compared string
135
+ // and the redacted span always agree — restoring that tail afterwards.
136
+ const result = text.replace(/https?:\/\/[^\s<>"']+/gi, (token) => {
137
+ const core = stripTrailingLlmPunctuation(token);
138
+ const tail = token.slice(core.length);
139
+ if (!core || trusted.has(normalizeUrl(core))) return token;
140
+ removed++;
141
+ return `[link removed: not found in retrieved sources]${tail}`;
142
+ });
143
+ if (removed > 0) {
144
+ logger.warn(`[ResearchSynthesisService] Redacted ${removed} unverified inline URL(s) from synthesis prose (not present in retrieved sources).`);
145
+ }
146
+ return result;
147
+ }
148
+
29
149
  /**
30
150
  * Research Synthesis Service
31
151
  *
@@ -178,6 +298,18 @@ export class ResearchSynthesisService implements IService {
178
298
  let globalCitations: GlobalCitation[] =
179
299
  reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
180
300
 
301
+ // NOTE: a "provenance gate" previously DROPPED citations tagged Scrape/Transcript
302
+ // whose URL was absent from the session's successful-scrape pool. It was REMOVED
303
+ // because it conflated a failed/blocked scrape (real URL, content unretrieved) with
304
+ // fabrication: under the common high scrape-failure rate (bot blocks on GitHub /
305
+ // Microsoft / etc.) it mass-dropped legitimate citations, and the remap below then
306
+ // neutralized their inline [N] markers into a broken '[removed]' token that reached
307
+ // users. We now KEEP every parsed citation (deduped by URL) and never emit
308
+ // '[removed]'; a dangling inline marker is removed cleanly instead. Fabrication
309
+ // defense rests on the URL dedup + the prose-URL sweep, which do not punish failed
310
+ // scrapes. (The successful-scrape pool is still reconciled below to FOLD IN any
311
+ // sources the researchers scraped but failed to cite.)
312
+
181
313
  // Robustness fallback: reconcile against ground-truth provenance — the URLs
182
314
  // ACTUALLY, successfully scraped this session (registerScrapedLinks only records
183
315
  // HTTP-success fetches). This runs even when SOME citations parsed: a
@@ -216,6 +348,7 @@ export class ResearchSynthesisService implements IService {
216
348
 
217
349
  if (globalCitations.length > 0) {
218
350
  const verifiedLinksSection = formatCitedLinks(globalCitations);
351
+ const trustedUrls = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
219
352
  // Replace an existing CITED LINKS section with the verified version — but only
220
353
  // a line-leading, all-caps "CITED LINKS" header marks the section. An
221
354
  // unanchored /CITED LINKS[\s\S]*$/i would also fire on an incidental
@@ -225,31 +358,48 @@ export class ResearchSynthesisService implements IService {
225
358
  if (citedHeaderIdx >= 0) {
226
359
  logger.debug('[ResearchSynthesisService] Replacing existing CITED LINKS with verified version');
227
360
  // The verified list is renumbered (dedup by URL, implausible entries dropped),
228
- // but the body's inline [N] markers were authored against the list being
229
- // replaced. Remap them by URL identity — parse the synthesis's own list and
230
- // map each written number to the global id of the same normalized URL. In
231
- // deep mode the numbering already matches and this is a no-op; in quick mode
232
- // (where the model's own list is what gets replaced) it prevents every
233
- // marker after a dropped/deduped entry from pointing at the wrong source.
361
+ // but the body's inline [N] markers were authored against the list being replaced.
362
+ // Remap them by URL identity — parse the synthesis's own list and map each
363
+ // written number to the global id of the same normalized URL. In deep mode
364
+ // the numbering already matches and this is a no-op; in quick mode (where the
365
+ // model's own list is what gets replaced) it prevents every marker after a
366
+ // dropped/deduped entry from pointing at the wrong source.
234
367
  const ownCitations = parseCitations(synthesis);
235
368
  const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
236
369
  const localToGlobal = new Map<number, number>();
370
+ // own-citation numbers whose URL is absent from the verified list. Their inline
371
+ // [N] markers are REMOVED cleanly (never '[removed]') so the prose reads
372
+ // naturally. A marker can dangle only if the lead cited a URL absent from the
373
+ // rebuilt list (e.g. it invented one not in any researcher report); removing it
374
+ // beats shipping a broken token or silently retargeting it at the wrong source.
375
+ const droppedNumbers = new Set<number>();
237
376
  ownCitations.forEach((cit, index) => {
377
+ const localKey = cit.number ?? index + 1;
238
378
  const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
239
- if (globalId !== undefined) localToGlobal.set(cit.number ?? index + 1, globalId);
379
+ if (globalId !== undefined) localToGlobal.set(localKey, globalId);
380
+ else droppedNumbers.add(localKey);
240
381
  });
241
382
  let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
242
- const needsRemap = [...localToGlobal].some(([local, global]) => local !== global);
243
- if (needsRemap) {
244
- body = body.replace(/\[(\d+)\]/g, (match, p1) => {
245
- const globalId = localToGlobal.get(parseInt(p1, 10));
246
- return globalId !== undefined ? `[${globalId}]` : match;
247
- });
383
+ if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
384
+ body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
248
385
  }
386
+ // Defense-in-depth: redact unverified inline URLs in the prose body. The body
387
+ // is otherwise passed through verbatim, so this is the one channel by which a
388
+ // fabricated source can still reach the user; inline URLs also violate the
389
+ // required format. Verified inline references (in `trustedUrls`) are kept.
390
+ body = redactUnverifiedProseUrls(body, trustedUrls);
249
391
  return `${body}\n\n${verifiedLinksSection}`;
250
392
  }
251
393
  logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
252
- return `${synthesis.trim()}\n\n${verifiedLinksSection}`;
394
+ // No existing CITED LINKS header: the synthesis is all prose. The model's
395
+ // inline [N] markers were authored without a list we could remap against, so
396
+ // REMOVE any that fall outside the verified range (1..K) — they point at sources
397
+ // absent from the rebuilt list — before sweeping for unverified inline URLs and
398
+ // appending the verified section. Removed cleanly (never '[removed]').
399
+ const validIds = new Set(globalCitations.map((c) => c.id));
400
+ let prose = rewriteCitationMarkers(synthesis.trim(), new Map(), (n) => !validIds.has(n));
401
+ prose = redactUnverifiedProseUrls(prose, trustedUrls);
402
+ return `${prose}\n\n${verifiedLinksSection}`;
253
403
  }
254
404
 
255
405
  // Genuinely no sources were retrieved (e.g. every search/scrape was blocked by
@@ -257,7 +407,13 @@ export class ResearchSynthesisService implements IService {
257
407
  // uncited report that looks sourced — but also never destroy a CITED LINKS block
258
408
  // the model may have written that we simply could not parse (preserve as-is).
259
409
  if (/CITED LINKS/i.test(synthesis)) {
260
- return synthesis;
410
+ // Preserved, but NOT silently: reaching here means no citation parsed AND no
411
+ // scrape succeeded, so every link in that block is unverified by construction.
412
+ // Returning it untouched shipped a fully sourced-looking report with no caveat —
413
+ // the worst case for a reader, since an unretrievable session is precisely when
414
+ // a model's links are most likely to be invented.
415
+ logger.warn('[ResearchSynthesisService] Preserving an unparseable CITED LINKS block from a run that retrieved no sources — flagging it as unverified.');
416
+ return `${synthesis.trimEnd()}\n\n${UNVERIFIED_LINKS_NOTE}`;
261
417
  }
262
418
  logger.warn('[ResearchSynthesisService] No citations and no scrape provenance — appending explicit no-sources note.');
263
419
  return `${synthesis.trim()}\n\n${NO_SOURCES_NOTE}`;