@lincoln504/pi-research 1.3.1 → 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.
- package/dist/cli.mjs +166 -117
- package/dist/thread-worker.mjs +29 -2
- package/docs/CHANGELOG.md +34 -0
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cleanup/research-cleanup.ts +3 -35
- package/src/cli.ts +11 -3
- package/src/config.ts +28 -3
- package/src/core/interfaces/state-manager-interfaces.ts +0 -1
- package/src/core/service-registry.ts +104 -36
- package/src/infrastructure/browser/thread-worker-browser.ts +53 -4
- package/src/infrastructure/browser/thread-worker.mjs +29 -2
- package/src/infrastructure/browser/thread-worker.ts +8 -0
- package/src/infrastructure/knowledge-store-service.ts +78 -32
- package/src/infrastructure/state/state-browser-api.ts +0 -22
- package/src/infrastructure/state/state-browser-manager.ts +0 -21
- package/src/infrastructure/state/state-manager-service.ts +0 -7
- package/src/infrastructure/state/state-manager.ts +0 -11
- package/src/infrastructure/state/state-path-configuration.ts +7 -3
- package/src/knowledge/store.ts +33 -4
- package/src/orchestration/researcher-executor.ts +15 -5
- package/src/tools/research-tool-definition.ts +0 -2
- package/src/types/index.ts +0 -2
- package/src/types/research-panel-types.ts +0 -2
- package/src/utils/log-utils.ts +13 -1
- package/src/utils/url-utils.ts +15 -7
- package/src/web-research/scraper-utils.ts +9 -5
- package/src/web-research/web-scraper.ts +29 -4
|
@@ -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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
this._initLock = null;
|
|
355
|
+
if (initLock) {
|
|
356
|
+
await initLock.dispose();
|
|
357
|
+
}
|
|
325
358
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 {
|
|
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
|
-
|
|
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');
|
package/src/knowledge/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|
|
@@ -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);
|
package/src/types/index.ts
CHANGED
|
@@ -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 */
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -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,
|
|
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]@');
|
package/src/utils/url-utils.ts
CHANGED
|
@@ -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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (ch ===
|
|
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 (
|
|
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:`)
|
|
35
|
-
*
|
|
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:') ||
|
|
41
|
-
msg.startsWith('Fetch returned stub:') ||
|
|
42
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|