@lincoln504/pi-research 1.4.3 → 1.5.1
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 +396 -125
- package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
- package/dist/prompts/research-knowledge-search-extractor.md +2 -0
- package/dist/prompts/researcher.md +2 -14
- package/dist/thread-worker.mjs +17 -4
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +131 -2
- package/docs/CONFIGURATION.md +2 -2
- package/docs/SDK.md +1 -1
- package/package.json +3 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +31 -9
- package/src/constants.ts +23 -4
- package/src/core/interfaces/state-manager-interfaces.ts +8 -0
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +39 -4
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +20 -3
- package/src/infrastructure/browser/thread-worker.mjs +17 -4
- package/src/infrastructure/embedding/embedding-factory.ts +34 -0
- package/src/infrastructure/embedding/embedding-server.ts +34 -9
- package/src/infrastructure/state/state-manager.ts +15 -0
- package/src/knowledge/embedder.ts +36 -7
- package/src/knowledge/store-operations.ts +47 -38
- package/src/knowledge/store.ts +86 -8
- package/src/knowledge/writer-queue.ts +4 -2
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/deep-research-orchestrator.ts +15 -3
- package/src/orchestration/quick-research-orchestrator.ts +37 -11
- package/src/orchestration/research-orchestration-service.ts +21 -2
- package/src/orchestration/research-synthesis-service.ts +36 -0
- package/src/orchestration/researcher-executor.ts +46 -17
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +36 -4
- package/src/prompts/research-knowledge-relevance-triage.md +2 -0
- package/src/prompts/research-knowledge-search-extractor.md +2 -0
- package/src/prompts/researcher.md +2 -14
- package/src/sdk.ts +65 -52
- package/src/stackexchange/index.ts +8 -0
- package/src/tools/health-tool-definition.ts +11 -1
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +58 -10
- package/src/tools/research-tool-definition.ts +27 -16
- package/src/tools/scrape.ts +8 -1
- package/src/tools/stackexchange.ts +14 -7
- package/src/utils/llm-usage.ts +4 -1
- package/src/utils/log-utils.ts +5 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/scraper-utils.ts +6 -2
- package/src/web-research/web-scraper.ts +9 -0
- package/src/youtube/index.ts +11 -1
- package/src/youtube/potoken.ts +20 -2
package/docs/SDK.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
## SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A high-level research SDK for scripts, CI, and custom tooling. For configuration (the
|
|
4
4
|
layering model, the TUI settings, and every environment variable) see
|
|
5
5
|
[CONFIGURATION.md](CONFIGURATION.md).
|
|
6
6
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Web research for your agents with smart and safe tooling + knowledge store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -158,7 +158,8 @@
|
|
|
158
158
|
"adm-zip": "^0.6.0",
|
|
159
159
|
"jsdom": {
|
|
160
160
|
"undici": "^7.29.0"
|
|
161
|
-
}
|
|
161
|
+
},
|
|
162
|
+
"sharp": "^0.35.0"
|
|
162
163
|
},
|
|
163
164
|
"devDependencies": {
|
|
164
165
|
"@eslint/js": "^10.0.1",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pi-research
|
|
3
3
|
description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
|
|
4
4
|
license: MIT
|
|
5
|
-
metadata: { "author": "Lincoln Deen", "version": "1.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.5.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
package/src/cli.ts
CHANGED
|
@@ -150,7 +150,7 @@ function pretty(obj: unknown): string {
|
|
|
150
150
|
* lifecycle event to stderr. Keeps a long research run visible without spamming
|
|
151
151
|
* stdout (the report goes to stdout, untouched).
|
|
152
152
|
*/
|
|
153
|
-
function makeProgressObserver(): HeadlessObserverOptions {
|
|
153
|
+
export function makeProgressObserver(): HeadlessObserverOptions {
|
|
154
154
|
return {
|
|
155
155
|
enableLogging: false,
|
|
156
156
|
onProgress: (event: string, data?: any) => {
|
|
@@ -196,9 +196,24 @@ function makeProgressObserver(): HeadlessObserverOptions {
|
|
|
196
196
|
}
|
|
197
197
|
break;
|
|
198
198
|
}
|
|
199
|
-
case 'complete':
|
|
199
|
+
case 'complete': {
|
|
200
200
|
toStderr(` • complete (${data?.result?.length ?? 0} chars)\n`);
|
|
201
|
+
// Degradation signal for callers that read stderr (the agent skill
|
|
202
|
+
// does): a run that completes but is weakly grounded or retrieved no
|
|
203
|
+
// sources at all looks identical to a healthy run on exit code and
|
|
204
|
+
// the "complete" line alone. Two real runs on 2026-08-20 shipped
|
|
205
|
+
// degraded reports (1 citation / 0 sources) with nothing on stderr to
|
|
206
|
+
// distinguish them. The markers are the synthesis service's own
|
|
207
|
+
// notices, appended by ensureCitedLinks.
|
|
208
|
+
const reportText = typeof data?.result === 'string' ? data.result : '';
|
|
209
|
+
if (reportText.includes('No web sources were successfully retrieved') ||
|
|
210
|
+
reportText.includes('None of the links above could be verified')) {
|
|
211
|
+
toStderr(' • WARNING: no web sources could be retrieved — the report is unverified\n');
|
|
212
|
+
} else if (reportText.includes('Grounding notice: only')) {
|
|
213
|
+
toStderr(' • WARNING: weakly grounded report (fewer than 3 cited sources) — treat unattributed claims as unverified\n');
|
|
214
|
+
}
|
|
201
215
|
break;
|
|
216
|
+
}
|
|
202
217
|
case 'error':
|
|
203
218
|
toStderr(` • error: ${data?.message}\n`);
|
|
204
219
|
break;
|
|
@@ -738,7 +753,9 @@ export async function cmdKnowledge(queries: string[], json?: boolean): Promise<n
|
|
|
738
753
|
config: getConfig(process.cwd(), 'cli'),
|
|
739
754
|
ignoreGlobalConfig: true,
|
|
740
755
|
});
|
|
741
|
-
|
|
756
|
+
// "N queries", not "N query/s" — same rationale as the search_start line above:
|
|
757
|
+
// the slash form reads as a rate and renders "1 query/" in the singular.
|
|
758
|
+
toStderr(`[pi-research] searching knowledge store (${queries.length} ${queries.length === 1 ? 'query' : 'queries'})…\n`);
|
|
742
759
|
result = await searchKnowledge(queries, abortController.signal);
|
|
743
760
|
} catch (err) {
|
|
744
761
|
return reportError(err, 'knowledge search', json);
|
|
@@ -1660,10 +1677,34 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1660
1677
|
// left the SDK's handler to tear the container down underneath a live orchestrator
|
|
1661
1678
|
// — exactly the race the CLI installs its own handlers to avoid.
|
|
1662
1679
|
process.on('SIGHUP', () => onSignal('SIGHUP'));
|
|
1680
|
+
// SIGQUIT: documented (see exitCodeForSignal's comment and the exit-code
|
|
1681
|
+
// tables in docs/SDK.md and SKILL.md) as handled the same way as SIGHUP,
|
|
1682
|
+
// but no listener was ever registered for it — an unhandled SIGQUIT hits
|
|
1683
|
+
// Node's default disposition (immediate termination, no abort, no drain),
|
|
1684
|
+
// silently breaking that documented guarantee. Has no Windows equivalent;
|
|
1685
|
+
// registering the listener there is a documented no-op, so no guard needed.
|
|
1686
|
+
process.on('SIGQUIT', () => onSignal('SIGQUIT'));
|
|
1663
1687
|
if (process.platform === 'win32') {
|
|
1664
1688
|
process.on('SIGBREAK', () => onSignal('SIGBREAK'));
|
|
1665
1689
|
}
|
|
1666
1690
|
|
|
1691
|
+
// Test-only seam: idle here, right after every handler above is registered,
|
|
1692
|
+
// instead of parsing argv/running a command. Lets a subprocess test deliver
|
|
1693
|
+
// a real OS signal and observe THIS handler run (process.exit with the
|
|
1694
|
+
// derived code) rather than Node's default disposition (process killed by
|
|
1695
|
+
// the signal, no code) — the exact distinction that would have caught the
|
|
1696
|
+
// missing SIGQUIT registration this seam was added for. Never set in
|
|
1697
|
+
// production; see the SIGQUIT subprocess test in test/unit/cli.test.ts.
|
|
1698
|
+
if (process.env['PI_RESEARCH_TEST_HANG_FOR_SIGNAL'] === '1') {
|
|
1699
|
+
process.stdout.write('PI_RESEARCH_TEST_READY\n');
|
|
1700
|
+
// A bare unresolved Promise does not keep the event loop alive by
|
|
1701
|
+
// itself (only real libuv handles do — an installed signal listener
|
|
1702
|
+
// isn't one either), so without a ref'd timer the process would exit 0
|
|
1703
|
+
// on its own the instant this synchronous section finishes, before any
|
|
1704
|
+
// signal could ever arrive.
|
|
1705
|
+
await new Promise<never>(() => setInterval(() => {}, 1000));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1667
1708
|
let parsed: ParsedArgs;
|
|
1668
1709
|
try {
|
|
1669
1710
|
parsed = parseArgs(argv);
|
package/src/config.ts
CHANGED
|
@@ -245,6 +245,11 @@ const USER_MIGRATION_KEYS = [
|
|
|
245
245
|
'PI_RESEARCH_PLANNING_MAX_TOKENS',
|
|
246
246
|
'PI_RESEARCH_SYNTHESIS_MAX_TOKENS',
|
|
247
247
|
'PI_RESEARCH_MIGRATION_STRATEGY',
|
|
248
|
+
'PI_RESEARCH_MAX_FAILED_RESEARCHERS',
|
|
249
|
+
'PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS',
|
|
250
|
+
'PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS',
|
|
251
|
+
'PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG',
|
|
252
|
+
'PI_RESEARCH_YOUTUBE_QUERY_EVERY_N',
|
|
248
253
|
'PI_RESEARCH_CONSOLE_LOG',
|
|
249
254
|
'PI_RESEARCH_MODEL',
|
|
250
255
|
'PI_RESEARCH_KNOWLEDGE_DIR',
|
|
@@ -751,16 +756,23 @@ function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, stri
|
|
|
751
756
|
try {
|
|
752
757
|
logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
|
|
753
758
|
legacyEnv = parseDotEnv(fs.readFileSync(legacyPath, 'utf-8'));
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
759
|
+
// Only LOCAL_SCOPE_KEYS from this run's resolution too — same boundary
|
|
760
|
+
// step 4 below already enforces for the registry this file migrates
|
|
761
|
+
// into ("the registry is project-scoped storage... never user-scoped
|
|
762
|
+
// keys"). This file lives in the CWD, which for this tool is routinely
|
|
763
|
+
// an arbitrary, potentially untrusted repository a coding agent was
|
|
764
|
+
// just pointed at — applying every key unconditionally let a dropped-in
|
|
765
|
+
// .pi-research.env silently control user-scoped settings (RESEARCH_MODEL,
|
|
766
|
+
// EMBEDDING_DEVICE, DISABLED_TOOLS, export destinations, ...) for that
|
|
767
|
+
// run with no confirmation, for any setting the user hadn't already set
|
|
768
|
+
// via a real environment variable (which still wins over all of this).
|
|
769
|
+
// Non-local keys are ignored here exactly as they already are for the
|
|
770
|
+
// registry — this predecessor mechanism should not be MORE permissive
|
|
771
|
+
// than the successor it's migrating into.
|
|
761
772
|
const legacyLocal = Object.fromEntries(
|
|
762
773
|
Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
763
774
|
);
|
|
775
|
+
Object.assign(merged, legacyLocal);
|
|
764
776
|
const existingLocal = Object.fromEntries(
|
|
765
777
|
Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
766
778
|
);
|
|
@@ -799,8 +811,14 @@ function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, stri
|
|
|
799
811
|
}
|
|
800
812
|
}
|
|
801
813
|
} else if (Object.keys(merged).length === 0 && !fs.existsSync(legacyPath) && !fs.existsSync(globalPath)) {
|
|
802
|
-
// 5. Warning for missing config
|
|
803
|
-
|
|
814
|
+
// 5. Warning for missing config — once per workspace per process. getConfig
|
|
815
|
+
// is called on many hot paths (every tool invocation, health checks), and
|
|
816
|
+
// repeating this advisory each time made it the single most common line in
|
|
817
|
+
// the shared log (362 entries in one observed day), burying real warnings.
|
|
818
|
+
if (!warnedMissingConfigWorkspaces.has(normalizedCwd)) {
|
|
819
|
+
warnedMissingConfigWorkspaces.add(normalizedCwd);
|
|
820
|
+
logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
|
|
821
|
+
}
|
|
804
822
|
}
|
|
805
823
|
|
|
806
824
|
warnLegacyEnvVarsOnce(merged);
|
|
@@ -825,6 +843,10 @@ const LEGACY_ENV_VARS: Record<string, string> = {
|
|
|
825
843
|
|
|
826
844
|
let warnedLegacyEnvVars = false;
|
|
827
845
|
|
|
846
|
+
// Workspaces already warned about having no configuration (see loadEnvFiles) —
|
|
847
|
+
// per-process dedup so the advisory appears once, not on every getConfig call.
|
|
848
|
+
const warnedMissingConfigWorkspaces = new Set<string>();
|
|
849
|
+
|
|
828
850
|
/** Warn once per process about set-but-dead 0.1.x env vars (upgrade aid; never throws). */
|
|
829
851
|
function warnLegacyEnvVarsOnce(merged: Record<string, string>): void {
|
|
830
852
|
if (warnedLegacyEnvVars) return;
|
package/src/constants.ts
CHANGED
|
@@ -46,11 +46,26 @@ export function getMaxGatheringCalls(config?: Config): number {
|
|
|
46
46
|
/**
|
|
47
47
|
* Get the maximum scrape batches from config.
|
|
48
48
|
* This function should be used instead of MAX_SCRAPE_CALLS to support dynamic configuration.
|
|
49
|
+
*
|
|
50
|
+
* @param cachingActive - Pass `true` when prompt caching is active for this
|
|
51
|
+
* run's resolved model (see isPromptCachingActiveForModel in
|
|
52
|
+
* core/llm/research-model-resolver.ts). With caching active, the EFFECTIVE
|
|
53
|
+
* maximum is the configured value plus one: a cached run's system-prompt/
|
|
54
|
+
* tool-definition prefix is cheap to re-send, so the same token budget
|
|
55
|
+
* covers one more scrape batch per researcher. Every consumer of the batch
|
|
56
|
+
* count (tracker enforcement, prompt text, progress units) must go through
|
|
57
|
+
* this function with the same cachingActive value so they all agree on the
|
|
58
|
+
* effective number. The "unlimited" sentinel (0 or >99) is unchanged —
|
|
59
|
+
* adding one to unlimited is meaningless.
|
|
49
60
|
*/
|
|
50
|
-
export function getMaxScrapeBatches(config?: Config): number {
|
|
61
|
+
export function getMaxScrapeBatches(config?: Config, cachingActive = false): number {
|
|
51
62
|
try {
|
|
52
63
|
const batches = (config || getConfig()).MAX_SCRAPE_BATCHES;
|
|
53
|
-
|
|
64
|
+
if (batches === 0 || batches > 99) return 999999;
|
|
65
|
+
// Cap at 99: a configured 99 (the documented max) plus one would land in the
|
|
66
|
+
// >99 "unlimited" band that every renderer checks — the prompt would say
|
|
67
|
+
// "unlimited batches" while enforcement actually blocked at 100.
|
|
68
|
+
return cachingActive ? Math.min(batches + 1, 99) : batches;
|
|
54
69
|
} catch {
|
|
55
70
|
return 2; // Fallback to default
|
|
56
71
|
}
|
|
@@ -63,9 +78,13 @@ export const MAX_SCRAPE_URLS = 6;
|
|
|
63
78
|
* Get the units per researcher for the progress bar.
|
|
64
79
|
* Units = 1 (for start/search) + number of scrape batches.
|
|
65
80
|
* When batches are unlimited (MAX_SCRAPE_BATCHES=0), caps at 4 for a sane progress estimate.
|
|
81
|
+
*
|
|
82
|
+
* Relative to the EFFECTIVE batch count: pass the run's cachingActive (from
|
|
83
|
+
* isPromptCachingActiveForModel over the resolved model) so progress units
|
|
84
|
+
* agree with the limit the tracker actually enforces.
|
|
66
85
|
*/
|
|
67
|
-
export function getUnitsPerResearcher(config?: Config): number {
|
|
68
|
-
const batches = getMaxScrapeBatches(config);
|
|
86
|
+
export function getUnitsPerResearcher(config?: Config, cachingActive = false): number {
|
|
87
|
+
const batches = getMaxScrapeBatches(config, cachingActive);
|
|
69
88
|
return 1 + Math.min(batches, 4);
|
|
70
89
|
}
|
|
71
90
|
|
|
@@ -8,6 +8,14 @@ import type { IService } from '../service-registry.ts';
|
|
|
8
8
|
* State manager service interface
|
|
9
9
|
*/
|
|
10
10
|
export interface IStateManager extends IService {
|
|
11
|
+
/**
|
|
12
|
+
* True once a newer-build state file forced this process into read-only mode
|
|
13
|
+
* (writes suppressed) — state-file-based coordination (GPU lock, embedding
|
|
14
|
+
* leader election) is then void and consumers must degrade to a
|
|
15
|
+
* coordination-free mode. Optional so structural test doubles need not
|
|
16
|
+
* implement it; callers use `isReadOnly?.()` (absent ⇒ not read-only).
|
|
17
|
+
*/
|
|
18
|
+
isReadOnly?(): boolean;
|
|
11
19
|
readState(): Promise<any>;
|
|
12
20
|
writeState(state: any): Promise<void>;
|
|
13
21
|
updateState(updater: (state: any) => any | Promise<any>): Promise<void>;
|
|
@@ -89,3 +89,41 @@ export function resolveResearchModel(options: {
|
|
|
89
89
|
|
|
90
90
|
throw new Error(buildNoModelAvailableMessage());
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort, CONSERVATIVE signal for "will pi-ai apply prompt caching for
|
|
95
|
+
* this resolved model." pi-ai owns all cache-mechanism logic internally (this
|
|
96
|
+
* codebase never constructs cache_control blocks itself) and exposes no
|
|
97
|
+
* public API to ask in advance for most providers, so this can only answer
|
|
98
|
+
* for the two client-visible cases:
|
|
99
|
+
*
|
|
100
|
+
* - `api === 'anthropic-messages'`: caching is native and on by default (no
|
|
101
|
+
* capability gate at all — see pi-ai's anthropic-messages provider).
|
|
102
|
+
* - `api === 'openai-completions'` with `compat.cacheControlFormat ===
|
|
103
|
+
* 'anthropic'`: an explicit opt-in to Anthropic-style cache_control
|
|
104
|
+
* emulation (e.g. an OpenRouter/gateway route to an Anthropic model
|
|
105
|
+
* configured with this override).
|
|
106
|
+
*
|
|
107
|
+
* Every other case returns false even though caching may actually be
|
|
108
|
+
* active: pi's generated model catalog sets `cacheControlFormat` on zero
|
|
109
|
+
* models, so plain `openai-completions` traffic (this project's custom
|
|
110
|
+
* providers — glm-coding, cerebras, zai, OpenRouter, ...) has its cache
|
|
111
|
+
* compat auto-detected by pi-ai's own private, unexported heuristic INSIDE
|
|
112
|
+
* the live request — not knowable client-side, before or after model
|
|
113
|
+
* resolution. Providers with fully implicit/automatic caching
|
|
114
|
+
* (openai-responses family, google-generative-ai, google-vertex,
|
|
115
|
+
* mistral-conversations, bedrock-converse-stream) have no "will it be
|
|
116
|
+
* active" signal at all — it depends on provider-side runtime behavior
|
|
117
|
+
* (minimum prompt length, server cache state). Treat this as "caching is
|
|
118
|
+
* DEFINITELY active" — a false negative is expected and fine for providers
|
|
119
|
+
* outside these two cases, but a false positive would be a real bug.
|
|
120
|
+
*/
|
|
121
|
+
export function isPromptCachingActiveForModel(model: Model<any> | undefined): boolean {
|
|
122
|
+
if (!model) return false;
|
|
123
|
+
if (model.api === 'anthropic-messages') return true;
|
|
124
|
+
if (model.api === 'openai-completions') {
|
|
125
|
+
const compat = model.compat as { cacheControlFormat?: string } | undefined;
|
|
126
|
+
return compat?.cacheControlFormat === 'anthropic';
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
@@ -220,8 +220,19 @@ function isDegradableLlmError(error: unknown, signal?: AbortSignal): boolean {
|
|
|
220
220
|
export function salvageReportText(raw: string): string {
|
|
221
221
|
const trimmed = raw.trim();
|
|
222
222
|
if (trimmed.length <= 50) return '';
|
|
223
|
+
// Envelope sniff. Models routinely wrap the JSON envelope in a markdown code
|
|
224
|
+
// fence (```json … ```); a fenced envelope starts with backticks, not '{', so a
|
|
225
|
+
// bare startsWith('{') check shipped one verbatim as a user-facing report
|
|
226
|
+
// (observed in production 2026-08-20: a ```json-fenced {"action":"synthesize",…}
|
|
227
|
+
// blob, internal protocol fields included, published as the final report).
|
|
228
|
+
// Strip one leading fence line and a trailing fence before the shape check.
|
|
229
|
+
let sniff = trimmed;
|
|
230
|
+
const fence = sniff.match(/^```[a-zA-Z0-9_-]*[ \t]*\r?\n/);
|
|
231
|
+
if (fence) {
|
|
232
|
+
sniff = sniff.slice(fence[0].length).replace(/\r?\n```\s*$/, '').trim();
|
|
233
|
+
}
|
|
223
234
|
// Looks like a (broken) JSON envelope rather than a report — suppress it.
|
|
224
|
-
if (
|
|
235
|
+
if (sniff.startsWith('{') && /"(?:action|content|researchers)"\s*:/.test(sniff)) {
|
|
225
236
|
return '';
|
|
226
237
|
}
|
|
227
238
|
return raw;
|
|
@@ -637,7 +648,10 @@ export class PlanningService implements IPlanningService {
|
|
|
637
648
|
|
|
638
649
|
// Both lead prompts are deliberately ROUND-INVARIANT: they interpolate only values
|
|
639
650
|
// that are fixed for the whole run (complexity, team size, query budget, the
|
|
640
|
-
// disabled-tool list).
|
|
651
|
+
// disabled-tool list). One deliberate exception: the total-rounds figure inside
|
|
652
|
+
// complexity_guidance moves if steering extends maxRounds mid-run — the evaluator
|
|
653
|
+
// must see the true budget, so that steer costs one cache-prefix invalidation.
|
|
654
|
+
// Everything that changes between rounds — root query, round
|
|
641
655
|
// number, agenda, executed queries, steering, round-phase guidance — is appended to
|
|
642
656
|
// the END of the user message instead (see runContext below).
|
|
643
657
|
//
|
|
@@ -871,7 +885,15 @@ export class PlanningService implements IPlanningService {
|
|
|
871
885
|
return capped;
|
|
872
886
|
}
|
|
873
887
|
|
|
874
|
-
|
|
888
|
+
// A 'wait' plan is NOT stored: it carries no agenda (schema-legal with
|
|
889
|
+
// researchers: []), and storing it clobbered the last real delegate plan
|
|
890
|
+
// that the empty-after-cap fallback above and the retry's previousPlan /
|
|
891
|
+
// initialAgendaSection all read — so a rare 'wait' followed by one
|
|
892
|
+
// unparseable/transient retry degraded to an early empty synthesize
|
|
893
|
+
// instead of continuing the prior agenda.
|
|
894
|
+
if (finalPlan.action !== 'wait') {
|
|
895
|
+
this.currentPlans.set(sessionId, finalPlan);
|
|
896
|
+
}
|
|
875
897
|
return finalPlan;
|
|
876
898
|
} catch (err) {
|
|
877
899
|
// A genuine cancellation must propagate so the orchestrator can abort cleanly.
|
|
@@ -1200,7 +1222,20 @@ function unwrapPartialSynthesis(text: string, service: { parseJsonPlan(t: string
|
|
|
1200
1222
|
return content;
|
|
1201
1223
|
}
|
|
1202
1224
|
} catch {
|
|
1203
|
-
//
|
|
1225
|
+
// parseJsonPlan rejects a `{"content": "..."}` envelope with no "action"
|
|
1226
|
+
// (its non-synthesize guard demands a researchers array) — but that is
|
|
1227
|
+
// still an envelope, not prose, and returning it raw put the braces in the
|
|
1228
|
+
// final report: the exact failure this unwrap exists to prevent. Try the
|
|
1229
|
+
// action-less shape directly before giving up.
|
|
1230
|
+
try {
|
|
1231
|
+
const bare = JSON.parse(trimmed) as { content?: unknown };
|
|
1232
|
+
if (bare && typeof bare === 'object' && typeof bare.content === 'string' && bare.content.trim()) {
|
|
1233
|
+
logger.debug('[PlanningService] Partial synthesis returned an action-less JSON envelope; unwrapped its content');
|
|
1234
|
+
return bare.content.trim();
|
|
1235
|
+
}
|
|
1236
|
+
} catch {
|
|
1237
|
+
// Genuinely not JSON — prose that happens to mention "content". Use as written.
|
|
1238
|
+
}
|
|
1204
1239
|
}
|
|
1205
1240
|
return trimmed;
|
|
1206
1241
|
}
|
package/src/index.ts
CHANGED
|
@@ -556,7 +556,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
556
556
|
|
|
557
557
|
// Researcher sub-agent sessions already carry the authoritative steering copy:
|
|
558
558
|
// the executor (deep) and quick orchestrator inject "ADDITIONAL USER GUIDANCE"/
|
|
559
|
-
// "ADDITIONAL CONSIDERATIONS" into the researcher
|
|
559
|
+
// "ADDITIONAL CONSIDERATIONS" into the researcher's initial user message themselves
|
|
560
|
+
// (kept OUT of the system prompt so its cacheable prefix stays byte-identical
|
|
561
|
+
// across researchers — see researcher-executor.ts).
|
|
560
562
|
// Appending the same messages again here delivered every steering message
|
|
561
563
|
// TWICE to every researcher — return the prompt untouched. (The coordinator and
|
|
562
564
|
// the research lead never pass through this hook at all: they run via
|
|
@@ -243,6 +243,16 @@ export async function initBrowser(): Promise<void> {
|
|
|
243
243
|
firefox_user_prefs: {
|
|
244
244
|
'media.volume_scale': '0.0',
|
|
245
245
|
'media.autoplay.default': 5,
|
|
246
|
+
// SSRF: WebSocket connections bypass BOTH layers of the browser
|
|
247
|
+
// scrape defense — page.route('**') intercepts HTTP(S) only, and
|
|
248
|
+
// the serverAddr() rebinding backstop hangs off page.on('response'),
|
|
249
|
+
// which a WS handshake never fires. A hostile scraped page could
|
|
250
|
+
// therefore `new WebSocket('ws://10.x.x.x/...')` to probe or reach
|
|
251
|
+
// internal hosts with no validation, and write any reply into the
|
|
252
|
+
// DOM that page.content() returns. The scraper extracts static
|
|
253
|
+
// content (domcontentloaded), so nothing it needs arrives over WS —
|
|
254
|
+
// disable the channel outright at the engine level.
|
|
255
|
+
'network.websocket.max-connections': 0,
|
|
246
256
|
},
|
|
247
257
|
});
|
|
248
258
|
// Guard with a hard timeout. We catch the launchPromise rejection so it
|
|
@@ -241,8 +241,14 @@ async function extractSearchResults(page: any): Promise<any[]> {
|
|
|
241
241
|
let url = link.href;
|
|
242
242
|
try {
|
|
243
243
|
const u = new URL(url);
|
|
244
|
+
// searchParams.get() already percent-decodes the redirect target once.
|
|
245
|
+
// A second decodeURIComponent here double-decoded targets whose
|
|
246
|
+
// canonical form itself contains escapes (%26/%3F in query values,
|
|
247
|
+
// %2F path slashes, %27 in titles) — structurally corrupting them so
|
|
248
|
+
// the later scrape fetched the wrong resource — and threw URIError on
|
|
249
|
+
// a lone `%`, silently dropping the result via the hostname filter.
|
|
244
250
|
const uddg = u.searchParams.get('uddg');
|
|
245
|
-
if (uddg) url =
|
|
251
|
+
if (uddg) url = uddg;
|
|
246
252
|
} catch {
|
|
247
253
|
// ignore
|
|
248
254
|
}
|
|
@@ -542,7 +548,11 @@ export async function executeScrapeTask(
|
|
|
542
548
|
// Matches the PDF branch below AND the fetch layer's own detection
|
|
543
549
|
// (web-scraper.ts): a server can mislabel or omit content-type, so a
|
|
544
550
|
// `.pdf` URL extension is an equally-trusted signal, not just the header.
|
|
545
|
-
|
|
551
|
+
// resp.url() — THIS response's URL, not the original task URL: goto()
|
|
552
|
+
// follows redirects, and a download endpoint without `.pdf` that
|
|
553
|
+
// redirects to the real `.pdf` file must still arm the early abort
|
|
554
|
+
// (same redirect-aware fix the fetch layer got via currentUrl).
|
|
555
|
+
const looksLikePdf = ct?.includes('application/pdf') || String(resp.url()).toLowerCase().endsWith('.pdf');
|
|
546
556
|
if (!looksLikePdf || oversizedPdfError || poisonedError) return;
|
|
547
557
|
const declared = parseInt((await resp.headerValue('content-length').catch(() => null)) || '', 10);
|
|
548
558
|
if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
|
|
@@ -612,7 +622,14 @@ export async function executeScrapeTask(
|
|
|
612
622
|
// extracting the document — a silent content loss for exactly the URLs this
|
|
613
623
|
// feature advertises as supported, and specifically for the malformed/oddly-
|
|
614
624
|
// served PDFs most likely to reach this browser fallback in the first place.
|
|
615
|
-
|
|
625
|
+
// response.url(), NOT the original task `url`: goto() follows redirects, so
|
|
626
|
+
// classification must use the URL that actually answered — a download
|
|
627
|
+
// endpoint without `.pdf` redirecting to the real `.pdf` file must take the
|
|
628
|
+
// PDF branch, and a `.pdf` task URL redirecting to an HTML page must NOT
|
|
629
|
+
// (same two-sided redirect-aware fix the fetch layer got via currentUrl,
|
|
630
|
+
// and the early-abort check above already applies via resp.url()).
|
|
631
|
+
const finalUrl = String(response?.url?.() ?? url);
|
|
632
|
+
if (contentType.includes('application/pdf') || finalUrl.toLowerCase().endsWith('.pdf')) {
|
|
616
633
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
617
634
|
// An errored "PDF" is an error page: fail BEFORE buffering its body.
|
|
618
635
|
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
@@ -1571,6 +1571,8 @@ function isBenignScrapeFailure(error) {
|
|
|
1571
1571
|
msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
|
|
1572
1572
|
msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
|
|
1573
1573
|
msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
|
|
1574
|
+
msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
|
|
1575
|
+
msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
|
|
1574
1576
|
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
1575
1577
|
isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
|
|
1576
1578
|
}
|
|
@@ -2190,7 +2192,7 @@ async function extractSearchResults(page) {
|
|
|
2190
2192
|
try {
|
|
2191
2193
|
const u = new URL(url);
|
|
2192
2194
|
const uddg = u.searchParams.get("uddg");
|
|
2193
|
-
if (uddg) url =
|
|
2195
|
+
if (uddg) url = uddg;
|
|
2194
2196
|
} catch {
|
|
2195
2197
|
}
|
|
2196
2198
|
let urlHostname = "";
|
|
@@ -2332,7 +2334,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2332
2334
|
if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
|
|
2333
2335
|
finalMainFrameAddr = resp.serverAddr().catch(() => null);
|
|
2334
2336
|
const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
|
|
2335
|
-
const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
|
|
2337
|
+
const looksLikePdf = ct?.includes("application/pdf") || String(resp.url()).toLowerCase().endsWith(".pdf");
|
|
2336
2338
|
if (!looksLikePdf || oversizedPdfError || poisonedError) return;
|
|
2337
2339
|
const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
|
|
2338
2340
|
if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
|
|
@@ -2374,7 +2376,8 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2374
2376
|
}
|
|
2375
2377
|
const contentType = await response?.headerValue("content-type") || "";
|
|
2376
2378
|
const status = response?.status?.() ?? 0;
|
|
2377
|
-
|
|
2379
|
+
const finalUrl = String(response?.url?.() ?? url);
|
|
2380
|
+
if (contentType.includes("application/pdf") || finalUrl.toLowerCase().endsWith(".pdf")) {
|
|
2378
2381
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
2379
2382
|
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
2380
2383
|
const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
|
|
@@ -2805,7 +2808,17 @@ async function initBrowser() {
|
|
|
2805
2808
|
// silent either, so the mute is applied unconditionally.
|
|
2806
2809
|
firefox_user_prefs: {
|
|
2807
2810
|
"media.volume_scale": "0.0",
|
|
2808
|
-
"media.autoplay.default": 5
|
|
2811
|
+
"media.autoplay.default": 5,
|
|
2812
|
+
// SSRF: WebSocket connections bypass BOTH layers of the browser
|
|
2813
|
+
// scrape defense — page.route('**') intercepts HTTP(S) only, and
|
|
2814
|
+
// the serverAddr() rebinding backstop hangs off page.on('response'),
|
|
2815
|
+
// which a WS handshake never fires. A hostile scraped page could
|
|
2816
|
+
// therefore `new WebSocket('ws://10.x.x.x/...')` to probe or reach
|
|
2817
|
+
// internal hosts with no validation, and write any reply into the
|
|
2818
|
+
// DOM that page.content() returns. The scraper extracts static
|
|
2819
|
+
// content (domcontentloaded), so nothing it needs arrives over WS —
|
|
2820
|
+
// disable the channel outright at the engine level.
|
|
2821
|
+
"network.websocket.max-connections": 0
|
|
2809
2822
|
}
|
|
2810
2823
|
});
|
|
2811
2824
|
launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
@@ -162,6 +162,31 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
162
162
|
// read → the entry omits it and liveness falls back to a bare signal-0 check.
|
|
163
163
|
const myStartTime = await processLifecycle.getCurrentProcessStartTime();
|
|
164
164
|
|
|
165
|
+
// Read-only state (a NEWER build's state file is on disk — mixed-version
|
|
166
|
+
// window): every state write is suppressed, so the port=-1 candidacy claim
|
|
167
|
+
// below would never be visible to other processes, every process would
|
|
168
|
+
// "win" its own election, and the GPU lock would likewise always read as
|
|
169
|
+
// unowned — the exact concurrent-GPU-init segfault class election + lock
|
|
170
|
+
// exist to prevent. Degrade to a coordination-free mode instead: no
|
|
171
|
+
// election, local in-process embedder forced onto CPU (concurrent CPU
|
|
172
|
+
// inference across processes is safe; the exclusion protects GPU/WebGPU
|
|
173
|
+
// contexts, and a CPU device never touches the GPU lock). Slower embedding
|
|
174
|
+
// for the mixed-version window only; normal service resumes once every
|
|
175
|
+
// session runs the same build.
|
|
176
|
+
const localCpuForReadOnlyState = (): IEmbedder => {
|
|
177
|
+
logger.warn(
|
|
178
|
+
'[EmbeddingFactory] State file is read-only (written by a newer build): cross-process leader election and the GPU lock cannot coordinate in this mode. Skipping election and embedding locally on CPU until all sessions run the same build.',
|
|
179
|
+
);
|
|
180
|
+
const embedder = buildInProcessEmbedder({ ...cfg, EMBEDDING_DEVICE: 'cpu' }, stateManager);
|
|
181
|
+
_embeddingInstance = embedder;
|
|
182
|
+
_cachedModel = cfg.EMBEDDING_MODEL;
|
|
183
|
+
_cachedDevice = cfg.EMBEDDING_DEVICE;
|
|
184
|
+
return embedder;
|
|
185
|
+
};
|
|
186
|
+
if (stateManager.isReadOnly?.()) {
|
|
187
|
+
return localCpuForReadOnlyState();
|
|
188
|
+
}
|
|
189
|
+
|
|
165
190
|
// ---- Phase 1: Atomically claim candidacy under the state lock ----
|
|
166
191
|
// Writing port=-1 acts as a mutex: other processes that see it will wait
|
|
167
192
|
// for the real port rather than starting their own model initialization.
|
|
@@ -200,6 +225,15 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
200
225
|
return state;
|
|
201
226
|
});
|
|
202
227
|
|
|
228
|
+
// Re-check AFTER the claim: the read-only flag is set lazily by the first
|
|
229
|
+
// read that encounters the newer file — which can be this very updateState.
|
|
230
|
+
// In that case iAmCandidate is true but the claim write was silently
|
|
231
|
+
// suppressed: no other process can see it, so proceeding as leader is the
|
|
232
|
+
// concurrent-election hazard the pre-check above exists to avoid.
|
|
233
|
+
if (iAmCandidate && stateManager.isReadOnly?.()) {
|
|
234
|
+
return localCpuForReadOnlyState();
|
|
235
|
+
}
|
|
236
|
+
|
|
203
237
|
// A live leader serving a DIFFERENT model must not be adopted: two sessions
|
|
204
238
|
// with different EMBEDDING_MODELs would cross-embed (same-dimension models
|
|
205
239
|
// corrupt the vector space silently). Fall back to a local in-process
|
|
@@ -178,19 +178,39 @@ class SerialQueue {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
|
-
* Resolve once no native call is in flight
|
|
182
|
-
* to wait out a still-running
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* leak-to-process-exit
|
|
181
|
+
* Resolve once no native call is in flight AND no queued task is still due
|
|
182
|
+
* to run. Used by EmbeddingServer.shutdown() to wait out a still-running
|
|
183
|
+
* inference before disposing the pipeline — without this, disposal could
|
|
184
|
+
* run concurrently with a legitimately slow (not hung) embed/embedMany call
|
|
185
|
+
* on the same re-entrant-locked GPU context, the exact native-segfault
|
|
186
|
+
* class this queue exists to prevent. No timeout: a promise that truly
|
|
187
|
+
* never settles is already an accepted, pre-existing leak-to-process-exit
|
|
188
|
+
* scenario elsewhere in this class (see the poison path's
|
|
188
189
|
* disposeEmbedder=false handling) — better to leak than risk concurrent
|
|
189
190
|
* native access.
|
|
191
|
+
*
|
|
192
|
+
* Checking only `currentWork` is NOT enough with a second task already
|
|
193
|
+
* queued: a task's `run()` clears `currentWork` to null in its `finally`
|
|
194
|
+
* (once its native call has settled), and `pump()` only claims it again for
|
|
195
|
+
* the next task on a LATER microtask tick, once `run()` for the current
|
|
196
|
+
* task has fully unwound and `await entry.run()` resumes. If a caller's own
|
|
197
|
+
* check happens to land in that gap, `currentWork` reads as null even
|
|
198
|
+
* though a second native call is about to start (or a queue in the process
|
|
199
|
+
* of unwinding to its next task hasn't reached it yet) — exactly the window
|
|
200
|
+
* disposal must not run in. `running` stays true for the queue's entire
|
|
201
|
+
* drain, across every task in `tasks`, not just the current one, so it has
|
|
202
|
+
* no equivalent gap and closes this one.
|
|
190
203
|
*/
|
|
191
204
|
async waitForIdle(): Promise<void> {
|
|
192
|
-
while (this.currentWork) {
|
|
193
|
-
|
|
205
|
+
while (this.currentWork || this.running) {
|
|
206
|
+
if (this.currentWork) {
|
|
207
|
+
await this.currentWork.catch(() => {});
|
|
208
|
+
} else {
|
|
209
|
+
// Nothing executing right now, but pump() is still mid-drain (about
|
|
210
|
+
// to claim the next queued task, or unwinding) — yield a microtask
|
|
211
|
+
// so it can advance instead of reporting idle into this exact gap.
|
|
212
|
+
await Promise.resolve();
|
|
213
|
+
}
|
|
194
214
|
}
|
|
195
215
|
}
|
|
196
216
|
|
|
@@ -330,6 +350,11 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
330
350
|
// stuck native thread (itself hanging) or touch a device-lost context. Clearing
|
|
331
351
|
// the state registration + closing the HTTP server is enough for re-election;
|
|
332
352
|
// the wedged native resources are abandoned to eventual process exit.
|
|
353
|
+
// Latch the wedge on the Embedder itself too: it stays registered for
|
|
354
|
+
// process-exit cleanup (registerGlobalEmbedder), and without the latch that
|
|
355
|
+
// exit task's dispose() would call pipeline.dispose() on the wedged session
|
|
356
|
+
// anyway — re-creating at teardown the exact hang this path avoids.
|
|
357
|
+
this.embedder.markPipelineWedged();
|
|
333
358
|
void this.shutdown(false);
|
|
334
359
|
this.externalOnPoison?.(err);
|
|
335
360
|
}
|
|
@@ -198,6 +198,21 @@ export class StateManager {
|
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* True once this process has seen a newer-build state file and entered
|
|
203
|
+
* read-only mode (writes suppressed, reads served from an in-memory default).
|
|
204
|
+
* In this mode NO cross-process coordination that lives in the state file
|
|
205
|
+
* works: a GPU-lock acquire always sees "no owner" and the embedding
|
|
206
|
+
* leader-election CAS always sees an empty slot, so every read-only process
|
|
207
|
+
* would self-elect — the concurrent-GPU-init class those mechanisms exist to
|
|
208
|
+
* prevent. Consumers that rely on that exclusion (the embedding factory) must
|
|
209
|
+
* check this and degrade to a coordination-free mode (local CPU embedding).
|
|
210
|
+
* The flag is set lazily by the first read that encounters the newer file.
|
|
211
|
+
*/
|
|
212
|
+
isReadOnly(): boolean {
|
|
213
|
+
return this.stateTooNew;
|
|
214
|
+
}
|
|
215
|
+
|
|
201
216
|
// ==================== Core State Operations ====================
|
|
202
217
|
|
|
203
218
|
/**
|