@lincoln504/pi-research 1.6.0 → 1.6.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.
@@ -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.6.0", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.6.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/dist/cli.mjs CHANGED
@@ -2216,6 +2216,29 @@ function lastCitedLinksHeader(text) {
2216
2216
  }
2217
2217
  return { lineStart, textStart };
2218
2218
  }
2219
+ function stripReportPreamble(text) {
2220
+ const trimmed = text.trimStart();
2221
+ const breakAt = trimmed.indexOf("\n\n");
2222
+ if (breakAt < 0) return { body: text, preamble: "" };
2223
+ const head = trimmed.slice(0, breakAt).trim();
2224
+ const rest = trimmed.slice(breakAt + 2).trimStart();
2225
+ if (head.includes("\n")) return { body: text, preamble: "" };
2226
+ if (!rest) return { body: text, preamble: "" };
2227
+ if (!REPORT_NARRATION_OPENER.test(head)) return { body: text, preamble: "" };
2228
+ if (!REPORT_NARRATION_SUBJECT.test(head)) return { body: text, preamble: "" };
2229
+ return { body: rest, preamble: head };
2230
+ }
2231
+ function synthesisProseBody(text) {
2232
+ const { lineStart } = lastCitedLinksHeader(text);
2233
+ return (lineStart < 0 ? text : text.slice(0, lineStart)).trim();
2234
+ }
2235
+ function isAnalysisFreeSynthesis(text, minProseChars) {
2236
+ const prose = synthesisProseBody(text);
2237
+ const stripped = prose.replace(/^[ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)/gm, "").trim();
2238
+ if (minProseChars > 0 && stripped.length < minProseChars) return true;
2239
+ if (!/[.!?。!?।॥۔؟።]/.test(stripped)) return true;
2240
+ return false;
2241
+ }
2219
2242
  function parseCitations(report) {
2220
2243
  const headerIdx = lastCitedLinksHeaderIndex(report);
2221
2244
  if (headerIdx < 0) return [];
@@ -2268,11 +2291,14 @@ function parseCitations(report) {
2268
2291
  }
2269
2292
  return citations;
2270
2293
  }
2294
+ var REPORT_NARRATION_OPENER, REPORT_NARRATION_SUBJECT;
2271
2295
  var init_text_utils = __esm({
2272
2296
  "src/utils/text-utils.ts"() {
2273
2297
  "use strict";
2274
2298
  init_logger();
2275
2299
  init_url_utils();
2300
+ REPORT_NARRATION_OPENER = /^(?:I|I'll|I'm|Let me|Now I|Next I|First,? I)\b/i;
2301
+ REPORT_NARRATION_SUBJECT = /\b(?:report|synthesi[sz]\w*|findings|material|sources|scraped|gathered|research\w*|summar\w+)\b/i;
2276
2302
  }
2277
2303
  });
2278
2304
 
@@ -3217,7 +3243,7 @@ function getMaxScrapeBatches(config, cachingActive = false) {
3217
3243
  function resolveExcludedTools(callerExclusions, configDisabled = []) {
3218
3244
  return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
3219
3245
  }
3220
- var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS, RESEARCH_TOOL_NAMES;
3246
+ var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS, MIN_SYNTHESIS_PROSE_CHARS, QUICK_THIN_REPORT_NOTICE, SYNTHESIS_FALLBACK_NOTICE, RESEARCH_TOOL_NAMES;
3221
3247
  var init_constants = __esm({
3222
3248
  "src/constants.ts"() {
3223
3249
  "use strict";
@@ -3249,6 +3275,9 @@ var init_constants = __esm({
3249
3275
  MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
3250
3276
  MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
3251
3277
  DEFAULT_EXCLUDED_TOOLS = ["grep"];
3278
+ MIN_SYNTHESIS_PROSE_CHARS = 800;
3279
+ QUICK_THIN_REPORT_NOTICE = "_Note: this run did not produce a substantive write-up. The sources below were retrieved and are cited accurately, but the summary above should not be treated as a complete answer \u2014 consider re-running, or running at a deeper level._";
3280
+ SYNTHESIS_FALLBACK_NOTICE = "_Note: the synthesis step did not produce a usable summary for this run, so the individual researcher reports are included below in full. The sources cited at the end of this document are unaffected._";
3252
3281
  RESEARCH_TOOL_NAMES = [
3253
3282
  "search",
3254
3283
  "scrape",
@@ -23866,6 +23895,19 @@ ${userMessage}`);
23866
23895
  const synthesisService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
23867
23896
  synthesisService.storeReport(this.options.researchId, "quick", result);
23868
23897
  result = synthesisService.getReport(this.options.researchId, "quick") ?? result;
23898
+ const { body: withoutPreamble, preamble } = stripReportPreamble(result);
23899
+ if (preamble) {
23900
+ logger.debug(`[QuickOrchestrator] Dropped a ${preamble.length}-char narration preamble from the report: ${preamble.slice(0, 120)}`);
23901
+ metrics.increment("research_report_preamble_stripped_total", 1, { mode: "quick" });
23902
+ result = withoutPreamble;
23903
+ }
23904
+ if (isAnalysisFreeSynthesis(result, 0)) {
23905
+ logger.warn(`[QuickOrchestrator] Report contains no complete sentence (${synthesisProseBody(result).length} char(s) of prose) \u2014 flagging it in the report`);
23906
+ metrics.increment("research_synthesis_analysis_free_total", 1, { mode: "quick" });
23907
+ result = `${result.trim()}
23908
+
23909
+ ${QUICK_THIN_REPORT_NOTICE}`;
23910
+ }
23869
23911
  result = synthesisService.ensureCitedLinks(this.options.researchId, result);
23870
23912
  const citationSource = result;
23871
23913
  const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
@@ -23976,6 +24018,7 @@ ${result}`);
23976
24018
  init_logger();
23977
24019
  init_metrics();
23978
24020
  init_constants();
24021
+ init_text_utils();
23979
24022
  init_service_interfaces();
23980
24023
  init_service_registry();
23981
24024
  init_config();
@@ -24291,6 +24334,13 @@ var DeepResearchOrchestrator = class {
24291
24334
  observer?.onEvaluationDecision?.("synthesize", finalReport, this.currentRound);
24292
24335
  }
24293
24336
  let result = finalReport.content || "";
24337
+ try {
24338
+ const parsed = JSON.parse(result);
24339
+ if (parsed && typeof parsed === "object" && typeof parsed["content"] === "string") {
24340
+ result = parsed["content"];
24341
+ }
24342
+ } catch {
24343
+ }
24294
24344
  if (!result.trim()) {
24295
24345
  if (hasReports) {
24296
24346
  logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
@@ -24298,13 +24348,15 @@ var DeepResearchOrchestrator = class {
24298
24348
  } else {
24299
24349
  throw new Error("Research produced no report \u2014 no source material was collected.");
24300
24350
  }
24301
- }
24302
- try {
24303
- const parsed = JSON.parse(result);
24304
- if (parsed && typeof parsed === "object" && typeof parsed["content"] === "string") {
24305
- result = parsed["content"];
24306
- }
24307
- } catch {
24351
+ } else if (hasReports && isAnalysisFreeSynthesis(result, MIN_SYNTHESIS_PROSE_CHARS)) {
24352
+ const proseChars = synthesisProseBody(result).length;
24353
+ logger.warn(`[DeepOrchestrator] LLM synthesis for ${researchId} carried no analysis (${proseChars} char(s) of prose, min ${MIN_SYNTHESIS_PROSE_CHARS}) \u2014 appending ${synthesisCheckService.getReportCount(researchId)} researcher report(s) below it`);
24354
+ metrics.increment("research_synthesis_analysis_free_total", 1, { mode: "deep" });
24355
+ result = [
24356
+ result.trim(),
24357
+ SYNTHESIS_FALLBACK_NOTICE,
24358
+ synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound)
24359
+ ].join("\n\n");
24308
24360
  }
24309
24361
  const synthesisService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
24310
24362
  result = synthesisService.ensureCitedLinks(researchId, result);
@@ -25172,7 +25224,7 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
25172
25224
  if (reportCount === 0) {
25173
25225
  synthesis += "No researcher reports were generated before the process stopped.";
25174
25226
  } else {
25175
- synthesis += `This is an automated synthesis of ${reportCount} individual researcher report(s) gathered before the process was interrupted.
25227
+ synthesis += `This is an automated compilation of ${reportCount} individual researcher report(s). The synthesis step did not produce a usable summary for this run, so the findings are presented as collected.
25176
25228
 
25177
25229
  `;
25178
25230
  synthesis += Array.from(reports.entries()).map(([id, report]) => `Researcher ${id}
@@ -62,6 +62,8 @@ Write your report immediately after scraping is complete, or as soon as a tool r
62
62
  ## Report Format
63
63
 
64
64
  {{digest_section}}
65
+ Emit the report and nothing else. No preamble, and no narration of what you are about to do — not "I have gathered extensive material…", not "Let me now synthesize my findings…". Begin at the report's own first line.
66
+
65
67
  Use [N] inline citations throughout. Write in plain prose — no markdown headings, no bullet points, no bold or italic text. Use clear paragraphs separated by blank lines. The full CITED LINKS list goes at the very end.
66
68
 
67
69
  [Topic Title]
@@ -2334,12 +2334,19 @@ async function readPdfDownload(download, url, page, pendingAddrChecks, getPoison
2334
2334
  }
2335
2335
  const filePath = await download.path();
2336
2336
  if (!filePath) throw new Error("Fetch blocked: download produced no readable file");
2337
- const { size } = await fsp.stat(filePath);
2338
- if (size > MAX_PDF_SIZE) {
2339
- const sizeMB = Math.round(size / 1024 / 1024);
2340
- throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2337
+ const handle = await fsp.open(filePath, "r");
2338
+ let buffer;
2339
+ try {
2340
+ const { size } = await handle.stat();
2341
+ if (size > MAX_PDF_SIZE) {
2342
+ const sizeMB = Math.round(size / 1024 / 1024);
2343
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2344
+ }
2345
+ buffer = await handle.readFile();
2346
+ } finally {
2347
+ await handle.close().catch(() => {
2348
+ });
2341
2349
  }
2342
- const buffer = await fsp.readFile(filePath);
2343
2350
  await Promise.allSettled(pendingAddrChecks);
2344
2351
  const poisoned = getPoisonedError();
2345
2352
  if (poisoned) throw poisoned;
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.1] - 2026-08-23
9
+
10
+ A report that contained no analysis was being delivered as a completed run, because
11
+ the engine's own repair step padded it out. Fixing that exposed a second way into the
12
+ same failure, and a review of the machinery around it turned up a third defect.
13
+
14
+ ### Fixed
15
+
16
+ - **A synthesis with no analysis in it reached users looking like a finished report.** The completion guard was `if (!result.trim())` — a lone title line passes it, and `ensureCitedLinks` then appends the rebuilt source list underneath. Two runs in the retained logs did exactly that: 131 and 147 characters of prose delivered as 6.8KB and 9.9KB documents with a heading, a full citation list, and not one sentence between them, both reported as complete. The engine's own repair machinery was what disguised the failure. A synthesis is now checked for analysis by two independent signals — prose volume measured BEFORE the citation block, and whether any sentence ends at all — and the response is **additive**: the model's own text is kept, the researchers' reports are appended below it, and a notice states what happened. That is the part that matters. Because a false positive can only ever add material, never discard it, the threshold can sit well clear of real reports (800 characters, against a smallest observed legitimate report of 1056) rather than hugging the two failures out of fear of destroying a good run. Quick mode gets the structural signal only: it has no researcher reports to fall back on, so the sole available response is to flag the report — and telling a reader their correct one-sentence answer is untrustworthy is worse than missing a thin one. Its notice says the run produced no substantive write-up and that the cited sources are still accurate (`src/orchestration/deep-research-orchestrator.ts`, `src/orchestration/quick-research-orchestrator.ts`, `src/utils/text-utils.ts`, `src/constants.ts`).
17
+ - **The same failure had a second route in, through the raw JSON envelope.** When upstream JSON parsing fails, the synthesis arrives as the whole envelope string rather than the extracted markdown, and an unwrap ran afterwards to recover it. Both completion checks therefore measured the envelope — long, well punctuated, and healthy-looking whatever it wrapped — and the unwrap then replaced it with the title or the empty string it was hiding, with nothing left to catch either. An envelope wrapping empty content produced a citation list with no document above it. The unwrap now runs first, so every check judges the report itself (`src/orchestration/deep-research-orchestrator.ts`).
18
+ - **The structural signal assumed Latin punctuation.** It looked for `.!?` plus the CJK full stops, so a correct report written with a danda, an Arabic full stop or an Ethiopic full stop ended no sentence as far as the check was concerned. In quick mode, where that signal runs alone, every such report would have been flagged as thin (`src/utils/text-utils.ts`).
19
+ - **A time-of-check/time-of-use gap in the attachment-served PDF path.** The 100MB cap was measured with `fsp.stat(filePath)` and the bytes then read with `fsp.readFile(filePath)` — two separate lookups of a path the browser process is still writing to, so the file that was measured need not be the file that was read (CodeQL `js/file-system-race`, alert #266). The size is now taken from an open handle and the bytes read back through that same handle, which cannot be swapped underneath, and the descriptor is released on both the accept and the over-cap reject (`src/infrastructure/browser/thread-worker-messaging.ts`).
20
+
21
+ ### Changed
22
+
23
+ - **A quick report no longer opens by narrating itself.** Live runs delivered documents whose first line was "I have gathered extensive material… Let me now synthesize my findings into a comprehensive report." and "I have gathered sufficient material from multiple authoritative sources. I will now synthesize the full report." Quick mode has no synthesis step behind it to absorb that, so it landed as the reader's first sentence. The researcher prompt now forbids it — and, because that instruction was measurably not obeyed (it still appeared in 2 of 3 live runs after the prompt change), quick mode also removes the line. Removing text is destructive, so every condition on it is a required narrowing rather than a score: the segment must be a single line, separated from the report by a blank line, first-person AND about the act of reporting, and must leave a report behind. A report that opens as the format requires — with a topic title — matches none of them. The strip runs BEFORE the analysis check above, so a title-only report cannot pass that check on the strength of the narration's full stop, and each removal is counted in `research_report_preamble_stripped_total` (`src/prompts/researcher.md`, `src/utils/text-utils.ts`, `src/orchestration/quick-research-orchestrator.ts`).
24
+
25
+ ### Removed
26
+
27
+ - **The GitHub Release job added in 1.6.0 is gone; tags remain the only release marker.** Cutting a Release per tag was not wanted — the tag plus `docs/CHANGELOG.md` is the intended record — so the job was removed and the one Release it created (v1.6.0) was deleted. The tag itself is untouched, and nothing about publishing changes: the release workflow is still CI-gate → tarball test → npm publish (`.github/workflows/release.yml`).
28
+
29
+ ### Verified
30
+
31
+ - Full gates green: 2917 unit tests across 230 files, type-check over source and tests, lint, dependency-cruiser, and the integration suite (77 passed, 26 skipped for live network).
32
+ - Live runs across all four front-ends: CLI quick and depth-1 (15.5KB and 16.5KB reports), the agent skill launcher through its installed link (11.8KB, opening straight at the report), the pi extension, and the SDK (12.1KB, clean shutdown). Every report was replayed through both guards afterwards: the one real preamble was removed, and neither analysis check fired on any of them.
33
+
8
34
  ## [1.6.0] - 2026-08-22
9
35
 
10
36
  Driven by log forensics rather than by the test suite: every entry below was found
@@ -32,7 +58,6 @@ and recovered conditions reported as terminal failures.
32
58
 
33
59
  - **`bgutils-js` upgraded 3.2.0 → 4.0.3**, the YouTube PoToken stack's most upstream-brittle dependency and the one pin with no written rationale. v4 is a hard break: the barrel entry is gone (the package now exposes only `/botguard`, `/webpo` and `/utils` subpaths, so the previous bare import resolves to nothing), `BG.Challenge.create` became a free `getChallenge()` whose config drops `globalObj`/`identifier` and renames `fetch` to `fetchFunction`, and `BotGuardClient`'s `globalObj` became `globalObject`. All three renames fail silently rather than loudly — a wrong `globalObject` leaves the VM with no global to attach to and the minter factory returns a non-function — so each is now pinned by a test that asserts the field names v4 actually reads, and the whole flow was re-validated live: a real PoToken minted against Google's attestation endpoint and a real transcript fetched. Staying on v3 meant sitting on an unmaintained line for the one subsystem that breaks whenever YouTube changes BotGuard, with the failure mode being a silent empty transcript body (`src/youtube/potoken.ts`).
34
60
  - **`jsdom` deliberately stays at 29.1.1.** jsdom 30 narrows its supported Node range to `^22.22.2 || ^24.15.0 || >=26.0.0`, which excludes both part of this package's own `>=22.19.0` floor and Node 25 entirely; 29's `^20.19.0 || ^22.13.0 || >=24.0.0` covers the whole supported surface. Attempted, measured against the engine ranges, reverted — the pin now has the same recorded rationale as the others (`package.json`).
35
- - **Tags now produce GitHub Releases.** 44 tags existed and zero Releases, so each version's changelog entry was reachable only by reading `docs/CHANGELOG.md` in the tree. A new job extracts that version's section from the tag's own copy of the changelog and publishes it. It runs after `publish` and needs it, so a failed npm publish cannot announce a version nobody can install; it holds `contents: write` and nothing else — in particular no `id-token` — and installs nothing, so no dependency lifecycle script runs while that token is live. A version that reached npm with no changelog section fails the job rather than publishing an empty Release (`.github/workflows/release.yml`).
36
61
 
37
62
  ### Added
38
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
package/src/constants.ts CHANGED
@@ -201,6 +201,36 @@ export const MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
201
201
  */
202
202
  export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
203
203
 
204
+ /**
205
+ * Volume signal for {@link isAnalysisFreeSynthesis}: prose below this, measured BEFORE
206
+ * the CITED LINKS section, is treated as a synthesis containing no analysis.
207
+ *
208
+ * Calibrated against 41 real reports from the run logs rather than guessed. The two
209
+ * broken ones — each a lone title line, delivered as a "complete" 6.8KB and 9.9KB
210
+ * document because the engine's own citation rebuild padded them out — measured 131 and
211
+ * 147 characters. The shortest LEGITIMATE report measured 1056.
212
+ *
213
+ * Set at 800 rather than hugging the failures, because the response to tripping it is
214
+ * ADDITIVE: the model's own text is kept and the researcher reports are appended below
215
+ * it. A false positive therefore costs some duplication, never a lost report, so the
216
+ * boundary is placed to catch marginal cases rather than to avoid them. It is also only
217
+ * one of two independent signals — a synthesis with no sentence-ending punctuation is
218
+ * caught at any length, so a long title would not slip through on volume alone.
219
+ */
220
+ export const MIN_SYNTHESIS_PROSE_CHARS = 800;
221
+
222
+ /** Flags a quick-mode report that contains no analysis, where there is no fallback to add. */
223
+ export const QUICK_THIN_REPORT_NOTICE =
224
+ '_Note: this run did not produce a substantive write-up. The sources below were ' +
225
+ 'retrieved and are cited accurately, but the summary above should not be treated as ' +
226
+ 'a complete answer — consider re-running, or running at a deeper level._';
227
+
228
+ /** Explains an additive fallback in the delivered report, so the reader is never misled. */
229
+ export const SYNTHESIS_FALLBACK_NOTICE =
230
+ '_Note: the synthesis step did not produce a usable summary for this run, so the ' +
231
+ 'individual researcher reports are included below in full. The sources cited at the ' +
232
+ 'end of this document are unaffected._';
233
+
204
234
  /**
205
235
  * The complete set of excludable research tool names — the vocabulary
206
236
  * `--exclude-tools` / `excludeTools` / PI_RESEARCH_DISABLED_TOOLS accept.
@@ -524,14 +524,24 @@ async function readPdfDownload(
524
524
 
525
525
  // Same 100MB policy cap the PDF branch enforces, checked from the file size so the
526
526
  // bytes are never read into the worker at all when the file is over the limit.
527
- const { size } = await fsp.stat(filePath);
528
- if (size > MAX_PDF_SIZE) {
529
- const sizeMB = Math.round(size / 1024 / 1024);
530
- throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
527
+ //
528
+ // The size is measured on an OPEN HANDLE and the bytes are read back through that
529
+ // same handle, so the check and the read cannot land on two different files: a
530
+ // stat-by-path followed by a read-by-path is a time-of-check/time-of-use gap, and
531
+ // the download directory is a path the browser process is still writing to.
532
+ const handle = await fsp.open(filePath, 'r');
533
+ let buffer: Buffer;
534
+ try {
535
+ const { size } = await handle.stat();
536
+ if (size > MAX_PDF_SIZE) {
537
+ const sizeMB = Math.round(size / 1024 / 1024);
538
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
539
+ }
540
+ buffer = await handle.readFile();
541
+ } finally {
542
+ await handle.close().catch(() => {});
531
543
  }
532
544
 
533
- const buffer = await fsp.readFile(filePath);
534
-
535
545
  await Promise.allSettled(pendingAddrChecks);
536
546
  const poisoned = getPoisonedError();
537
547
  if (poisoned) throw poisoned;
@@ -2334,12 +2334,19 @@ async function readPdfDownload(download, url, page, pendingAddrChecks, getPoison
2334
2334
  }
2335
2335
  const filePath = await download.path();
2336
2336
  if (!filePath) throw new Error("Fetch blocked: download produced no readable file");
2337
- const { size } = await fsp.stat(filePath);
2338
- if (size > MAX_PDF_SIZE) {
2339
- const sizeMB = Math.round(size / 1024 / 1024);
2340
- throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2337
+ const handle = await fsp.open(filePath, "r");
2338
+ let buffer;
2339
+ try {
2340
+ const { size } = await handle.stat();
2341
+ if (size > MAX_PDF_SIZE) {
2342
+ const sizeMB = Math.round(size / 1024 / 1024);
2343
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
2344
+ }
2345
+ buffer = await handle.readFile();
2346
+ } finally {
2347
+ await handle.close().catch(() => {
2348
+ });
2341
2349
  }
2342
- const buffer = await fsp.readFile(filePath);
2343
2350
  await Promise.allSettled(pendingAddrChecks);
2344
2351
  const poisoned = getPoisonedError();
2345
2352
  if (poisoned) throw poisoned;
@@ -9,7 +9,8 @@ import { type Model } from '@earendil-works/pi-ai';
9
9
  import { logger } from '../logger.ts';
10
10
  import { metrics } from '../utils/metrics.ts';
11
11
  import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
12
- import { MAX_EXTRA_ROUNDS_WITH_STEERING, resolveExcludedTools } from '../constants.ts';
12
+ import { MAX_EXTRA_ROUNDS_WITH_STEERING, MIN_SYNTHESIS_PROSE_CHARS, SYNTHESIS_FALLBACK_NOTICE, resolveExcludedTools } from '../constants.ts';
13
+ import { synthesisProseBody, isAnalysisFreeSynthesis } from '../utils/text-utils.ts';
13
14
  import {
14
15
  getMaxRounds,
15
16
  } from '../core/planning-utils.ts';
@@ -594,6 +595,19 @@ export class DeepResearchOrchestrator {
594
595
 
595
596
  let result = finalReport.content || '';
596
597
 
598
+ // Unwrap FIRST, before anything judges the content. If the LLM returned the full
599
+ // JSON envelope as its response text and JSON parsing failed upstream,
600
+ // finalReport.content is the raw JSON string rather than the extracted markdown —
601
+ // and every check below would then be measuring the envelope instead of the report.
602
+ // An envelope wrapping an empty or title-only content read as a long, well-punctuated
603
+ // document, so neither guard fired and the unwrapped nothing went straight out.
604
+ try {
605
+ const parsed: unknown = JSON.parse(result);
606
+ if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>)['content'] === 'string') {
607
+ result = (parsed as Record<string, unknown>)['content'] as string;
608
+ }
609
+ } catch { /* result is not JSON — use as-is */ }
610
+
597
611
  if (!result.trim()) {
598
612
  if (hasReports) {
599
613
  // Content is empty but reports exist — build a fallback synthesis.
@@ -603,18 +617,30 @@ export class DeepResearchOrchestrator {
603
617
  // No reports, no recorded failures, and nothing to ship either.
604
618
  throw new Error('Research produced no report — no source material was collected.');
605
619
  }
620
+ } else if (hasReports && isAnalysisFreeSynthesis(result, MIN_SYNTHESIS_PROSE_CHARS)) {
621
+ // The synthesis LLM returned something, but nothing that constitutes analysis —
622
+ // twice in the run logs it was a lone title line. `!result.trim()` passes on
623
+ // that, and ensureCitedLinks then appends the rebuilt source list underneath, so
624
+ // a 131-character failure reached the user as a 6.8KB document with a heading,
625
+ // citations, and not one sentence between them, reported as a successful run.
626
+ //
627
+ // The response is deliberately ADDITIVE rather than a replacement. Whatever the
628
+ // model did write is kept, the researchers' own reports are appended below it,
629
+ // and a notice states plainly what happened. That matters more than the
630
+ // detector's precision: because a false positive can only ever ADD material,
631
+ // never discard it, the threshold can sit far away from real reports instead of
632
+ // hugging them out of fear of destroying a legitimate one. A missed detection
633
+ // costs a thin report; a false detection costs a few paragraphs of duplication.
634
+ const proseChars = synthesisProseBody(result).length;
635
+ logger.warn(`[DeepOrchestrator] LLM synthesis for ${researchId} carried no analysis (${proseChars} char(s) of prose, min ${MIN_SYNTHESIS_PROSE_CHARS}) — appending ${synthesisCheckService.getReportCount(researchId)} researcher report(s) below it`);
636
+ metrics.increment('research_synthesis_analysis_free_total', 1, { mode: 'deep' });
637
+ result = [
638
+ result.trim(),
639
+ SYNTHESIS_FALLBACK_NOTICE,
640
+ synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound),
641
+ ].join('\n\n');
606
642
  }
607
643
 
608
- // Guard: if the LLM returned the full JSON envelope as its response text and JSON
609
- // parsing failed upstream, finalReport.content may be the raw JSON string rather
610
- // than the extracted markdown. Unwrap it here so docs never show the raw JSON.
611
- try {
612
- const parsed: unknown = JSON.parse(result);
613
- if (parsed && typeof parsed === 'object' && typeof (parsed as Record<string, unknown>)['content'] === 'string') {
614
- result = (parsed as Record<string, unknown>)['content'] as string;
615
- }
616
- } catch { /* result is not JSON — use as-is */ }
617
-
618
644
  // Final post-processing: Ensure CITED LINKS section is accurate and consistent
619
645
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
620
646
  result = synthesisService.ensureCitedLinks(researchId, result);
@@ -17,8 +17,8 @@ import { recordLlmUsage } from '../utils/llm-usage.ts';
17
17
  import { logger } from '../logger.ts';
18
18
  import { getConfig, type Config } from '../config.ts';
19
19
  import { createResearcherSession } from './researcher.ts';
20
- import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
21
- import { getMaxScrapeBatches, resolveExcludedTools } from '../constants.ts';
20
+ import { ensureAssistantResponse, parseCitations, isAnalysisFreeSynthesis, synthesisProseBody, stripReportPreamble } from '../utils/text-utils.ts';
21
+ import { getMaxScrapeBatches, resolveExcludedTools, QUICK_THIN_REPORT_NOTICE } from '../constants.ts';
22
22
  import { resolveResearchModel, isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
23
23
  import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
24
24
  import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
@@ -354,6 +354,44 @@ export class QuickResearchOrchestrator {
354
354
  // digest out of the delivered document.
355
355
  result = synthesisService.getReport(this.options.researchId, 'quick') ?? result;
356
356
 
357
+ // Drop a leading line that announces the report instead of being part of it
358
+ // ("I have gathered sufficient material… I will now synthesize the full
359
+ // report."). The prompt forbids it and the instruction is not reliably obeyed;
360
+ // quick mode has nothing behind it to absorb the line, so it lands as the
361
+ // reader's first sentence. Removed BEFORE the analysis check below, so that
362
+ // check measures the report rather than the narration about it.
363
+ const { body: withoutPreamble, preamble } = stripReportPreamble(result);
364
+ if (preamble) {
365
+ logger.debug(`[QuickOrchestrator] Dropped a ${preamble.length}-char narration preamble from the report: ${preamble.slice(0, 120)}`);
366
+ metrics.increment('research_report_preamble_stripped_total', 1, { mode: 'quick' });
367
+ result = withoutPreamble;
368
+ }
369
+
370
+ // Same disguise as the deep path, and here it is the DELIVERABLE: a report with
371
+ // no analysis in it — a bare heading, a fragment — is about to have the rebuilt
372
+ // source list appended below, which pads it to kilobytes and makes a failed run
373
+ // read as a complete one. Quick mode has no second layer of researcher reports to
374
+ // fall back on, so the honest response is to say so in the report itself rather
375
+ // than hand back a heading over a link dump and call it a success.
376
+ //
377
+ // The VOLUME signal is deliberately disabled here (0). The 800-character floor was
378
+ // calibrated on deep reports; quick mode answers a single question and a correct
379
+ // reply can legitimately be one sentence, and there is no measured distribution of
380
+ // quick-report lengths to justify any floor. Since the only available response is
381
+ // a warning, a false positive would tell a user their correct answer is untrustworthy
382
+ // — strictly worse than missing a thin one. The structural signal is length-independent
383
+ // and carries no such risk: "Paris is the capital of France." ends a sentence and
384
+ // passes, a bare title does not and is flagged.
385
+ //
386
+ // Added to the body BEFORE ensureCitedLinks so the links land beneath it — the
387
+ // same ordering constraint the weak-grounding notice observes, because
388
+ // parseCitations absorbs any text trailing the last entry into its description.
389
+ if (isAnalysisFreeSynthesis(result, 0)) {
390
+ logger.warn(`[QuickOrchestrator] Report contains no complete sentence (${synthesisProseBody(result).length} char(s) of prose) — flagging it in the report`);
391
+ metrics.increment('research_synthesis_analysis_free_total', 1, { mode: 'quick' });
392
+ result = `${result.trim()}\n\n${QUICK_THIN_REPORT_NOTICE}`;
393
+ }
394
+
357
395
  // Ensure CITED LINKS section is accurate and consistent
358
396
  result = synthesisService.ensureCitedLinks(this.options.researchId, result);
359
397
  // Snapshot BEFORE steering guidance is appended: parseCitations absorbs
@@ -277,7 +277,12 @@ export class ResearchSynthesisService implements IService {
277
277
  if (reportCount === 0) {
278
278
  synthesis += 'No researcher reports were generated before the process stopped.';
279
279
  } else {
280
- synthesis += `This is an automated synthesis of ${reportCount} individual researcher report(s) gathered before the process was interrupted.\n\n`;
280
+ // Neutral about WHY the synthesis step produced nothing usable, because there are
281
+ // now two callers: a run interrupted mid-flight, and one whose synthesis LLM returned
282
+ // a report with no analysis in it. Asserting an interruption would be inaccurate for
283
+ // the second, and telling a user the run was interrupted when it was not is exactly
284
+ // the kind of misleading output this fallback exists to avoid.
285
+ synthesis += `This is an automated compilation of ${reportCount} individual researcher report(s). The synthesis step did not produce a usable summary for this run, so the findings are presented as collected.\n\n`;
281
286
  synthesis += Array.from(reports.entries())
282
287
  .map(([id, report]) => `Researcher ${id}\n\n${report}`)
283
288
  .join('\n\n');
@@ -62,6 +62,8 @@ Write your report immediately after scraping is complete, or as soon as a tool r
62
62
  ## Report Format
63
63
 
64
64
  {{digest_section}}
65
+ Emit the report and nothing else. No preamble, and no narration of what you are about to do — not "I have gathered extensive material…", not "Let me now synthesize my findings…". Begin at the report's own first line.
66
+
65
67
  Use [N] inline citations throughout. Write in plain prose — no markdown headings, no bullet points, no bold or italic text. Use clear paragraphs separated by blank lines. The full CITED LINKS list goes at the very end.
66
68
 
67
69
  [Topic Title]
@@ -280,6 +280,105 @@ export function lastCitedLinksHeader(text: string): { lineStart: number; textSta
280
280
  return { lineStart, textStart };
281
281
  }
282
282
 
283
+ /**
284
+ * A first-person opener that announces the report instead of being part of it:
285
+ * "I have gathered extensive material… Let me now synthesize my findings into a
286
+ * comprehensive report."
287
+ *
288
+ * Deliberately narrow. It has to match a line that is BOTH first-person AND about the
289
+ * act of reporting, because the only available response is to delete the line, and the
290
+ * cost of deleting a real one is losing content nothing else can recover.
291
+ */
292
+ const REPORT_NARRATION_OPENER = /^(?:I|I'll|I'm|Let me|Now I|Next I|First,? I)\b/i;
293
+ const REPORT_NARRATION_SUBJECT =
294
+ /\b(?:report|synthesi[sz]\w*|findings|material|sources|scraped|gathered|research\w*|summar\w+)\b/i;
295
+
296
+ /**
297
+ * Remove a leading line that narrates the report rather than being part of it.
298
+ *
299
+ * The researcher prompt forbids this, and the instruction is not reliably obeyed: across
300
+ * live quick runs the delivered document repeatedly opened with "I have gathered
301
+ * sufficient material… I will now synthesize the full report." Quick mode has no
302
+ * synthesis step behind it to absorb that, so it reaches the reader as the report's
303
+ * first line.
304
+ *
305
+ * Every condition here narrows a DESTRUCTIVE action, so each one is required rather than
306
+ * scored: the segment must be a single line, must be separated from the report by a
307
+ * blank line, must read as first-person narration ABOUT reporting, and must leave
308
+ * something behind. A report that opens correctly — with a topic title, as the format
309
+ * requires — matches none of them.
310
+ *
311
+ * @returns the body with the preamble removed, and the preamble itself (empty when
312
+ * nothing was stripped) so callers can log and count what they dropped.
313
+ */
314
+ export function stripReportPreamble(text: string): { body: string; preamble: string } {
315
+ const trimmed = text.trimStart();
316
+ const breakAt = trimmed.indexOf('\n\n');
317
+ if (breakAt < 0) return { body: text, preamble: '' };
318
+
319
+ const head = trimmed.slice(0, breakAt).trim();
320
+ const rest = trimmed.slice(breakAt + 2).trimStart();
321
+ if (head.includes('\n')) return { body: text, preamble: '' };
322
+ // Something has to be left. There is deliberately no LENGTH floor beyond that: a
323
+ // short remainder is the answer ("Paris is the capital of France."), and keeping the
324
+ // narration to pad it out would both deliver the wrong text and let that narration's
325
+ // full stop satisfy the analysis check that runs on the result.
326
+ if (!rest) return { body: text, preamble: '' };
327
+ if (!REPORT_NARRATION_OPENER.test(head)) return { body: text, preamble: '' };
328
+ if (!REPORT_NARRATION_SUBJECT.test(head)) return { body: text, preamble: '' };
329
+
330
+ return { body: rest, preamble: head };
331
+ }
332
+
333
+ /**
334
+ * The prose body of a synthesis: everything before its CITED LINKS section, trimmed.
335
+ * Returns the whole text when there is no such section.
336
+ *
337
+ * Exists to measure how much the model ACTUALLY wrote. Length alone cannot do that,
338
+ * because the engine rebuilds and appends the citation list itself — a report whose
339
+ * body is one title line still comes out kilobytes long once that block lands, which
340
+ * is precisely how a synthesis containing no analysis reached users looking complete.
341
+ */
342
+ export function synthesisProseBody(text: string): string {
343
+ const { lineStart } = lastCitedLinksHeader(text);
344
+ return (lineStart < 0 ? text : text.slice(0, lineStart)).trim();
345
+ }
346
+
347
+ /**
348
+ * Whether a synthesis carries no actual analysis — a heading with nothing under it,
349
+ * a stray sentence fragment, or an empty string.
350
+ *
351
+ * Two INDEPENDENT signals, either of which is enough, so the check does not rest on a
352
+ * single tuned number:
353
+ *
354
+ * - Volume. Less prose than a real report could possibly contain. Pass 0 to DISABLE
355
+ * this signal, which callers must do wherever no length data justifies a floor:
356
+ * a wrongly-flagged report is worse than a missed one when the only available
357
+ * response is to warn the reader about output that is actually fine.
358
+ * - Structure. No sentence ever ends. Every observed failure was a bare title line,
359
+ * which has no terminator at all; any genuine report has many. This one needs no
360
+ * threshold and would catch a title of any length.
361
+ *
362
+ * Measured on the prose only — the text before CITED LINKS — because the engine
363
+ * rebuilds and appends the source list itself, which pads a one-line failure out to
364
+ * kilobytes and is exactly how such a report reached users looking complete.
365
+ */
366
+ export function isAnalysisFreeSynthesis(text: string, minProseChars: number): boolean {
367
+ const prose = synthesisProseBody(text);
368
+ // Drop markdown heading markers and blockquote/bold decoration before judging, so a
369
+ // model that writes "## Title" is measured the same as one that writes "Title".
370
+ const stripped = prose.replace(/^[ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)/gm, '').trim();
371
+ if (minProseChars > 0 && stripped.length < minProseChars) return true;
372
+ // A report with no sentence-ending punctuation anywhere is a heading, a fragment or a
373
+ // list of bare labels — never analysis. The terminator set must not assume Latin
374
+ // punctuation: a report written in a script that ends sentences with a danda, an
375
+ // Arabic full stop or a CJK full stop is a correct report, and this signal is the only
376
+ // one quick mode runs, where a false positive tells the reader a good answer is
377
+ // untrustworthy.
378
+ if (!/[.!?。!?।॥۔؟።]/.test(stripped)) return true;
379
+ return false;
380
+ }
381
+
283
382
  /**
284
383
  * Parses the CITED LINKS section from a researcher report, extracting URLs, sources, and their descriptions.
285
384
  * Handles both single-line 'URL - description' and multi-line 'Source:'/'Description:' formats.