@lincoln504/pi-research 1.3.10 → 1.3.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.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +264 -61
  3. package/dist/prompts/system-coordinator.md +1 -1
  4. package/dist/prompts/system-lead-evaluator.md +1 -1
  5. package/dist/thread-worker.mjs +31 -21
  6. package/docs/ARCHITECTURE.md +60 -22
  7. package/docs/CHANGELOG.md +103 -1
  8. package/docs/CONFIGURATION.md +6 -5
  9. package/package.json +18 -10
  10. package/scripts/cleanup.cjs +26 -5
  11. package/scripts/setup.cjs +25 -1
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +94 -13
  14. package/src/config.ts +6 -15
  15. package/src/constants.ts +13 -0
  16. package/src/core/service-registry.ts +19 -3
  17. package/src/infrastructure/browser/config.ts +36 -5
  18. package/src/infrastructure/browser/task-execution-service.ts +12 -2
  19. package/src/infrastructure/browser/thread-worker-browser.ts +34 -18
  20. package/src/infrastructure/browser/thread-worker.mjs +31 -21
  21. package/src/infrastructure/gpu-resource-service.ts +15 -1
  22. package/src/knowledge/embedder-init.ts +34 -2
  23. package/src/knowledge/store.ts +75 -6
  24. package/src/knowledge/writer-queue.ts +28 -0
  25. package/src/orchestration/research-orchestration-service.ts +35 -1
  26. package/src/orchestration/research-synthesis-service.ts +4 -2
  27. package/src/orchestration/researcher.ts +2 -13
  28. package/src/prompts/system-coordinator.md +1 -1
  29. package/src/prompts/system-lead-evaluator.md +1 -1
  30. package/src/sdk.ts +10 -1
  31. package/src/security/nvd-client.ts +55 -4
  32. package/src/security/osv-client.ts +13 -1
  33. package/src/tools/research-knowledge-search.ts +18 -1
  34. package/src/tools/research-tool-definition.ts +8 -1
  35. package/src/utils/citation-utils.ts +4 -2
  36. package/src/utils/log-utils.ts +16 -1
  37. package/src/utils/text-utils.ts +17 -3
  38. package/src/web-research/scraper-utils.ts +55 -14
@@ -42,7 +42,23 @@ export async function loadPipelineWithTimeout(
42
42
  interOpNumThreads: 1,
43
43
  },
44
44
  });
45
- return await withTimeout(pipelinePromise, timeoutMs, errorMessage);
45
+ // withTimeout races but cannot cancel: on timeout the underlying pipeline()
46
+ // keeps loading in the background and its eventual ONNX session would leak
47
+ // (the caller's catch disposes this.pipeline, which was never assigned).
48
+ // Dispose the late arrival when it eventually resolves — one orphaned native
49
+ // session per retry adds up on a slow disk.
50
+ let timedOut = true;
51
+ try {
52
+ const p = await withTimeout(pipelinePromise, timeoutMs, errorMessage);
53
+ timedOut = false;
54
+ return p;
55
+ } finally {
56
+ if (timedOut) {
57
+ pipelinePromise
58
+ .then((late) => (late as unknown as { dispose?: () => Promise<void> }).dispose?.())
59
+ .catch(() => { /* load failed after the timeout — nothing to dispose */ });
60
+ }
61
+ }
46
62
  });
47
63
 
48
64
  return { pipeline: loadedPipeline, errorMessage };
@@ -279,7 +295,23 @@ export async function loadModelOnCPU(
279
295
  interOpNumThreads: 1,
280
296
  },
281
297
  });
282
- return await withTimeout(pipelinePromise, initializationTimeoutMs, 'CPU fallback model load timed out');
298
+ // Same late-arrival disposal as loadPipelineWithTimeout: withTimeout races
299
+ // but cannot cancel, so on timeout the CPU load keeps running and its
300
+ // eventual ONNX session would leak. This path runs *after* a WebGPU
301
+ // failure — i.e. on a host already under memory pressure — and can be
302
+ // re-entered per retry, so each orphaned session compounds.
303
+ let timedOut = true;
304
+ try {
305
+ const p = await withTimeout(pipelinePromise, initializationTimeoutMs, 'CPU fallback model load timed out');
306
+ timedOut = false;
307
+ return p;
308
+ } finally {
309
+ if (timedOut) {
310
+ pipelinePromise
311
+ .then((late) => (late as unknown as { dispose?: () => Promise<void> }).dispose?.())
312
+ .catch(() => { /* load failed after the timeout — nothing to dispose */ });
313
+ }
314
+ }
283
315
  });
284
316
 
285
317
  return loadedPipeline;
@@ -30,11 +30,15 @@ import { normalizeWorkspacePath } from '../utils/text-utils.ts';
30
30
 
31
31
  /**
32
32
  * Detect a LanceDB concurrent-write conflict — the class of error worth retrying
33
- * with backoff. LanceDB 0.29 surfaces these as "Commit conflict for version N",
33
+ * with backoff. LanceDB surfaces these as "Commit conflict for version N",
34
34
  * "Retryable commit conflict for version N" and "Too many concurrent writes,
35
- * please retry later:" (strings confirmed in the 0.29.0 native binary). The three
36
- * legacy strings ("Version mismatch" / "Lock error" / "Commit error") no longer
37
- * appear in 0.29 but are kept as a safety net for other/older Lance versions.
35
+ * please retry later:" — all three re-confirmed present in the 0.37.1 native
36
+ * binary (`@lancedb/lancedb-<platform>`, NOT the `@lancedb/lancedb` JS package;
37
+ * grepping the latter finds nothing and looks like a regression), as they were
38
+ * in 0.29.0. The three legacy strings ("Version mismatch" / "Lock error" /
39
+ * "Commit error") appear in neither and are kept as a safety net for other/older
40
+ * Lance versions. Re-check these against the native binary on every upgrade: a
41
+ * changed message silently turns a retryable conflict into a hard failure.
38
42
  */
39
43
  export function isLanceCommitConflict(err: unknown): boolean {
40
44
  const msg = err instanceof Error ? err.message : String(err);
@@ -290,6 +294,15 @@ export class KnowledgeStore implements IKnowledgeStore {
290
294
  logger.warn('[store] Failed to read table schema:', schemaErr);
291
295
  }
292
296
  } else {
297
+ // Same warm-up as the migration branch above: createTable() throws
298
+ // "embedder dimension unknown" on a never-initialized embedder. This
299
+ // branch (no existing table — fresh dbDir or first-ever run) hit exactly
300
+ // that when the embedding factory handed back its model-mismatch local
301
+ // fallback, which is constructed but never initialize()d: every one of
302
+ // the caller's init retries burned against the same cached instance.
303
+ if (this.options.embedder.getDimension() === null) {
304
+ await this.withEmbedderReconnect(embedder => embedder.embedMany([' ']));
305
+ }
293
306
  this.table = await this.createTable();
294
307
  }
295
308
 
@@ -787,6 +800,49 @@ export class KnowledgeStore implements IKnowledgeStore {
787
800
  }
788
801
  }
789
802
 
803
+ /**
804
+ * Refresh the timestamp of one CURRENT generation for url+type — the
805
+ * revalidation "touch" for the writer-queue's content-unchanged skip. Without
806
+ * it a stable page kept its original timestamp forever: with
807
+ * KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS set, the serve-age gate then treated the
808
+ * entry as permanently stale (a live re-scrape on every run, for exactly the
809
+ * content that never changes), and TTL eviction eventually deleted an entry
810
+ * that had been re-verified byte-identical possibly hours earlier.
811
+ */
812
+ async touchByUrlAndType(url: string, ingestionType: string, generationTs: number, newTs: number): Promise<void> {
813
+ return this.trackOperation('touchByUrlAndType', undefined, async () => {
814
+ let retryCount = 0;
815
+ const MAX_RETRIES = 5;
816
+ const BASE_DELAY = 100;
817
+ const scopeFilter = this.getScopeFilter();
818
+ while (retryCount <= MAX_RETRIES) {
819
+ const table = await this.getFreshTable();
820
+ try {
821
+ const escapedUrl = url.replace(/'/g, "''");
822
+ const escapedType = ingestionType.replace(/'/g, "''");
823
+ // valuesSql with integer-coerced literals: timestamp is an Int64 column,
824
+ // and both inputs are integer-coerced so the SQL can never carry
825
+ // anything but digits.
826
+ await table.update({
827
+ where: `url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND timestamp = ${Math.floor(generationTs)} AND (${scopeFilter})`,
828
+ valuesSql: { timestamp: String(Math.floor(newTs)) },
829
+ });
830
+ logger.log(`[store] Refreshed timestamp for unchanged ${ingestionType} chunks of ${url}`);
831
+ return;
832
+ } catch (err) {
833
+ if (isLanceCommitConflict(err)) {
834
+ retryCount++;
835
+ if (retryCount > MAX_RETRIES) throw err;
836
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
837
+ await new Promise(r => setTimeout(r, delay));
838
+ continue;
839
+ }
840
+ throw err;
841
+ }
842
+ }
843
+ });
844
+ }
845
+
790
846
  async findDocumentsByUrl(url: string): Promise<StoreDocument[]> {
791
847
  // Coordinate with close(): findByUrl runs via the writer-queue in the post-run
792
848
  // background window (link-description storage), exactly when a Ctrl+C-driven
@@ -1013,7 +1069,9 @@ export class KnowledgeStore implements IKnowledgeStore {
1013
1069
  // no lock, so rows committed DURING the rebuild were absorbed into the
1014
1070
  // baseline as already-indexed and stayed out of the BM25 leg until an
1015
1071
  // unrelated later commit. Each createIndex commits exactly one version
1016
- // (measured on lancedb 0.29, fresh and replace alike); anything beyond
1072
+ // (measured on lancedb 0.29 and re-measured on 0.37.1 — vector index, FTS
1073
+ // index and a `replace: true` rebuild each advance version() by exactly
1074
+ // 1); anything beyond
1017
1075
  // version+2 means a concurrent commit rebased beneath our index commits, so
1018
1076
  // fall back to the conservative pre-rebuild baseline — the next cleanup
1019
1077
  // sees version > baseline and rebuilds over the missed rows.
@@ -1179,7 +1237,18 @@ export class KnowledgeStore implements IKnowledgeStore {
1179
1237
  backupDirs.map(async name => ({ name, ts: await this.backupDirTimestamp(name) }))
1180
1238
  );
1181
1239
  ranked.sort((a, b) => a.ts - b.ts);
1182
- for (const { name } of ranked.slice(0, -1)) {
1240
+ // Retain older backups until they age past the store's own TTL, not just
1241
+ // the single newest one. Two sessions configured with DIFFERENT embedding
1242
+ // models alternate migrations against the shared table (each open sees
1243
+ // "wrong model" → backup → fresh table), and keep-only-newest deleted the
1244
+ // only surviving copy of the first model's data after just two
1245
+ // alternations — silent, permanent loss. A backup older than ttlDays holds
1246
+ // only rows eviction would have deleted anyway, so time-based retention
1247
+ // matches the store's own data-retention contract.
1248
+ const ttlDays = this.options.ttlDays ?? 30;
1249
+ const backupCutoff = Math.trunc(Date.now() - (ttlDays * 24 * 60 * 60 * 1000));
1250
+ for (const { name, ts } of ranked.slice(0, -1)) {
1251
+ if (ts >= backupCutoff) continue;
1183
1252
  const fullPath = path.join(this.options.dbDir, name);
1184
1253
  logger.info(`[store] Removing superseded backup directory: ${name}`);
1185
1254
  await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
@@ -18,6 +18,9 @@ interface WriterQueueOptions {
18
18
  findByUrl: (url: string) => Promise<StoreDocument[]>;
19
19
  deleteByUrlAndType: (url: string, type: string, olderThan?: number) => Promise<void>;
20
20
  addDocuments: (docs: StoreDocument[]) => Promise<void>;
21
+ /** Revalidation touch for the content-unchanged skip (see that path). Optional
22
+ * so lightweight test doubles need not implement it. */
23
+ touchByUrlAndType?: (url: string, type: string, generationTs: number, newTs: number) => Promise<void>;
21
24
  };
22
25
  chunker: Chunker | null;
23
26
  }
@@ -199,6 +202,31 @@ export class WriterQueue implements IWriterQueue {
199
202
  metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
200
203
  });
201
204
  }
205
+ // The content was just re-fetched and verified byte-identical — record
206
+ // that as freshness. Without this touch, a stable page's timestamp never
207
+ // moved: the serve-age gate treated it as permanently stale (live scrape
208
+ // every run) and TTL eviction eventually deleted a revalidated entry.
209
+ //
210
+ // Re-check the newest generation first: a CONCURRENT process may have
211
+ // ingested changed content for this url+type since our dedup read, and
212
+ // stamping our (now-old) generation with a later timestamp would make the
213
+ // stale rows outrank the genuinely newer ones. The touch predicate is
214
+ // pinned to `timestamp = newestTs`, so the residual window is only the
215
+ // gap between this re-read and the update — self-healing on the next
216
+ // real scrape either way.
217
+ if (this.options.store.touchByUrlAndType) {
218
+ try {
219
+ const recheck = (await this.options.store.findByUrl(normalizedUrl))
220
+ .filter(c => c.metadata['ingestionType'] === incomingType);
221
+ const stillNewest = recheck.length > 0
222
+ && Math.max(...recheck.map(d => Number(d.timestamp) || 0)) === newestTs;
223
+ if (stillNewest) {
224
+ await this.options.store.touchByUrlAndType(normalizedUrl, incomingType, newestTs, Date.now());
225
+ }
226
+ } catch (err) {
227
+ logger.warn(`[writer-queue] Timestamp refresh for unchanged ${normalizedUrl} (${incomingType}) failed:`, err);
228
+ }
229
+ }
202
230
  logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
203
231
  return;
204
232
  }
@@ -14,7 +14,7 @@ import type { Model } from '@earendil-works/pi-ai';
14
14
  import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
15
15
  import type { QueryResultWithError } from '../web-research/types.ts';
16
16
  import type { RunResearchersOptions } from './orchestration-types.ts';
17
- import { RESEARCHER_LAUNCH_DELAY_MS, resolveExcludedTools } from '../constants.ts';
17
+ import { RESEARCHER_LAUNCH_DELAY_MS, RESEARCH_TOOL_NAMES, resolveExcludedTools } from '../constants.ts';
18
18
  import { search } from '../web-research/search.ts';
19
19
  import { parseCitations } from '../utils/text-utils.ts';
20
20
  import { logger, resetLogger } from '../logger.ts';
@@ -98,6 +98,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
98
98
  const configDisabledTools = researchConfig.DISABLED_TOOLS
99
99
  ? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
100
100
  : [];
101
+ // Unknown names are a SILENT no-op downstream (the merge is a blind Set union),
102
+ // so a typo leaves the tool enabled for a whole billed run while the user
103
+ // believes it is off. `--exclude-tools` fails fast on this in the CLI parser;
104
+ // its two siblings — the SDK's `excludeTools` option and
105
+ // PI_RESEARCH_DISABLED_TOOLS — reach the same union with no check at all, and
106
+ // RESEARCH_TOOL_NAMES already documents itself as the vocabulary all THREE
107
+ // accept. Warn rather than throw here: this chokepoint is mid-run and serves a
108
+ // library API and an ambient env var, where aborting a legitimate request over
109
+ // one unrecognized name is the wrong trade — the CLI keeps its fail-fast.
110
+ for (const [surface, names] of [
111
+ ['excludeTools', excludeTools ?? []],
112
+ ['PI_RESEARCH_DISABLED_TOOLS', configDisabledTools],
113
+ ] as const) {
114
+ const unknown = names.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
115
+ if (unknown.length > 0) {
116
+ logger.warn(
117
+ `[ResearchOrchestrationService] ${surface}: ignoring unknown tool name${unknown.length === 1 ? '' : 's'} ` +
118
+ `${unknown.map((t) => `"${t}"`).join(', ')} — these exclude nothing. Valid names: ${RESEARCH_TOOL_NAMES.join(', ')}.`
119
+ );
120
+ }
121
+ }
101
122
  const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
102
123
 
103
124
  // Resolve model using centralized priority logic
@@ -129,6 +150,19 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
129
150
  });
130
151
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
131
152
  } catch (err) {
153
+ const isTerminal =
154
+ err instanceof ResearchRunCapacityError || (err instanceof Error && err.name === 'AbortError');
155
+ if (isTerminal) {
156
+ // The queue notice above may already have announced this run to the
157
+ // observer (onRunQueued); rethrowing with no terminal event breaks the
158
+ // "exactly one of onComplete/onError" contract both orchestrators keep —
159
+ // an observer-driven consumer saw run_queued and then silence. No
160
+ // orchestrator exists yet, so this cannot double-fire. Observer throws
161
+ // stay isolated, as everywhere else.
162
+ try {
163
+ (observer as { onError?: (e: Error) => void } | undefined)?.onError?.(err as Error);
164
+ } catch { /* observer isolation */ }
165
+ }
132
166
  if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
133
167
  // A cancel while queueing for a slot must stay cancelled. Fail-open here would
134
168
  // start the very run the user just aborted.
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { parseCitations } from '../utils/text-utils.ts';
13
13
  import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
14
- import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
14
+ import { lastCitedLinksHeaderIndex, lastCitedLinksHeader } from '../utils/text-utils.ts';
15
15
  import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
16
16
  import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
17
17
  import { logger } from '../logger.ts';
@@ -321,7 +321,9 @@ export class ResearchSynthesisService implements IService {
321
321
  if (globalId !== undefined) localToGlobal.set(localKey, globalId);
322
322
  else droppedNumbers.add(localKey);
323
323
  });
324
- let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
324
+ // lineStart (not the 'C' offset): slicing at textStart left the tolerated
325
+ // `##`/`**`/`>` marker dangling as a stray line at the end of the body.
326
+ let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
325
327
  if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
326
328
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
327
329
  }
@@ -85,17 +85,6 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
85
85
 
86
86
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
87
87
 
88
- // The session sends model.maxTokens as the per-request max_tokens. Large-context
89
- // catalog entries carry ~230k ceilings, and providers that budget-check max_tokens
90
- // up front (OpenRouter 402s it against remaining credits) then reject every
91
- // researcher call on a low balance even though the real response costs cents.
92
- // Clamp the session's copy of the model to RESEARCHER_MAX_TOKENS; Model is plain
93
- // catalog data, so a spread clone is safe and the shared registry entry is untouched.
94
- const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
95
- const sessionModel = modelToUse.maxTokens > outputCap
96
- ? { ...modelToUse, maxTokens: outputCap }
97
- : modelToUse;
98
-
99
88
  try {
100
89
  const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
101
90
 
@@ -149,7 +138,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
149
138
  tools, // Explicit allowlist (BUG-1 fix)
150
139
  sessionManager: SessionManager.inMemory(), // Each researcher gets its own isolated session
151
140
  settingsManager: researcherSettings,
152
- model: sessionModel,
141
+ model: modelToUse,
153
142
  // pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
154
143
  // with the async `modelRuntime`. Pass the runtime backing OUR registry when
155
144
  // this run built one (standalone CLI/SDK): it carries any explicit API key
@@ -196,7 +185,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
196
185
 
197
186
  sessionRef.session = result.session;
198
187
 
199
- return { session: result.session, resolvedModel: sessionModel };
188
+ return { session: result.session, resolvedModel: modelToUse };
200
189
  } catch (tailError) {
201
190
  const session = result?.session;
202
191
  if (session && typeof session.abort === 'function') {
@@ -36,7 +36,7 @@ Before splitting the query into researchers, pin down its scope — exactly what
36
36
 
37
37
  1. **Plan ONLY Round 1**: Split the query into distinct sub-topics, all inside the scope above, each assigned to a researcher. **Do NOT plan multiple rounds in advance** — additional rounds are only delegated reactively when Round 1 findings reveal gaps.
38
38
  2. **Assign Goals**: Each researcher gets a focused goal covering a specific angle or time period.
39
- 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year (e.g. 2024 or 2025) unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
39
+ 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year than the one shown above unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
40
40
  4. **Query Planning**: For EACH researcher, generate targeted, specific queries within the budget. Fill the budget when the topic is broad enough to warrant it, but don't pad queries for a narrow topic. Each round is planned independently — make Round 1 count.
41
41
  5. **YouTube Discovery**: For roughly **one in {{youtube_query_every_n}}** of each researcher's queries, append the word `youtube` to the query text (e.g. `"<topic> explained youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip this only when the topic is clearly unsuited to video (e.g. a single numeric lookup).
42
42
  6. **Seed Search Burst**: All queries across all researchers are combined into a single pre-search pass that seeds the global link pool.
@@ -74,7 +74,7 @@ Use unique, targeted queries for any new researchers.
74
74
  - **CRITICAL — Queries are mandatory**: Every researcher MUST have at least one query. Never plan a researcher without queries. Researchers receive ONLY the search results you delegate to them.
75
75
  - **Maximize queries**: For EACH researcher, generate the maximum number of targeted, specific queries within the budget. Do not plan fewer than needed — fill the budget. Queries should target primary sources and authoritative evidence.
76
76
  - **YouTube discovery**: For roughly one in {{youtube_query_every_n}} queries, append the word `youtube` to the query text (e.g. `"<topic> analysis youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip only when the gap is clearly unsuited to video.
77
- - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year such as 2024 or 2025 unless the user explicitly asks about the past.
77
+ - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year than the one shown there unless the user explicitly asks about the past.
78
78
  - **Flexible coverage**: Use up to {{max_team_size}} researchers to cover distinct angles in parallel. Scale based on research gaps — a single well-targeted researcher is often sufficient for focused gaps.
79
79
  - **Source diversity**: Encourage researchers to find multiple authoritative sources per topic area to enable comprehensive citations in the final synthesis.
80
80
 
package/src/sdk.ts CHANGED
@@ -44,7 +44,7 @@ import { runHealthCheck } from './healthcheck/index.ts';
44
44
  import { buildModelRegistry as sharedBuildModelRegistry, resolveModel } from './core/llm/model-registry-factory.ts';
45
45
  import { scrapeSingle } from './web-research/web-scraper.ts';
46
46
  import { validateInitialLinks } from './utils/url-utils.ts';
47
- import { validateUrlForSSRF } from './web-research/scraper-utils.ts';
47
+ import { validateUrlForSSRF, disposeSsrfSafeFetcher } from './web-research/scraper-utils.ts';
48
48
  import type { ScrapeResult } from './core/interfaces/scheduler-interfaces.ts';
49
49
  import { randomUUID } from 'node:crypto';
50
50
  import type { ResearchDepth } from './types/index.ts';
@@ -954,6 +954,15 @@ async function _doShutdown(): Promise<void> {
954
954
  errors.push(err instanceof Error ? err : new Error(String(err)));
955
955
  }
956
956
 
957
+ // Release the module-level undici Agent's keep-alive sockets. Nothing else
958
+ // owns it, so a long-lived host would otherwise hold them past teardown.
959
+ try {
960
+ await disposeSsrfSafeFetcher();
961
+ } catch (err) {
962
+ logger.error('[SDK] Error disposing SSRF fetcher:', err);
963
+ errors.push(err instanceof Error ? err : new Error(String(err)));
964
+ }
965
+
957
966
  if (globalContainer) {
958
967
  // Dispose all services in one DAG-ordered teardown. disposeCoreServices first
959
968
  // clears the embedding-server state while the StateManager is still alive, then
@@ -22,7 +22,6 @@ import type {
22
22
  } from './nvd-types.ts';
23
23
  import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
24
24
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
25
- import { safeUnref } from '../utils/safe-unref.ts';
26
25
  import { metrics } from '../utils/metrics.ts';
27
26
  import { readJsonCapped, BodyTooLargeError, MAX_BULK_JSON_BODY_BYTES } from '../utils/http-body.ts';
28
27
  import {
@@ -77,7 +76,15 @@ class NVDRateLimiter {
77
76
  signal?.removeEventListener('abort', onAbort);
78
77
  resolve();
79
78
  }, waitTime);
80
- safeUnref(timeoutId);
79
+ // NOT unref'd. This timer IS the operation, not a watchdog racing one:
80
+ // nothing else will ever settle this promise, so unref'ing it let Node
81
+ // consider the process idle while an NVD search was mid-spacing. With no
82
+ // other ref'd handle the search simply never resumed — the awaited
83
+ // promise stayed unsettled and the caller got no result and no error.
84
+ // Every other unref in this codebase guards a timeout against real I/O
85
+ // that holds the loop open by itself; this one had nothing behind it.
86
+ // Bounded (≤6s, or ≤600ms with NVD_API_KEY) and abortable below, so
87
+ // keeping it ref'd cannot delay shutdown beyond one spacing interval.
81
88
  const onAbort = () => {
82
89
  clearTimeout(timeoutId as unknown as ReturnType<typeof setTimeout>);
83
90
  reject(Object.assign(new Error('NVD search aborted'), { name: 'AbortError' }));
@@ -392,6 +399,8 @@ async function fetchPaginated(
392
399
  const signal = options?.signal;
393
400
  let startIndex = 0;
394
401
  let totalPagesFetched = 0;
402
+ // Whether the one-time jump to the newest window (below) has been performed.
403
+ let seekedToNewest = false;
395
404
 
396
405
  while (totalPagesFetched < maxPages && allVulnerabilities.length < maxResults) {
397
406
  if (signal?.aborted) break; // stop paginating promptly on cancel
@@ -433,10 +442,40 @@ async function fetchPaginated(
433
442
  }
434
443
  metrics.increment('nvd_cache_hits_total', 1);
435
444
 
445
+ const responseData = data as Record<string, unknown> | undefined;
446
+ const reportedTotal = typeof responseData?.['totalResults'] === 'number'
447
+ ? responseData['totalResults'] as number
448
+ : undefined;
449
+
450
+ // NVD 2.0 returns keyword matches OLDEST FIRST and offers no sort parameter
451
+ // (`sortBy` is a 404), so paging from index 0 and stopping at maxResults kept
452
+ // precisely the least useful window: a `security_search` for "openssl"
453
+ // returned CVE-1999-0428 through 2004 out of 566 matches and could never
454
+ // reach the ones published this month. Seek once to the tail instead, so the
455
+ // rows we keep are the most recent. Only fires when the match set is larger
456
+ // than what we return — a query whose results all fit is already complete, so
457
+ // the common (and every mocked) case still costs exactly one request.
458
+ //
459
+ // Interaction with the `v2` supplemental pass (searchSingleTerm), deliberate:
460
+ // that pass admits only entries carrying NO v3/v4 metric, and NVD scores
461
+ // essentially every modern CVE with v3 — so v2-only rows live at the OLD end.
462
+ // Seeking to the tail therefore makes the supplement return little or nothing
463
+ // once a match set is large. That is the intended trade, not an oversight: the
464
+ // supplement was the channel feeding 1999-era advisories into results that are
465
+ // supposed to describe the current picture. A genuinely legacy product still
466
+ // works, because its own v2 match set is small enough that no seek fires.
467
+
468
+ if (!seekedToNewest && startIndex === 0 && reportedTotal !== undefined && reportedTotal > maxResults) {
469
+ seekedToNewest = true;
470
+ startIndex = reportedTotal - maxResults;
471
+ allVulnerabilities.length = 0; // the oldest window we just read is not what we want
472
+ totalPagesFetched++;
473
+ continue;
474
+ }
475
+
436
476
  allVulnerabilities.push(...admitted);
437
477
 
438
- const responseData = data as Record<string, unknown> | undefined;
439
- if (responseData?.['totalResults'] !== undefined && typeof responseData['totalResults'] === 'number' && startIndex + pageSize >= responseData['totalResults']) {
478
+ if (reportedTotal !== undefined && startIndex + pageSize >= reportedTotal) {
440
479
  break;
441
480
  }
442
481
 
@@ -447,6 +486,18 @@ async function fetchPaginated(
447
486
  metrics.increment('nvd_pagination_requests_total', totalPagesFetched);
448
487
  metrics.observe('nvd_pagination_pages_fetched', totalPagesFetched);
449
488
 
489
+ // Newest first within the window. NVD's own order is ascending, so without
490
+ // this the caller's slice() would again favour the older end of whatever we
491
+ // gathered. Undated entries sort last rather than jumping to the front.
492
+ allVulnerabilities.sort((a, b) => {
493
+ const ta = a.published ? Date.parse(a.published) : NaN;
494
+ const tb = b.published ? Date.parse(b.published) : NaN;
495
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
496
+ if (Number.isNaN(ta)) return 1;
497
+ if (Number.isNaN(tb)) return -1;
498
+ return tb - ta;
499
+ });
500
+
450
501
  return allVulnerabilities.slice(0, maxResults);
451
502
  }
452
503
 
@@ -230,7 +230,19 @@ export async function searchOSV(
230
230
  }
231
231
  }
232
232
 
233
- vulnerabilities.push(...Array.from(uniqueVulns.values()));
233
+ // Newest first, so the slice(0, maxResults) below keeps the advisories most
234
+ // likely to matter. OSV returns a package's vulnerabilities in ID order,
235
+ // which is effectively alphabetical — truncating that dropped whichever
236
+ // advisories happened to sort late, recency playing no part. Undated entries
237
+ // sort last rather than displacing dated ones.
238
+ vulnerabilities.push(...Array.from(uniqueVulns.values()).sort((a, b) => {
239
+ const ta = a.published ? Date.parse(a.published) : NaN;
240
+ const tb = b.published ? Date.parse(b.published) : NaN;
241
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
242
+ if (Number.isNaN(ta)) return 1;
243
+ if (Number.isNaN(tb)) return -1;
244
+ return tb - ta;
245
+ }));
234
246
 
235
247
  if (failures.length > 0 && failures.length === terms.length) {
236
248
  throw new Error(`All OSV lookups failed: ${failures.join('; ')}`);
@@ -114,6 +114,17 @@ export function formatQueriesBlock(queries: string[]): string {
114
114
  /** Widget ID for the knowledge search TUI panel */
115
115
  const KNOWLEDGE_WIDGET_ID = 'pi-research-knowledge-search';
116
116
 
117
+ /**
118
+ * How many knowledge searches are currently showing the widget.
119
+ *
120
+ * The widget ID is a single global slot and `setWidget` replaces rather than
121
+ * stacks, but this tool is `executionMode: 'parallel'` and can also be driven
122
+ * concurrently by the `/knowledge-store` slash command. Without a refcount the
123
+ * first call to finish tears the widget down while a sibling is still running,
124
+ * leaving an in-flight search with no indicator. Only the last one out removes it.
125
+ */
126
+ let knowledgeWidgetRefCount = 0;
127
+
117
128
  /**
118
129
  * System string returned when the answer is NOT found in the research
119
130
  * knowledge store. The exact phrasing is critical — the main pi agent
@@ -140,6 +151,7 @@ export const RESEARCH_KNOWLEDGE_MAYBE_STRING =
140
151
  */
141
152
  function showKnowledgeSearchWidget(ctx: ExtensionContext): void {
142
153
  if (ctx.mode !== 'tui' || !ctx.hasUI) return;
154
+ knowledgeWidgetRefCount++;
143
155
  try {
144
156
  const panelFactory = createKnowledgeSearchPanel();
145
157
  ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, panelFactory as any, { placement: 'aboveEditor' });
@@ -149,10 +161,15 @@ function showKnowledgeSearchWidget(ctx: ExtensionContext): void {
149
161
  }
150
162
 
151
163
  /**
152
- * Remove the knowledge search TUI widget.
164
+ * Remove the knowledge search TUI widget once the last concurrent search is done.
153
165
  */
154
166
  function hideKnowledgeSearchWidget(ctx: ExtensionContext): void {
155
167
  if (ctx.mode !== 'tui' || !ctx.hasUI) return;
168
+ // show() incremented under the identical guard, so the counter stays balanced.
169
+ // Clamp anyway: a stray hide must never drive it negative and wedge the
170
+ // widget on-screen for the rest of the session.
171
+ knowledgeWidgetRefCount = Math.max(0, knowledgeWidgetRefCount - 1);
172
+ if (knowledgeWidgetRefCount > 0) return;
156
173
  try {
157
174
  ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, undefined as any, { placement: 'aboveEditor' });
158
175
  } catch (err) {
@@ -28,7 +28,7 @@ import { redactSecrets } from '../utils/log-utils.ts';
28
28
  import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
29
29
  import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
30
30
  import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
31
- import { startResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
31
+ import { startResearchSession, endResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
32
32
  import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
33
33
  import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
34
34
  import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
@@ -339,6 +339,13 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
339
339
  registerSessionAbort(piSessionId, sessionResearchId, internalAbort);
340
340
 
341
341
  cleanup = async () => {
342
+ // Free what startResearchSession/registerSessionAbort created — the
343
+ // failures/failureReasons entries and the AbortController live in the
344
+ // module-level piSessions map keyed by the STABLE pi session id, so
345
+ // without this every headless run leaked them for the process
346
+ // lifetime (the TUI branch frees them via createCleanupFunction, the
347
+ // SDK in its own finally; this branch freed only steering).
348
+ endResearchSession(piSessionId, sessionResearchId);
342
349
  clearSteeringMessages(piSessionId);
343
350
  };
344
351
 
@@ -1,4 +1,4 @@
1
- import { parseCitations, lastCitedLinksHeaderIndex, type Citation } from './text-utils.ts';
1
+ import { parseCitations, lastCitedLinksHeader, type Citation } from './text-utils.ts';
2
2
  import { normalizeUrl } from './shared-links.ts';
3
3
 
4
4
  export interface GlobalCitation extends Citation {
@@ -66,7 +66,9 @@ export function normalizeCitations(reports: ReadonlyMap<string, string>): {
66
66
  // line-leading, all-caps header, never an incidental "cited links" mention in
67
67
  // prose (an unanchored split there would drop real findings before the
68
68
  // evaluator ever sees them).
69
- const headerIdx = lastCitedLinksHeaderIndex(report);
69
+ // lineStart (not textStart): slicing at the 'C' left the tolerated `##`/`**`/`>`
70
+ // marker dangling at the end of the kept body.
71
+ const headerIdx = lastCitedLinksHeader(report).lineStart;
70
72
  let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
71
73
 
72
74
  // Renumber local [N] markers to their global ids, and DELETE the ones that could
@@ -133,7 +133,22 @@ const KNOWN_TOKEN_PATTERN = new RegExp(
133
133
  'g',
134
134
  );
135
135
  // JSON Web Tokens — header.payload.signature, each a base64url segment.
136
- const JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, 'g');
136
+ //
137
+ // The segment lengths are bounded for the same load-bearing reason the URL
138
+ // scheme above is, and this pattern was missed when that one was fixed. `.` is
139
+ // NOT in the segment class, so on a run of base64url characters containing no
140
+ // dot, `[A-Za-z0-9_-]+` consumes to the end of the run and then backtracks one
141
+ // character at a time looking for one — and `_`/`-` count as non-alphanumeric,
142
+ // so B_LEFT admits a fresh start position every few characters inside that same
143
+ // run. `_eyJ` repeated across the 40_000-character scan window measured 393ms of
144
+ // blocked event loop per log message; bounding the segments brings the identical
145
+ // input to 41ms (~10x) with no loss of coverage. The bounds are far above any
146
+ // real token: a 1024-char header covers even an x5c certificate chain, and 8192
147
+ // covers payloads no bearer credential approaches.
148
+ const JWT_PATTERN = new RegExp(
149
+ `${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
150
+ 'g',
151
+ );
137
152
  // HTTP Basic credentials: "Basic <base64>".
138
153
  const BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, 'gi');
139
154
  // HTTP Bearer credentials: "Bearer <token>". The token is often opaque — many
@@ -253,17 +253,31 @@ function isPlausibleCitationUrl(url: string): boolean {
253
253
  * against the `CITED LINKS\n...` block that formatCitedLinks() emits.
254
254
  */
255
255
  export function lastCitedLinksHeaderIndex(text: string): number {
256
+ return lastCitedLinksHeader(text).textStart;
257
+ }
258
+
259
+ /**
260
+ * Both offsets of the last CITED LINKS header: `textStart` points at the 'C'
261
+ * (what parseCitations slices FROM), `lineStart` points at the start of the
262
+ * header line BEFORE any `#`/`>`/`**` markers (what body-replacement slices TO).
263
+ * Slicing the body at textStart left the tolerated markdown marker dangling at
264
+ * the end of the body — the rebuilt report then carried a stray `##` or `**`
265
+ * line whenever the model wrote `## CITED LINKS` instead of the plain form.
266
+ */
267
+ export function lastCitedLinksHeader(text: string): { lineStart: number; textStart: number } {
256
268
  // Case-insensitive but LINE-LEADING: a mid-prose "...the cited links below..." has
257
269
  // non-whitespace before it on the line, so it cannot match — only a header at the
258
270
  // start of a line (after optional markdown markers) does.
259
271
  const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
260
- let last = -1;
272
+ let lineStart = -1;
273
+ let textStart = -1;
261
274
  let m: RegExpExecArray | null;
262
275
  while ((m = re.exec(text)) !== null) {
276
+ lineStart = m.index + m[1]!.length;
263
277
  // Offset of the 'C' in CITED LINKS = match start + leading newline + markers.
264
- last = m.index + m[1]!.length + m[2]!.length;
278
+ textStart = m.index + m[1]!.length + m[2]!.length;
265
279
  }
266
- return last;
280
+ return { lineStart, textStart };
267
281
  }
268
282
 
269
283
  /**