@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.
- package/README.md +1 -1
- package/dist/cli.mjs +264 -61
- package/dist/prompts/system-coordinator.md +1 -1
- package/dist/prompts/system-lead-evaluator.md +1 -1
- package/dist/thread-worker.mjs +31 -21
- package/docs/ARCHITECTURE.md +60 -22
- package/docs/CHANGELOG.md +103 -1
- package/docs/CONFIGURATION.md +6 -5
- package/package.json +18 -10
- package/scripts/cleanup.cjs +26 -5
- package/scripts/setup.cjs +25 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +94 -13
- package/src/config.ts +6 -15
- package/src/constants.ts +13 -0
- package/src/core/service-registry.ts +19 -3
- package/src/infrastructure/browser/config.ts +36 -5
- package/src/infrastructure/browser/task-execution-service.ts +12 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +34 -18
- package/src/infrastructure/browser/thread-worker.mjs +31 -21
- package/src/infrastructure/gpu-resource-service.ts +15 -1
- package/src/knowledge/embedder-init.ts +34 -2
- package/src/knowledge/store.ts +75 -6
- package/src/knowledge/writer-queue.ts +28 -0
- package/src/orchestration/research-orchestration-service.ts +35 -1
- package/src/orchestration/research-synthesis-service.ts +4 -2
- package/src/orchestration/researcher.ts +2 -13
- package/src/prompts/system-coordinator.md +1 -1
- package/src/prompts/system-lead-evaluator.md +1 -1
- package/src/sdk.ts +10 -1
- package/src/security/nvd-client.ts +55 -4
- package/src/security/osv-client.ts +13 -1
- package/src/tools/research-knowledge-search.ts +18 -1
- package/src/tools/research-tool-definition.ts +8 -1
- package/src/utils/citation-utils.ts +4 -2
- package/src/utils/log-utils.ts +16 -1
- package/src/utils/text-utils.ts +17 -3
- 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
|
-
|
|
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
|
-
|
|
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;
|
package/src/knowledge/store.ts
CHANGED
|
@@ -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
|
|
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:"
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/utils/text-utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
278
|
+
textStart = m.index + m[1]!.length + m[2]!.length;
|
|
265
279
|
}
|
|
266
|
-
return
|
|
280
|
+
return { lineStart, textStart };
|
|
267
281
|
}
|
|
268
282
|
|
|
269
283
|
/**
|