@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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +396 -125
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +17 -4
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +131 -2
  9. package/docs/CONFIGURATION.md +2 -2
  10. package/docs/SDK.md +1 -1
  11. package/package.json +3 -2
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +31 -9
  15. package/src/constants.ts +23 -4
  16. package/src/core/interfaces/state-manager-interfaces.ts +8 -0
  17. package/src/core/llm/research-model-resolver.ts +38 -0
  18. package/src/core/planning-service.ts +39 -4
  19. package/src/index.ts +3 -1
  20. package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
  21. package/src/infrastructure/browser/thread-worker-messaging.ts +20 -3
  22. package/src/infrastructure/browser/thread-worker.mjs +17 -4
  23. package/src/infrastructure/embedding/embedding-factory.ts +34 -0
  24. package/src/infrastructure/embedding/embedding-server.ts +34 -9
  25. package/src/infrastructure/state/state-manager.ts +15 -0
  26. package/src/knowledge/embedder.ts +36 -7
  27. package/src/knowledge/store-operations.ts +47 -38
  28. package/src/knowledge/store.ts +86 -8
  29. package/src/knowledge/writer-queue.ts +4 -2
  30. package/src/observers/research-observer-impl.ts +14 -6
  31. package/src/orchestration/deep-research-orchestrator.ts +15 -3
  32. package/src/orchestration/quick-research-orchestrator.ts +37 -11
  33. package/src/orchestration/research-orchestration-service.ts +21 -2
  34. package/src/orchestration/research-synthesis-service.ts +36 -0
  35. package/src/orchestration/researcher-executor.ts +46 -17
  36. package/src/orchestration/researcher.ts +2 -2
  37. package/src/orchestration/session-state.ts +36 -4
  38. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  39. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  40. package/src/prompts/researcher.md +2 -14
  41. package/src/sdk.ts +65 -52
  42. package/src/stackexchange/index.ts +8 -0
  43. package/src/tools/health-tool-definition.ts +11 -1
  44. package/src/tools/index.ts +2 -1
  45. package/src/tools/research-knowledge-search.ts +58 -10
  46. package/src/tools/research-tool-definition.ts +27 -16
  47. package/src/tools/scrape.ts +8 -1
  48. package/src/tools/stackexchange.ts +14 -7
  49. package/src/utils/llm-usage.ts +4 -1
  50. package/src/utils/log-utils.ts +5 -2
  51. package/src/utils/metrics-summary.ts +21 -1
  52. package/src/utils/tool-usage-tracker.ts +6 -3
  53. package/src/web-research/scraper-utils.ts +6 -2
  54. package/src/web-research/web-scraper.ts +9 -0
  55. package/src/youtube/index.ts +11 -1
  56. package/src/youtube/potoken.ts +20 -2
package/docs/SDK.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ## SDK
2
2
 
3
- The programmatic SDK for scripts, CI, and custom tooling. For configuration (the
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.4.3",
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.4.3", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
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
- toStderr(`[pi-research] searching knowledge store (${queries.length} query/${queries.length === 1 ? '' : 's'})…\n`);
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
- Object.assign(merged, legacyEnv);
755
-
756
- // Auto-migrate legacy settings to the central registry — but only LOCAL_SCOPE_KEYS. The
757
- // registry is project-scoped storage by design (saveConfig writes only these keys to it);
758
- // persisting user-scoped legacy keys would freeze them per-workspace and silently override
759
- // the user's global config.env forever. Non-local legacy keys still apply to THIS resolution
760
- // via the Object.assign(merged, legacyEnv) above — they just aren't persisted.
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
- logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
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
- return batches === 0 || batches > 99 ? 999999 : batches;
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 (trimmed.startsWith('{') && /"(?:action|content|researchers)"\s*:/.test(trimmed)) {
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). Everything that changes between rounds — root query, round
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
- this.currentPlans.set(sessionId, finalPlan);
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
- // Not an envelope — it is prose that happens to mention "content". Use it as written.
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 system prompt themselves.
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 = decodeURIComponent(uddg || '');
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
- const looksLikePdf = ct?.includes('application/pdf') || url.toLowerCase().endsWith('.pdf');
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
- if (contentType.includes('application/pdf') || url.toLowerCase().endsWith('.pdf')) {
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 = decodeURIComponent(uddg || "");
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
- if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
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. Used by EmbeddingServer.shutdown()
182
- * to wait out a still-running inference before disposing the pipeline —
183
- * without this, disposal could run concurrently with a legitimately slow (not
184
- * hung) embed/embedMany call on the same re-entrant-locked GPU context, the
185
- * exact native-segfault class this queue exists to prevent. No timeout: a
186
- * promise that truly never settles is already an accepted, pre-existing
187
- * leak-to-process-exit scenario elsewhere in this class (see the poison path's
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
- await this.currentWork.catch(() => {});
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
  /**