@lincoln504/pi-research 1.3.0 → 1.3.2

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.
@@ -53,7 +53,23 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
53
53
  // Initialization promise to prevent concurrent initialization
54
54
  private _initializationPromise: Promise<void> | null = null;
55
55
 
56
+ // Disposal promise: lets a concurrent initialize() wait out an in-flight
57
+ // dispose() instead of racing it. See dispose()'s own comment for the
58
+ // failure this closes — none of initialize()'s lifecycle guards below
59
+ // recognize DISPOSING, so without this an initialize() arriving while
60
+ // dispose() is mid-teardown would start rebuilding immediately, and the
61
+ // two would race to close/null each other's components.
62
+ private _disposalPromise: Promise<void> | null = null;
63
+
56
64
  async initialize(ctx?: any): Promise<void> {
65
+ // A dispose() in flight must settle before anything below inspects
66
+ // lifecycle or decides what to do — while() (not if()) because a newer
67
+ // disposal can start again during the await (mirrors the
68
+ // _initializationPromise wait loop further down).
69
+ while (this._disposalPromise) {
70
+ await this._disposalPromise;
71
+ }
72
+
57
73
  // Only an EXPLICIT ctx.cwd may re-scope the store. The lazy callers
58
74
  // (getStore/getEmbedder/getWriterQueue) call initialize() with no ctx — they
59
75
  // must NOT flip the scope. Resolving the missing cwd to process.cwd() here was
@@ -277,6 +293,13 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
277
293
  return;
278
294
  }
279
295
 
296
+ // Join an already-in-flight disposal rather than starting a second one —
297
+ // two concurrent dispose() calls would otherwise both try to close/null
298
+ // the same handles.
299
+ if (this._disposalPromise) {
300
+ return this._disposalPromise;
301
+ }
302
+
280
303
  // If an initialization is still in flight, let it finish before we tear down.
281
304
  // Otherwise the init closure would re-assign this._embedder/_store/_writerQueue
282
305
  // and flip lifecycle back to INITIALIZED *after* dispose() nulled everything —
@@ -293,42 +316,65 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
293
316
  this.lifecycle = ServiceLifecycle.DISPOSING;
294
317
  logger.debug('[KnowledgeStoreService] Disposing...');
295
318
 
296
- try {
297
- if (this._writerQueue) {
298
- await this._writerQueue.dispose?.();
299
- }
300
-
301
- if (this._store) {
302
- await this._store.close();
303
- }
319
+ // Capture the components to tear down NOW, as local references — not
320
+ // re-read via this._embedder/this._store/etc. at each step below. A
321
+ // concurrent initialize() is blocked on _disposalPromise (see above) and
322
+ // cannot publish anything while we run, but capturing locally means this
323
+ // disposal only ever acts on what it was actually invoked to dispose,
324
+ // regardless of what the instance fields point at by the time each
325
+ // `await` below resumes — the same identity-capture discipline the
326
+ // embedding-leader CAS and file-lock PID+startTime checks elsewhere in
327
+ // this codebase already use for exactly this class of race.
328
+ const embedder = this._embedder;
329
+ const store = this._store;
330
+ const writerQueue = this._writerQueue;
331
+ const initLock = this._initLock;
332
+
333
+ this._disposalPromise = (async () => {
334
+ try {
335
+ if (writerQueue) {
336
+ await writerQueue.dispose?.();
337
+ }
304
338
 
305
- if (this._embedder) {
306
- await this._embedder.dispose?.();
307
- // The embedding factory's module-level cache still points at the instance
308
- // just disposed — its fast path has no liveness check, so a later re-init
309
- // (cwd/mode re-scope) would be handed the dead instance and burn every
310
- // warm-up/init retry on it. clearEmbeddingInstance() re-disposes
311
- // (idempotent) and nulls the cache; when this process was the leader, its
312
- // shutdown deregisters via the serverId CAS, and a client instance's
313
- // dispose is a no-op — a FOREIGN leader's registration is never touched.
314
- await clearEmbeddingInstance();
315
- }
339
+ if (store) {
340
+ await store.close();
341
+ }
316
342
 
317
- if (this._initLock) {
318
- await this._initLock.dispose();
319
- }
343
+ if (embedder) {
344
+ await embedder.dispose?.();
345
+ // The embedding factory's module-level cache still points at the instance
346
+ // just disposed — its fast path has no liveness check, so a later re-init
347
+ // (cwd/mode re-scope) would be handed the dead instance and burn every
348
+ // warm-up/init retry on it. clearEmbeddingInstance() re-disposes
349
+ // (idempotent) and nulls the cache; when this process was the leader, its
350
+ // shutdown deregisters via the serverId CAS, and a client instance's
351
+ // dispose is a no-op — a FOREIGN leader's registration is never touched.
352
+ await clearEmbeddingInstance();
353
+ }
320
354
 
321
- this._embedder = null;
322
- this._store = null;
323
- this._writerQueue = null;
324
- this._initLock = null;
355
+ if (initLock) {
356
+ await initLock.dispose();
357
+ }
325
358
 
326
- logger.debug('[KnowledgeStoreService] Disposed');
327
- } catch (err) {
328
- logger.error('[KnowledgeStoreService] Error during disposal:', err);
329
- } finally {
330
- this.lifecycle = ServiceLifecycle.DISPOSED;
331
- }
359
+ // Only clear fields that still point at what we just disposed — a
360
+ // concurrent initialize() cannot have published anything while
361
+ // blocked on _disposalPromise, but this keeps the intent explicit
362
+ // rather than relying on that invariant holding forever.
363
+ if (this._embedder === embedder) this._embedder = null;
364
+ if (this._store === store) this._store = null;
365
+ if (this._writerQueue === writerQueue) this._writerQueue = null;
366
+ if (this._initLock === initLock) this._initLock = null;
367
+
368
+ logger.debug('[KnowledgeStoreService] Disposed');
369
+ } catch (err) {
370
+ logger.error('[KnowledgeStoreService] Error during disposal:', err);
371
+ } finally {
372
+ this.lifecycle = ServiceLifecycle.DISPOSED;
373
+ this._disposalPromise = null;
374
+ }
375
+ })();
376
+
377
+ return this._disposalPromise;
332
378
  }
333
379
 
334
380
  /**
@@ -25,28 +25,6 @@ export class StateBrowserApi {
25
25
  return this.browserManager.getBrowserServer(state);
26
26
  }
27
27
 
28
- /**
29
- * Set the current browser server information (atomic: only overwrites if no live server exists)
30
- * @param port The browser server port
31
- * @param pid The browser server process ID
32
- * @param schedulerId Optional scheduler ID
33
- * @param updateState Function to update state atomically
34
- * @param getStartTime Function to get process start time
35
- */
36
- async setBrowserServer(
37
- port: number,
38
- pid: number,
39
- schedulerId: string | undefined,
40
- updateState: (updater: (state: SingletonState) => SingletonState | Promise<SingletonState>) => Promise<void>,
41
- getStartTime: (pid: number) => Promise<number | null>,
42
- authSecret?: string
43
- ): Promise<void> {
44
- const startTime = await getStartTime(pid);
45
- await updateState((state) => {
46
- return this.browserManager.setBrowserServer(state, port, pid, schedulerId, startTime, authSecret);
47
- });
48
- }
49
-
50
28
  /**
51
29
  * Clear the browser server information
52
30
  * @param updateState Function to update state atomically
@@ -23,27 +23,6 @@ export class StateBrowserManager implements IService {
23
23
  return state.browserServer ?? null;
24
24
  }
25
25
 
26
- /**
27
- * Set the current browser server information
28
- * @param state The current state
29
- * @param port The browser server port
30
- * @param pid The browser server process ID
31
- * @param schedulerId Optional scheduler ID
32
- * @param startTime Optional process start time
33
- * @returns Updated state with browser server info
34
- */
35
- setBrowserServer(
36
- state: SingletonState,
37
- port: number,
38
- pid: number,
39
- schedulerId?: string,
40
- startTime?: number | null,
41
- authSecret?: string
42
- ): SingletonState {
43
- state.browserServer = { port, pid, schedulerId, startTime: startTime ?? undefined, authSecret };
44
- return state;
45
- }
46
-
47
26
  /**
48
27
  * Clear the browser server information
49
28
  * @param state The current state
@@ -178,13 +178,6 @@ export class StateManagerService implements IStateManager {
178
178
  return this.getStateManager().getBrowserServer();
179
179
  }
180
180
 
181
- /**
182
- * Set the current browser server information
183
- */
184
- async setBrowserServer(port: number, pid: number, schedulerId?: string, authSecret?: string): Promise<void> {
185
- return this.getStateManager().setBrowserServer(port, pid, schedulerId, authSecret);
186
- }
187
-
188
181
  /**
189
182
  * Clear the browser server information
190
183
  */
@@ -435,17 +435,6 @@ export class StateManager {
435
435
  return this.browserApi.getBrowserServer(this.readState.bind(this));
436
436
  }
437
437
 
438
- public async setBrowserServer(port: number, pid: number, schedulerId?: string, authSecret?: string): Promise<void> {
439
- await this.browserApi.setBrowserServer(
440
- port,
441
- pid,
442
- schedulerId,
443
- this.updateState.bind(this),
444
- this.processLifecycle.getProcessStartTime.bind(this.processLifecycle),
445
- authSecret
446
- );
447
- }
448
-
449
438
  public async clearBrowserServer(expected?: { pid?: number; schedulerId?: string }): Promise<void> {
450
439
  await this.browserApi.clearBrowserServer(this.updateState.bind(this), expected);
451
440
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import * as path from 'node:path';
10
- import { getGlobalConfigDir } from '../../config.ts';
10
+ import { getStateDir } from '../../config.ts';
11
11
  import type { IService } from '../../core/service-registry.ts';
12
12
  import { ServiceLifecycle } from '../../core/service-registry.ts';
13
13
  import { ServiceNames } from '../../core/interfaces/service-names.ts';
@@ -39,8 +39,12 @@ export class StatePathConfiguration implements IService {
39
39
 
40
40
  constructor(stateDir?: string) {
41
41
  // pi-research's own namespace (~/.pi/research/state), beside config.env and
42
- // knowledge_db — not the host pi config root (~/.pi/state).
43
- const resolvedStateDir = stateDir || path.join(getGlobalConfigDir(), 'state');
42
+ // knowledge_db — not the host pi config root (~/.pi/state). A non-absolute
43
+ // explicit override is ignored in favor of getStateDir()'s own resolution
44
+ // (which re-derives from PI_RESEARCH_STATE_DIR/the default and warns) — see
45
+ // getStateDir()'s doc comment for why a relative state dir must never be
46
+ // resolved against whatever process.cwd() happens to be at construction time.
47
+ const resolvedStateDir = stateDir && path.isAbsolute(stateDir) ? stateDir : getStateDir();
44
48
  this.stateDir = resolvedStateDir;
45
49
  this.stateFilePath = path.join(resolvedStateDir, 'research-state.json');
46
50
  this.lockDirPath = path.join(resolvedStateDir, '.locks');
@@ -885,8 +885,27 @@ export class KnowledgeStore implements IKnowledgeStore {
885
885
  metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'miss' });
886
886
  return null;
887
887
  }
888
- // Newest generation wins, never row order.
889
- results.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp));
888
+ // Newest generation wins, never row order. Ties (multiple chunk rows from
889
+ // the same write batch share one timestamp — see writer-queue.ts's
890
+ // insertedAt) break on chunkIndex ascending: only the chunk-0 row carries
891
+ // the complete `content` field this method promises callers ("...→
892
+ // unfragmented original markdown, not stitched vector chunks" per
893
+ // rebuildDocuments()'s doc comment in research-knowledge-search.ts).
894
+ // LanceDB's fragment-scan order is not insertion order, so without this a
895
+ // non-zero chunk sorting first would silently return just that one
896
+ // chunk's own fragment in place of the full document.
897
+ const chunkIndexOf = (row: any): number => {
898
+ try {
899
+ const parsed = JSON.parse(row.metadata as string);
900
+ return typeof parsed.chunkIndex === 'number' ? parsed.chunkIndex : 0;
901
+ } catch {
902
+ return 0;
903
+ }
904
+ };
905
+ results.sort((a: any, b: any) => {
906
+ const tsDiff = Number(b.timestamp) - Number(a.timestamp);
907
+ return tsDiff !== 0 ? tsDiff : chunkIndexOf(a) - chunkIndexOf(b);
908
+ });
890
909
 
891
910
  const r = results[0];
892
911
  // Read-time freshness. The stored timestamp (ms) is otherwise discarded here;
@@ -908,7 +927,12 @@ export class KnowledgeStore implements IKnowledgeStore {
908
927
  try {
909
928
  const metadata = JSON.parse(r.metadata as string);
910
929
  const description: string | null = typeof metadata.description === 'string' ? metadata.description : null;
911
- const textToReturn = (r.content as string) || (r.text as string) || description || '';
930
+ // description is complete on every chunk row (spread verbatim from
931
+ // item.metadata at ingest time — see writer-queue.ts's docs.map), unlike
932
+ // r.text, which is only that ONE row's own chunk fragment. Prefer it
933
+ // over r.text so a selected non-content, non-chunk-0 row still returns
934
+ // a complete (if shorter) document instead of a silently truncated one.
935
+ const textToReturn = (r.content as string) || description || (r.text as string) || '';
912
936
  metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'hit' });
913
937
  logger.log(`[store] Cache hit: synthesis-description for ${url} (${textToReturn.length} chars, ~${ageDays ?? '?'}d old)`);
914
938
  return { text: textToReturn, description, metadata, ageDays };
@@ -1091,7 +1115,12 @@ export class KnowledgeStore implements IKnowledgeStore {
1091
1115
  if (scopeFilter === '1 = 0') return;
1092
1116
 
1093
1117
  const ttlDays = this.options.ttlDays ?? 30;
1094
- const cutoffTimestamp = Date.now() - (ttlDays * 24 * 60 * 60 * 1000);
1118
+ // Math.trunc: ttlDays is schema-typed as a plain number (fractional values
1119
+ // are accepted), and BigInt() throws a RangeError on a non-integer — which
1120
+ // would otherwise silently disable eviction entirely for this run (caught
1121
+ // by the catch below and only logged) whenever the configured value or the
1122
+ // current timestamp happens to leave a fractional cutoff.
1123
+ const cutoffTimestamp = Math.trunc(Date.now() - (ttlDays * 24 * 60 * 60 * 1000));
1095
1124
  // Scope eviction to this workspace only — do not evict other projects' data.
1096
1125
  await table.delete(`timestamp < ${BigInt(cutoffTimestamp)} AND (${scopeFilter})`);
1097
1126
  logger.log(`[store] Ran eviction for records older than ${ttlDays} days [scope: ${scopeFilter}]`);
@@ -454,11 +454,21 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
454
454
  observer?.onResearcherComplete?.(id, responseText);
455
455
  return;
456
456
  } catch (err) {
457
- const researcherDuration = Date.now() - researcherExecutionStartMs;
458
- metrics.observe('researcher_execution_latency_ms', researcherDuration, { mode: 'deep', complexity: String(complexity), round: String(round), status: 'error' });
459
- metrics.increment('researcher_errors_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
460
457
  lastError = err;
461
458
  const errMsg = err instanceof Error ? err.message : String(err);
459
+ // Computed up front (not just at the retry-skip check below) so the
460
+ // metrics right after don't mislabel a clean cancellation as an error.
461
+ // The sentinel has TWO forms (see the retry-skip check's own comment);
462
+ // isAbortSentinel matches both.
463
+ const tornDown = Boolean(signal?.aborted || isAbortSentinel(errMsg) || errMsg.includes('during container disposal') || container?.isDisposing);
464
+ const researcherDuration = Date.now() - researcherExecutionStartMs;
465
+ metrics.observe('researcher_execution_latency_ms', researcherDuration, { mode: 'deep', complexity: String(complexity), round: String(round), status: tornDown ? 'cancelled' : 'error' });
466
+ // A clean cancellation/teardown is not a researcher fault — mirrors
467
+ // quick-research-orchestrator's equivalent catch, which computes this
468
+ // same distinction before touching its own error-only counters.
469
+ if (!tornDown) {
470
+ metrics.increment('researcher_errors_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
471
+ }
462
472
 
463
473
  // Attempt to salvage partial content on timeout or error
464
474
  try {
@@ -490,8 +500,8 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
490
500
  // Retrying during disposal is futile — services are being destroyed — and it
491
501
  // relaunches search bursts into a WorkerPool that dispose() is simultaneously
492
502
  // destroying, producing a storm of "Cannot execute a task on destroying pool"
493
- // errors.
494
- if (signal?.aborted || isAbortSentinel(errMsg) || errMsg.includes('during container disposal') || container?.isDisposing) {
503
+ // errors. (tornDown computed above, alongside the metrics it also gates.)
504
+ if (tornDown) {
495
505
  logger.debug(`[ResearcherExecutor] Researcher ${id} aborted or container disposing, skipping retries.`);
496
506
  break; // Break out of the attempt loop
497
507
  }
@@ -259,7 +259,7 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
259
259
  metrics.increment('session_runs_started_total');
260
260
 
261
261
  try {
262
- const researchRunResult = await runWithRunRegistry<{ result: string; tokens: number; researchId: string }>(runRegistry, () =>
262
+ const researchRunResult = await runWithRunRegistry<{ result: string; tokens: number; researchId: string; cancelled: boolean }>(runRegistry, () =>
263
263
  logger.runCapturingStderr(async () => {
264
264
  const config = getConfig(ctx.cwd, iface);
265
265
  validateConfig(config);
@@ -305,8 +305,6 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
305
305
  researchId: startResearchSession(piSessionId, researchId),
306
306
  piSessionId,
307
307
  masterWidgetId: tuiManager.masterWidgetId,
308
- panelState,
309
- waveTimer: null,
310
308
  unsubOrder: null,
311
309
  };
312
310
  registerSessionAbort(piSessionId, cleanupCtx.researchId, internalAbort);
@@ -389,6 +387,17 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
389
387
  config,
390
388
  }, internalAbort.signal);
391
389
 
390
+ // DeepResearchOrchestrator.run() does not throw on cancellation once at
391
+ // least one researcher has produced a report — it returns a fallback
392
+ // synthesis through its normal return path (see
393
+ // deep-research-orchestrator.ts's synthesisService.hasReports branch).
394
+ // Left unchecked, a cancel mid-run that still yields a partial report
395
+ // would fall straight through as if the run had completed normally,
396
+ // with no indication in the returned text — the only channel the
397
+ // calling agent sees — that it is reading a partial result. Mirrors the
398
+ // CLI's cancellation-latch check on its own success path (cmdResearch).
399
+ const wasCancelled = aborted?.aborted === true || internalAbort.signal.aborted;
400
+
392
401
  // Stop wave animation
393
402
  if (observerState && panelState) stopObserverWaveAnimation(observerState, panelState);
394
403
 
@@ -418,10 +427,17 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
418
427
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
419
428
  finalResult = synthesisService.appendMetadata(finalResult, selectedModel!.id);
420
429
 
421
- return { result: finalResult, tokens: runTokens, researchId };
430
+ // The calling agent only ever sees `content[].text` — `details` is
431
+ // logs/UI-only (AgentToolResult's own doc comment) — so a cancellation
432
+ // note has to live IN the report text itself, not just in metadata.
433
+ if (wasCancelled) {
434
+ finalResult = `*Research cancelled — the report below is a partial synthesis from what had been collected before the run was stopped.*\n\n${finalResult}`;
435
+ }
436
+
437
+ return { result: finalResult, tokens: runTokens, researchId, cancelled: wasCancelled };
422
438
  } catch (error) {
423
439
  if (aborted?.aborted || internalAbort.signal.aborted) {
424
- return { result: 'Research cancelled.', tokens: 0, researchId };
440
+ return { result: 'Research cancelled.', tokens: 0, researchId, cancelled: true };
425
441
  }
426
442
  throw error;
427
443
  } finally {
@@ -431,17 +447,27 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
431
447
  return researchRunResult;
432
448
  })); // end runCapturingStderr / runWithRunRegistry
433
449
 
434
- // Snapshot the run registry
450
+ // Snapshot the run registry. Both branches that produce researchRunResult
451
+ // (the success path above and its inner catch) can represent a cancelled
452
+ // run — status must reflect that, not assume every non-throwing outcome
453
+ // was an ordinary success.
435
454
  metrics.recordRunSummary({
436
455
  runId: researchId,
437
456
  startedAt: runStartedAt,
438
457
  completedAt: Date.now(),
439
458
  durationMs: Date.now() - runStartedAt,
440
- status: 'success',
459
+ status: researchRunResult.cancelled ? 'cancelled' : 'success',
441
460
  snapshot: runRegistry.getSnapshot(),
442
461
  });
443
462
 
444
- return { content: [{ type: 'text', text: researchRunResult.result }], details: { totalTokens: researchRunResult.tokens, researchId: researchRunResult.researchId } };
463
+ return {
464
+ content: [{ type: 'text', text: researchRunResult.result }],
465
+ details: {
466
+ totalTokens: researchRunResult.tokens,
467
+ researchId: researchRunResult.researchId,
468
+ ...(researchRunResult.cancelled ? { cancelled: true } : {}),
469
+ },
470
+ };
445
471
  } catch (error) {
446
472
  if (aborted?.aborted || internalAbort.signal.aborted) {
447
473
  metrics.recordRunSummary({
@@ -34,8 +34,6 @@ export interface CleanupContext {
34
34
  researchId: string;
35
35
  piSessionId: string;
36
36
  masterWidgetId: string;
37
- panelState: import('./research-panel-types.ts').ResearchPanelState;
38
- waveTimer: NodeJS.Timeout | null;
39
37
  unsubOrder: (() => void) | null;
40
38
  unsubOrderRef?: { value: (() => void) | null };
41
39
  }
@@ -49,8 +49,6 @@ export interface ResearchPanelState {
49
49
  waveFrame?: number;
50
50
  /** Last-visit frame per position for wave animation decay */
51
51
  waveColors?: number[];
52
- /** Timer for wave animation */
53
- waveTimer?: NodeJS.Timeout | null;
54
52
  /** Flag indicating whether completed researchers should be cleared */
55
53
  needsClear?: boolean;
56
54
  /** 1-2 word topic title derived from the query after the coordinator LLM responds */
@@ -129,8 +129,20 @@ const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
129
129
  * neither sink receives clear-text secrets or unbounded network data.
130
130
  */
131
131
  export function redactSecrets(message: string): string {
132
+ // slice() counts UTF-16 code units, so a naive cut at MAX_LOG_MESSAGE_LENGTH
133
+ // can land between the two halves of a surrogate pair (non-BMP characters —
134
+ // routine in scraped CJK/emoji content). The console sink then writes a
135
+ // lone surrogate, silently mangled to U+FFFD on UTF-8 encode; the JSON-file
136
+ // sink emits an unpaired \uXXXX escape that many strict JSON consumers
137
+ // reject outright. Same back-off truncateWithMarker (text-utils.ts) already
138
+ // applies for the same reason.
139
+ let keep = MAX_LOG_MESSAGE_LENGTH;
140
+ if (message.length > keep) {
141
+ const lastKeptUnit = message.charCodeAt(keep - 1);
142
+ if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
143
+ }
132
144
  let out = message.length > MAX_LOG_MESSAGE_LENGTH
133
- ? `${message.slice(0, MAX_LOG_MESSAGE_LENGTH)}…[truncated ${message.length - MAX_LOG_MESSAGE_LENGTH} chars]`
145
+ ? `${message.slice(0, keep)}…[truncated ${message.length - keep} chars]`
134
146
  : message;
135
147
  out = out.replace(ANSI_PATTERN, '');
136
148
  out = out.replace(URL_CREDENTIALS_PATTERN, '$1[REDACTED]@');
@@ -31,18 +31,26 @@ export function stripTrailingLlmPunctuation(input: string): string {
31
31
  continue;
32
32
  }
33
33
  // Balance-aware removal of a trailing closing bracket: strip it only when it
34
- // has no matching opener anywhere in the string (so a markdown/parenthetical
34
+ // has no matching opener earlier in the string (so a markdown/parenthetical
35
35
  // noise bracket is removed, but a balanced one that's part of the URL —
36
36
  // Wikipedia "..._(programming_language)", IPv6 "http://[::1]/" — is kept).
37
+ //
38
+ // A running balance over everything BEFORE this trailing bracket — not a
39
+ // flat total-count comparison. A flat count misjudges a string that both
40
+ // ends in a genuinely balanced pair AND carries an earlier, unrelated
41
+ // unbalanced bracket: "http://x.com/a)b(c)" has 2 ')' vs 1 '(' overall,
42
+ // so a flat count sees closes > opens and strips the real, balanced
43
+ // trailing ')' that closes "(c)" — corrupting the URL. balance > 0 here
44
+ // means an opener earlier in the string is still unmatched, and this
45
+ // trailing bracket is the one that closes it.
37
46
  if (last === ')' || last === ']' || last === '}') {
38
47
  const open = last === ')' ? '(' : last === ']' ? '[' : '{';
39
- let opens = 0;
40
- let closes = 0;
41
- for (const ch of s) {
42
- if (ch === open) opens++;
43
- else if (ch === last) closes++;
48
+ let balance = 0;
49
+ for (const ch of s.slice(0, -1)) {
50
+ if (ch === open) balance++;
51
+ else if (ch === last) balance = Math.max(0, balance - 1);
44
52
  }
45
- if (closes > opens) {
53
+ if (balance === 0) {
46
54
  s = s.slice(0, -1);
47
55
  continue;
48
56
  }
@@ -31,15 +31,19 @@ import { isCloudflareBlockError, isTaskTimeoutError, isPoolShutdownError } from
31
31
  * failures at debug instead of error — keeping the ERROR log (and, via
32
32
  * logger.error's error-tracker re-track, the diagnostic error count) focused on
33
33
  * genuine faults. The messages matched here are produced by validateContent
34
- * (`Fetch blocked:` / `Fetch returned stub:`) and scrapeWithFetch (`HTTP <code>`);
35
- * the remaining cases delegate to the shared browser-layer predicates.
34
+ * (`Fetch blocked:` / `Fetch returned stub:`), scrapeWithFetch (`HTTP <code>`)
35
+ * and extractPdfToMarkdown (`Could not extract content from PDF`) — a
36
+ * malformed/encrypted/scanned-image-only PDF is routine on the open web and
37
+ * just as non-actionable as a stub page or a 404; the remaining cases
38
+ * delegate to the shared browser-layer predicates.
36
39
  */
37
40
  export function isBenignScrapeFailure(error: unknown): boolean {
38
41
  const msg = error instanceof Error ? error.message : String(error);
39
42
  return (
40
- msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
41
- msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
42
- /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
43
+ msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
44
+ msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
45
+ msg.startsWith('Could not extract content from PDF') || // malformed/encrypted/scanned-only PDF
46
+ /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
43
47
  isCloudflareBlockError(error) ||
44
48
  isTaskTimeoutError(error) ||
45
49
  isPoolShutdownError(error)
@@ -110,7 +110,17 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
110
110
  return markdown;
111
111
  } catch (e) {
112
112
  const msg = e instanceof Error ? e.message : String(e);
113
- logger.error(`[Scrapers] PDF extraction failed: ${msg}`);
113
+ // debug, not error: a malformed/encrypted/scanned-image-only PDF is an
114
+ // expected, non-actionable per-URL outcome, not a genuine fault — the same
115
+ // reason isBenignScrapeFailure (scraper-utils.ts) keeps stub/blocked/HTTP-error
116
+ // content out of ERROR. This is called from both the fetch layer (where a
117
+ // browser-layer fallback always follows — the fetch layer's own catch at line
118
+ // ~529 already logs ANY of its failures at debug for that reason) and the
119
+ // browser layer (the last resort — isBenignScrapeFailure now recognizes this
120
+ // message so that terminal call site's own classification stays consistent
121
+ // too). Logging ERROR here, before either caller gets a chance to classify,
122
+ // pre-empted both of those and always alarmed regardless.
123
+ logger.debug(`[Scrapers] PDF extraction failed: ${msg}`);
114
124
  metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'extraction_failed' });
115
125
  errorTracker.trackError(e instanceof Error ? e : String(e), {
116
126
  component: 'scrapers',
@@ -590,7 +600,22 @@ export async function scrape(urls: string[], maxConcurrency = 5, signal?: AbortS
590
600
  // settle via scrapeSingle's own cancellation classification. Without this,
591
601
  // every remaining batch still ran its full fetch + browser-fallback cycle
592
602
  // after a user cancel.
593
- if (signal?.aborted) break;
603
+ if (signal?.aborted) {
604
+ // scrapeSingle reports {success:false, error:'Aborted'} for a URL that
605
+ // was already in flight when the signal fired — the sub-batches that
606
+ // never even got dispatched (everything from here on) need the same
607
+ // treatment. Without this, the caller's own results.length silently
608
+ // fell short of urls.length with no indication a run was cut short —
609
+ // the same "cancellation resolves through a normal, non-throwing path
610
+ // and is invisible to the caller" gap the v1.3.1 research-tool fix
611
+ // closed at the orchestrator level, recurring one layer down here.
612
+ for (const url of urls.slice(i)) {
613
+ const aborted: ScrapeResult = { url, success: false, error: 'Aborted', markdown: '' };
614
+ onUrlComplete?.(aborted);
615
+ results.push(aborted);
616
+ }
617
+ break;
618
+ }
594
619
  const batch = urls.slice(i, i + maxConcurrency);
595
620
  const batchRes = await Promise.all(
596
621
  batch.map(async url => {
@@ -601,10 +626,10 @@ export async function scrape(urls: string[], maxConcurrency = 5, signal?: AbortS
601
626
  );
602
627
  results.push(...batchRes);
603
628
  }
604
-
629
+
605
630
  const batchDuration = Date.now() - batchStart;
606
631
  metrics.observe('scrape_batch_latency_ms', batchDuration);
607
-
632
+
608
633
  return results;
609
634
  }
610
635