@lincoln504/pi-research 1.6.0 → 1.6.2
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 +7 -5
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +120 -26
- package/dist/prompts/researcher.md +2 -0
- package/dist/thread-worker.mjs +12 -5
- package/docs/CHANGELOG.md +48 -1
- package/package.json +1 -1
- package/src/cli.ts +40 -12
- package/src/constants.ts +30 -0
- package/src/infrastructure/browser/browser-error-utils.ts +21 -0
- package/src/infrastructure/browser/config.ts +43 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +16 -6
- package/src/infrastructure/browser/thread-worker.mjs +12 -5
- package/src/orchestration/deep-research-orchestrator.ts +37 -11
- package/src/orchestration/quick-research-orchestrator.ts +40 -2
- package/src/orchestration/research-synthesis-service.ts +6 -1
- package/src/prompts/researcher.md +2 -0
- package/src/skill-install/skill-installer.ts +44 -7
- package/src/utils/text-utils.ts +99 -0
- package/src/web-research/browser-search.ts +19 -1
package/README.md
CHANGED
|
@@ -44,11 +44,13 @@ npm install -g @lincoln504/pi-research
|
|
|
44
44
|
pi-research skill install
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
On npm 12 or newer, add `--allow-scripts` to either install above. npm 12 stopped running dependency install scripts by default, and the stealth browser's native dependencies are built by one — without it searches fail with a missing-module error rather than a warning at install time.
|
|
48
|
+
|
|
49
|
+
In pi it works out of the box on the session's model and pi's configuration. Standalone use ([agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md)) needs a model configured. See [Configuration](docs/CONFIGURATION.md). The first install downloads the stealth browser engine, which takes a few minutes.
|
|
48
50
|
|
|
49
51
|
### How it works
|
|
50
52
|
|
|
51
|
-
A research run loops through agent teams: a coordinator plans and
|
|
53
|
+
A research run loops through agent teams: a coordinator plans and starts with a search, researcher agents scrape and read in parallel, and a research lead decides whether to go another round — then writes the report from every report collected. The result is one cited Markdown report with findings optionally saved to the knowledge store.
|
|
52
54
|
|
|
53
55
|
Three depth levels (normal, deep, ultra) set the team size and number of rounds. A natural-language request is enough — the tool picks the right one:
|
|
54
56
|
|
|
@@ -64,10 +66,10 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
|
|
|
64
66
|
### Why pi-research
|
|
65
67
|
|
|
66
68
|
- **No quota and no monthly fee.** Most AI search providers cap free searches and then charge, with results served from their index on their servers. pi-research searches and scrapes locally through DuckDuckGo in a stealth browser.
|
|
67
|
-
- **The index
|
|
69
|
+
- **The index is local.** Every finding can be saved to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
|
|
68
70
|
- **Cited reports rather than raw dumps.** Each run returns one sourced Markdown report instead of raw web content pasted into the conversation. Context efficient and easy to verify.
|
|
69
71
|
- **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything — the right shape for an agent whose whole job is reading untrusted web pages. Prompt injection picked up mid-run has nothing to act on.
|
|
70
|
-
- **Search a little or a lot.** Depth levels
|
|
72
|
+
- **Search a little or a lot.** Depth levels range from a quick pass to a large-scale investigation.
|
|
71
73
|
|
|
72
74
|
### Requirements / limitations
|
|
73
75
|
|
|
@@ -75,7 +77,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
|
|
|
75
77
|
- An LLM with a 100k+ context window (an API key or a local model)
|
|
76
78
|
- Internet access on a residential IP, since search, scraping, and YouTube transcripts get bot-blocked from datacenter/VPS/cloud IPs
|
|
77
79
|
- pi. The pi extension uses the host's copy while the standalone CLI and agent skill install it as a dependency.
|
|
78
|
-
- Cloudflare and similar systems block scraping on some sites. A run
|
|
80
|
+
- Cloudflare Turnstile and similar systems block scraping on some sites. A run compensates with a wide pool of search results to scrape.
|
|
79
81
|
|
|
80
82
|
### Channels
|
|
81
83
|
|
|
@@ -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.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.6.2", "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",
|
|
@@ -10254,9 +10283,17 @@ function detectHarnesses(opts) {
|
|
|
10254
10283
|
}
|
|
10255
10284
|
} catch {
|
|
10256
10285
|
}
|
|
10257
|
-
|
|
10286
|
+
const broken = (installed === "owned-symlink" || installed === "owned-copy") && !isUsableSkillDir(absSkillPath);
|
|
10287
|
+
return { ...h, present, absSkillsDir, absSkillPath, installed, broken };
|
|
10258
10288
|
});
|
|
10259
10289
|
}
|
|
10290
|
+
function isUsableSkillDir(p) {
|
|
10291
|
+
try {
|
|
10292
|
+
return fs16.existsSync(path18.join(p, "SKILL.md")) && fs16.existsSync(path18.join(p, "scripts", "run.mjs"));
|
|
10293
|
+
} catch {
|
|
10294
|
+
return false;
|
|
10295
|
+
}
|
|
10296
|
+
}
|
|
10260
10297
|
function isSymlinkPresent(p) {
|
|
10261
10298
|
try {
|
|
10262
10299
|
return fs16.lstatSync(p).isSymbolicLink();
|
|
@@ -10426,8 +10463,8 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
10426
10463
|
const keep = [];
|
|
10427
10464
|
for (const e of manifest.entries) {
|
|
10428
10465
|
if (e.type !== "symlink") {
|
|
10429
|
-
const
|
|
10430
|
-
if (
|
|
10466
|
+
const stale = currentVersion !== null && e.version !== currentVersion;
|
|
10467
|
+
if (stale && source !== null && fs16.existsSync(e.path) && isOwnedCopy(e.path)) {
|
|
10431
10468
|
const dir2 = path18.dirname(e.path);
|
|
10432
10469
|
const base2 = path18.basename(e.path);
|
|
10433
10470
|
try {
|
|
@@ -10504,14 +10541,8 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
10504
10541
|
keep.push(e);
|
|
10505
10542
|
continue;
|
|
10506
10543
|
}
|
|
10507
|
-
const targetLive = fs16.existsSync(e.path);
|
|
10508
|
-
|
|
10509
|
-
try {
|
|
10510
|
-
dest = path18.resolve(path18.dirname(e.path), fs16.readlinkSync(e.path));
|
|
10511
|
-
} catch {
|
|
10512
|
-
}
|
|
10513
|
-
const stale = source !== null && dest !== null && dest !== path18.resolve(source);
|
|
10514
|
-
if (!targetLive || stale) {
|
|
10544
|
+
const targetLive = fs16.existsSync(e.path) && isUsableSkillDir(e.path);
|
|
10545
|
+
if (!targetLive) {
|
|
10515
10546
|
if (source !== null) {
|
|
10516
10547
|
try {
|
|
10517
10548
|
fs16.unlinkSync(e.path);
|
|
@@ -12289,6 +12320,7 @@ import * as crypto4 from "node:crypto";
|
|
|
12289
12320
|
import { join as join8 } from "node:path";
|
|
12290
12321
|
import { existsSync as existsSync6, mkdirSync as mkdirSync3 } from "node:fs";
|
|
12291
12322
|
import { platform, homedir as homedir3 } from "node:os";
|
|
12323
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
12292
12324
|
function getWindowsCamoufoxDir() {
|
|
12293
12325
|
return join8(homedir3(), "AppData", "Local", "camoufox", "camoufox", "Cache");
|
|
12294
12326
|
}
|
|
@@ -12397,6 +12429,20 @@ function resolveHeadlessMode() {
|
|
|
12397
12429
|
if (process.env["PI_RESEARCH_USE_XVFB"] === "true") return "virtual";
|
|
12398
12430
|
return true;
|
|
12399
12431
|
}
|
|
12432
|
+
function probeBrowserNativeDeps(load = defaultNativeDepLoad) {
|
|
12433
|
+
try {
|
|
12434
|
+
load();
|
|
12435
|
+
return { ok: true };
|
|
12436
|
+
} catch (err) {
|
|
12437
|
+
return { ok: false, error: err instanceof Error ? err.message.split("\n")[0].trim() : String(err) };
|
|
12438
|
+
}
|
|
12439
|
+
}
|
|
12440
|
+
function defaultNativeDepLoad() {
|
|
12441
|
+
const require2 = createRequire3(import.meta.url);
|
|
12442
|
+
const Database = require2("better-sqlite3");
|
|
12443
|
+
const db = new Database(":memory:");
|
|
12444
|
+
db.close();
|
|
12445
|
+
}
|
|
12400
12446
|
function isBrowserAvailable() {
|
|
12401
12447
|
if (isFullMockMode()) return false;
|
|
12402
12448
|
try {
|
|
@@ -12489,6 +12535,11 @@ function isPoolShutdownError(error) {
|
|
|
12489
12535
|
// same transient swap the "being reset" string covers, not a hard fault.
|
|
12490
12536
|
err.message.includes("Cannot execute a task on not started pool"));
|
|
12491
12537
|
}
|
|
12538
|
+
var NATIVE_BINDING_FAILURE = /Could not locate the bindings file|ERR_DLOPEN_FAILED|NODE_MODULE_VERSION|was compiled against a different Node\.js version|invalid ELF header|Cannot find module '[^']*\.node'/;
|
|
12539
|
+
function isNativeBindingError(error) {
|
|
12540
|
+
const msg2 = error instanceof Error ? error.message : String(error);
|
|
12541
|
+
return NATIVE_BINDING_FAILURE.test(msg2);
|
|
12542
|
+
}
|
|
12492
12543
|
var BENIGN_NAVIGATION_FAILURE = /page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
|
|
12493
12544
|
function isBenignScrapeFailure(error) {
|
|
12494
12545
|
const msg2 = error instanceof Error ? error.message : String(error);
|
|
@@ -15720,7 +15771,7 @@ import { FixedClusterPool, WorkerChoiceStrategies } from "poolifier";
|
|
|
15720
15771
|
// src/infrastructure/browser/ensure-browser.ts
|
|
15721
15772
|
init_logger();
|
|
15722
15773
|
import { spawn as spawn2 } from "node:child_process";
|
|
15723
|
-
import { createRequire as
|
|
15774
|
+
import { createRequire as createRequire4 } from "node:module";
|
|
15724
15775
|
import { createHash as createHash2 } from "node:crypto";
|
|
15725
15776
|
import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync3, fstatSync, openSync as openSync4, closeSync as closeSync4, rmSync } from "node:fs";
|
|
15726
15777
|
import { join as join11, dirname as dirname8 } from "node:path";
|
|
@@ -15747,7 +15798,7 @@ function isBrowserBinaryPresent() {
|
|
|
15747
15798
|
}
|
|
15748
15799
|
function resolveCamoufoxBin() {
|
|
15749
15800
|
try {
|
|
15750
|
-
const require2 =
|
|
15801
|
+
const require2 = createRequire4(import.meta.url);
|
|
15751
15802
|
const pkgJson = require2.resolve("camoufox-js/package.json");
|
|
15752
15803
|
const bin = join11(dirname8(pkgJson), "..", ".bin", process.platform === "win32" ? "camoufox-js.cmd" : "camoufox-js");
|
|
15753
15804
|
if (existsSync7(bin)) return bin;
|
|
@@ -17503,6 +17554,12 @@ async function performSearch(queries, config, signal, onProgress, container = ge
|
|
|
17503
17554
|
} else if (timeoutCount + errorCount === filteredQueries.length) {
|
|
17504
17555
|
reason = `${timeoutCount} queries timed out and ${errorCount} queries failed`;
|
|
17505
17556
|
}
|
|
17557
|
+
if (isNativeBindingError(sampleWorkerError)) {
|
|
17558
|
+
metrics.increment("browser_search_total_failures_total", 1, { cause: "native_binding" });
|
|
17559
|
+
throw new Error(
|
|
17560
|
+
`Search completely failed: ${reason}, all with a missing native module. This is an incomplete install, not a network problem: the browser engine's native dependencies were never built. npm 12 does not run dependency install scripts by default \u2014 reinstall allowing them (npm install @lincoln504/pi-research --allow-scripts, or npm approve-scripts --allow-scripts-pending), then retry.` + (sampleWorkerError ? ` Last worker error: ${sampleWorkerError}` : "")
|
|
17561
|
+
);
|
|
17562
|
+
}
|
|
17506
17563
|
throw new Error(
|
|
17507
17564
|
`Search completely failed: ${reason}. Browser workers may be unavailable, DuckDuckGo is unreachable, or the system is under extreme load.` + (sampleWorkerError ? ` Last worker error: ${sampleWorkerError}` : "")
|
|
17508
17565
|
);
|
|
@@ -23866,6 +23923,19 @@ ${userMessage}`);
|
|
|
23866
23923
|
const synthesisService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
23867
23924
|
synthesisService.storeReport(this.options.researchId, "quick", result);
|
|
23868
23925
|
result = synthesisService.getReport(this.options.researchId, "quick") ?? result;
|
|
23926
|
+
const { body: withoutPreamble, preamble } = stripReportPreamble(result);
|
|
23927
|
+
if (preamble) {
|
|
23928
|
+
logger.debug(`[QuickOrchestrator] Dropped a ${preamble.length}-char narration preamble from the report: ${preamble.slice(0, 120)}`);
|
|
23929
|
+
metrics.increment("research_report_preamble_stripped_total", 1, { mode: "quick" });
|
|
23930
|
+
result = withoutPreamble;
|
|
23931
|
+
}
|
|
23932
|
+
if (isAnalysisFreeSynthesis(result, 0)) {
|
|
23933
|
+
logger.warn(`[QuickOrchestrator] Report contains no complete sentence (${synthesisProseBody(result).length} char(s) of prose) \u2014 flagging it in the report`);
|
|
23934
|
+
metrics.increment("research_synthesis_analysis_free_total", 1, { mode: "quick" });
|
|
23935
|
+
result = `${result.trim()}
|
|
23936
|
+
|
|
23937
|
+
${QUICK_THIN_REPORT_NOTICE}`;
|
|
23938
|
+
}
|
|
23869
23939
|
result = synthesisService.ensureCitedLinks(this.options.researchId, result);
|
|
23870
23940
|
const citationSource = result;
|
|
23871
23941
|
const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
|
|
@@ -23976,6 +24046,7 @@ ${result}`);
|
|
|
23976
24046
|
init_logger();
|
|
23977
24047
|
init_metrics();
|
|
23978
24048
|
init_constants();
|
|
24049
|
+
init_text_utils();
|
|
23979
24050
|
init_service_interfaces();
|
|
23980
24051
|
init_service_registry();
|
|
23981
24052
|
init_config();
|
|
@@ -24291,6 +24362,13 @@ var DeepResearchOrchestrator = class {
|
|
|
24291
24362
|
observer?.onEvaluationDecision?.("synthesize", finalReport, this.currentRound);
|
|
24292
24363
|
}
|
|
24293
24364
|
let result = finalReport.content || "";
|
|
24365
|
+
try {
|
|
24366
|
+
const parsed = JSON.parse(result);
|
|
24367
|
+
if (parsed && typeof parsed === "object" && typeof parsed["content"] === "string") {
|
|
24368
|
+
result = parsed["content"];
|
|
24369
|
+
}
|
|
24370
|
+
} catch {
|
|
24371
|
+
}
|
|
24294
24372
|
if (!result.trim()) {
|
|
24295
24373
|
if (hasReports) {
|
|
24296
24374
|
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
|
|
@@ -24298,13 +24376,15 @@ var DeepResearchOrchestrator = class {
|
|
|
24298
24376
|
} else {
|
|
24299
24377
|
throw new Error("Research produced no report \u2014 no source material was collected.");
|
|
24300
24378
|
}
|
|
24301
|
-
}
|
|
24302
|
-
|
|
24303
|
-
|
|
24304
|
-
|
|
24305
|
-
|
|
24306
|
-
|
|
24307
|
-
|
|
24379
|
+
} else if (hasReports && isAnalysisFreeSynthesis(result, MIN_SYNTHESIS_PROSE_CHARS)) {
|
|
24380
|
+
const proseChars = synthesisProseBody(result).length;
|
|
24381
|
+
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`);
|
|
24382
|
+
metrics.increment("research_synthesis_analysis_free_total", 1, { mode: "deep" });
|
|
24383
|
+
result = [
|
|
24384
|
+
result.trim(),
|
|
24385
|
+
SYNTHESIS_FALLBACK_NOTICE,
|
|
24386
|
+
synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound)
|
|
24387
|
+
].join("\n\n");
|
|
24308
24388
|
}
|
|
24309
24389
|
const synthesisService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
24310
24390
|
result = synthesisService.ensureCitedLinks(researchId, result);
|
|
@@ -25172,7 +25252,7 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
|
25172
25252
|
if (reportCount === 0) {
|
|
25173
25253
|
synthesis += "No researcher reports were generated before the process stopped.";
|
|
25174
25254
|
} else {
|
|
25175
|
-
synthesis += `This is an automated
|
|
25255
|
+
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
25256
|
|
|
25177
25257
|
`;
|
|
25178
25258
|
synthesis += Array.from(reports.entries()).map(([id, report]) => `Researcher ${id}
|
|
@@ -26655,11 +26735,12 @@ async function cmdStatus(json) {
|
|
|
26655
26735
|
const paths = resolvedConfigPaths();
|
|
26656
26736
|
const cfg = getConfig(process.cwd(), "cli");
|
|
26657
26737
|
const browserInstalled = isBrowserAvailable();
|
|
26738
|
+
const nativeDeps = probeBrowserNativeDeps();
|
|
26658
26739
|
const summary = {
|
|
26659
26740
|
package: "@lincoln504/pi-research",
|
|
26660
26741
|
version: PKG_VERSION,
|
|
26661
26742
|
cli: `node ${process.argv[1]}`,
|
|
26662
|
-
ready: !det.problem,
|
|
26743
|
+
ready: !det.problem && nativeDeps.ok,
|
|
26663
26744
|
credentials: {
|
|
26664
26745
|
source: det.source,
|
|
26665
26746
|
apiKeyConfigured: det.apiKeyConfigured,
|
|
@@ -26670,6 +26751,9 @@ async function cmdStatus(json) {
|
|
|
26670
26751
|
},
|
|
26671
26752
|
browser: {
|
|
26672
26753
|
installed: browserInstalled,
|
|
26754
|
+
nativeDepsOk: nativeDeps.ok,
|
|
26755
|
+
nativeDepsError: nativeDeps.ok ? null : nativeDeps.error,
|
|
26756
|
+
nativeDepsFix: nativeDeps.ok ? null : "Reinstall allowing dependency install scripts: npm install -g @lincoln504/pi-research --allow-scripts (npm 12 turns them off by default).",
|
|
26673
26757
|
// Same remedy the health check prints, so both surfaces name one command.
|
|
26674
26758
|
fix: browserInstalled ? null : "npx camoufox-js fetch",
|
|
26675
26759
|
note: browserInstalled ? null : "Stealth browser not installed \u2014 scraping falls back to plain fetch, which some sites block. npm 11.19+ blocks install scripts by default, which skips this package's postinstall."
|
|
@@ -26687,7 +26771,16 @@ async function cmdStatus(json) {
|
|
|
26687
26771
|
toStdout(`ready: ${summary.ready ? "yes" : "no"}
|
|
26688
26772
|
|
|
26689
26773
|
`);
|
|
26690
|
-
toStdout(configBlock(det,
|
|
26774
|
+
toStdout(configBlock(det, det.problem ? "\u2014 fix the problem above, then re-run." : void 0) + "\n");
|
|
26775
|
+
if (!summary.browser.nativeDepsOk) {
|
|
26776
|
+
toStdout(`
|
|
26777
|
+
browser engine: BROKEN \u2014 native dependencies were never built, so no search can run.
|
|
26778
|
+
`);
|
|
26779
|
+
toStdout(` ${summary.browser.nativeDepsFix}
|
|
26780
|
+
`);
|
|
26781
|
+
toStdout(` Underlying error: ${summary.browser.nativeDepsError}
|
|
26782
|
+
`);
|
|
26783
|
+
}
|
|
26691
26784
|
if (!summary.browser.installed) {
|
|
26692
26785
|
toStdout(`
|
|
26693
26786
|
stealth browser: NOT INSTALLED \u2014 run: ${summary.browser.fix}
|
|
@@ -26760,7 +26853,7 @@ async function cmdSkill(s) {
|
|
|
26760
26853
|
toStdout(pretty({
|
|
26761
26854
|
command: "skill",
|
|
26762
26855
|
action: "status",
|
|
26763
|
-
agents: agents.map((d) => ({ id: d.id, label: d.label, present: d.present, installed: d.installed, path: d.absSkillPath }))
|
|
26856
|
+
agents: agents.map((d) => ({ id: d.id, label: d.label, present: d.present, installed: d.installed, broken: d.broken, path: d.absSkillPath }))
|
|
26764
26857
|
}));
|
|
26765
26858
|
return EXIT.OK;
|
|
26766
26859
|
}
|
|
@@ -26768,7 +26861,8 @@ async function cmdSkill(s) {
|
|
|
26768
26861
|
|
|
26769
26862
|
`);
|
|
26770
26863
|
for (const d of agents) {
|
|
26771
|
-
const
|
|
26864
|
+
const kind = d.installed === "owned-copy" ? "copy" : "symlink";
|
|
26865
|
+
const state = !d.present ? "agent not detected" : d.installed === "none" ? "not installed" : d.installed === "foreign" ? "a different skill occupies this slot \u2014 left untouched" : d.broken ? `BROKEN ${kind} \u2014 target is missing or incomplete; re-run \`${BINARY_NAME} skill install\`` : `installed (${kind})`;
|
|
26772
26866
|
const loc = d.installed === "owned-symlink" || d.installed === "owned-copy" ? ` \u2192 ${d.absSkillPath}` : "";
|
|
26773
26867
|
toStdout(` ${d.label.padEnd(18)} ${state}${loc}
|
|
26774
26868
|
`);
|
|
@@ -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]
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -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
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
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,54 @@ 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.2] - 2026-08-24
|
|
9
|
+
|
|
10
|
+
An agent-skill run reported that every search had failed and concluded it had hit an
|
|
11
|
+
unfixable environment problem. It was half right: the install really was broken, but
|
|
12
|
+
nothing in the tool would say so, and one of the two causes was the tool's own doing.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **A second install captured the skill links of the first.** `reconcileSkillInstalls()` runs on every engine-touching CLI invocation and resolves its source from whichever copy is being invoked. It re-pointed any owned link whose destination differed — including a perfectly healthy one — so an ordinary second install captured the user's global Claude/Codex/OpenClaw skill links merely by being run once. That is a documented setup, not an exotic one: `docs/SDK.md` instructs users to `npm install @lincoln504/pi-research` INTO their project so the SDK imports resolve, and anything that then ran the project-local CLI moved the global skill into the project. Moving, cleaning or deleting the project later killed the skill with an opaque `Cannot find module …/scripts/run.mjs`. Reconcile now repairs only a link that is dangling or whose target has been gutted — the case it was written for, which is what an update that relocates the package actually produces. Choosing between two live installs is what the explicit `skill install` is for. Two existing tests asserted the old behaviour and were unfaithful in the same way: each described "package relocated by an update" while leaving the old directory on disk, so the link never dangled (`src/skill-install/skill-installer.ts`).
|
|
17
|
+
- **`skill status` called a dead skill installed.** Detection tested that the link existed, never that it resolved, so a dangling symlink reported as `installed (symlink)` — leaving the harness's later "Cannot find module" as the only signal anything was wrong. A link or copy whose target is missing, or which lacks `SKILL.md` / `scripts/run.mjs`, is now reported as BROKEN with the command that repairs it (`src/skill-install/skill-installer.ts`, `src/cli.ts`).
|
|
18
|
+
- **`status` reported a build that cannot search as ready.** npm 12 turns dependency install scripts off by default. `camoufox-js` needs `better-sqlite3` to launch a browser at all, and its binding is produced by exactly such a script — so the module imports fine and throws only at first use. `ready` was derived from credentials alone and said yes; `isBrowserAvailable()` only stats the camoufox binary and never sees it. Measured across every native dependency this package ships: better-sqlite3 is the ONLY one that fails that way — onnxruntime-node carries its binding inside its own tarball, and lancedb, impit and html-to-markdown resolve prebuilt platform packages, which is why the knowledge store kept answering while search was dead. `status` now opens an in-memory database to settle it, gates `ready` on the result, and prints the remedy. The old comment claiming "nothing else fails" was wrong and is corrected (`src/infrastructure/browser/config.ts`, `src/cli.ts`).
|
|
19
|
+
- **A broken install was reported as a network outage.** With no binding, every browser worker dies identically and the run said browser workers may be unavailable, DuckDuckGo unreachable, or the system under extreme load. All three were false and none is actionable — a calling agent read it and gave up rather than reinstalling. A total search failure whose workers all died on a missing native module now says it is an incomplete install and names the flag that fixes it (`src/infrastructure/browser/browser-error-utils.ts`, `src/web-research/browser-search.ts`).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **README documents the npm 12 install flag** on the install commands themselves, and drops a claim that a run flags unreachable pages. Turnstile is named because it is the challenge readers actually hit, and a handful of inflated verbs are now plain ones.
|
|
24
|
+
|
|
25
|
+
### Verified
|
|
26
|
+
|
|
27
|
+
- 2938 unit tests over 233 files, lint, type-check over source and tests, dependency-cruiser.
|
|
28
|
+
- The failure mode was reproduced and the remedy measured: a scripts-blocked install of 1.6.1 fails `better-sqlite3` at first use and passes every other native dependency. A live agent-skill run through the installed link completes clean with zero binding errors.
|
|
29
|
+
|
|
30
|
+
## [1.6.1] - 2026-08-23
|
|
31
|
+
|
|
32
|
+
A report that contained no analysis was being delivered as a completed run, because
|
|
33
|
+
the engine's own repair step padded it out. Fixing that exposed a second way into the
|
|
34
|
+
same failure, and a review of the machinery around it turned up a third defect.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **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`).
|
|
39
|
+
- **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`).
|
|
40
|
+
- **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`).
|
|
41
|
+
- **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`).
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **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`).
|
|
46
|
+
|
|
47
|
+
### Removed
|
|
48
|
+
|
|
49
|
+
- **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`).
|
|
50
|
+
|
|
51
|
+
### Verified
|
|
52
|
+
|
|
53
|
+
- 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).
|
|
54
|
+
- 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.
|
|
55
|
+
|
|
8
56
|
## [1.6.0] - 2026-08-22
|
|
9
57
|
|
|
10
58
|
Driven by log forensics rather than by the test suite: every entry below was found
|
|
@@ -32,7 +80,6 @@ and recovered conditions reported as terminal failures.
|
|
|
32
80
|
|
|
33
81
|
- **`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
82
|
- **`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
83
|
|
|
37
84
|
### Added
|
|
38
85
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFil
|
|
|
45
45
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
46
46
|
import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
|
|
47
47
|
import { RESEARCH_TOOL_NAMES } from './constants.ts';
|
|
48
|
-
import { isBrowserAvailable } from './infrastructure/browser/config.ts';
|
|
48
|
+
import { isBrowserAvailable, probeBrowserNativeDeps } from './infrastructure/browser/config.ts';
|
|
49
49
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
// Constants
|
|
@@ -796,19 +796,25 @@ async function cmdStatus(json?: boolean): Promise<number> {
|
|
|
796
796
|
// (scripts/setup.cjs), which npm 11.19+ BLOCKS BY DEFAULT — it now warns
|
|
797
797
|
// "packages have install scripts not yet covered by allowScripts" and carries
|
|
798
798
|
// on, so the browser is simply absent on an otherwise successful install.
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
799
|
+
// A missing browser DEGRADES capability rather than breaking the tool — the fetch
|
|
800
|
+
// path still serves most pages — so it is reported alongside `ready` rather than
|
|
801
|
+
// folded into it. isBrowserAvailable() is a resolve + existsSync; it never launches
|
|
802
|
+
// anything.
|
|
803
|
+
//
|
|
804
|
+
// The browser's NATIVE DEPENDENCIES are a separate and harsher question, and were
|
|
805
|
+
// previously assumed away here with "nothing else fails". Measured: better-sqlite3,
|
|
806
|
+
// which camoufox-js needs to launch at all, is built by a dependency install script
|
|
807
|
+
// and does NOT survive a scripts-blocked install. When it is missing every browser
|
|
808
|
+
// worker dies and SEARCH IS DEAD — not degraded — while the knowledge store keeps
|
|
809
|
+
// working because onnxruntime and lancedb both ship their binaries. That is not a
|
|
810
|
+
// capability note, so it gates `ready`: a build that cannot search cannot research.
|
|
806
811
|
const browserInstalled = isBrowserAvailable();
|
|
812
|
+
const nativeDeps = probeBrowserNativeDeps();
|
|
807
813
|
const summary = {
|
|
808
814
|
package: '@lincoln504/pi-research',
|
|
809
815
|
version: PKG_VERSION,
|
|
810
816
|
cli: `node ${process.argv[1]}`,
|
|
811
|
-
ready: !det.problem,
|
|
817
|
+
ready: !det.problem && nativeDeps.ok,
|
|
812
818
|
credentials: {
|
|
813
819
|
source: det.source,
|
|
814
820
|
apiKeyConfigured: det.apiKeyConfigured,
|
|
@@ -819,6 +825,11 @@ async function cmdStatus(json?: boolean): Promise<number> {
|
|
|
819
825
|
},
|
|
820
826
|
browser: {
|
|
821
827
|
installed: browserInstalled,
|
|
828
|
+
nativeDepsOk: nativeDeps.ok,
|
|
829
|
+
nativeDepsError: nativeDeps.ok ? null : nativeDeps.error,
|
|
830
|
+
nativeDepsFix: nativeDeps.ok
|
|
831
|
+
? null
|
|
832
|
+
: 'Reinstall allowing dependency install scripts: npm install -g @lincoln504/pi-research --allow-scripts (npm 12 turns them off by default).',
|
|
822
833
|
// Same remedy the health check prints, so both surfaces name one command.
|
|
823
834
|
fix: browserInstalled ? null : 'npx camoufox-js fetch',
|
|
824
835
|
note: browserInstalled
|
|
@@ -837,7 +848,19 @@ async function cmdStatus(json?: boolean): Promise<number> {
|
|
|
837
848
|
|
|
838
849
|
toStdout(`pi-research ${PKG_VERSION}\n`);
|
|
839
850
|
toStdout(`ready: ${summary.ready ? 'yes' : 'no'}\n\n`);
|
|
840
|
-
|
|
851
|
+
// The hint points at the CONFIG block, so it belongs to a credential problem only.
|
|
852
|
+
// `ready` can now also be false because the browser's native deps are missing, and
|
|
853
|
+
// that is reported below with its own remedy — sending the reader back to a config
|
|
854
|
+
// block that has nothing wrong with it would be its own small lie.
|
|
855
|
+
toStdout(configBlock(det, det.problem ? '— fix the problem above, then re-run.' : undefined) + '\n');
|
|
856
|
+
if (!summary.browser.nativeDepsOk) {
|
|
857
|
+
// Printed BEFORE the browser-download note: a missing binary is a degradation the
|
|
858
|
+
// fetch path covers, while a missing binding means no search runs at all. The more
|
|
859
|
+
// severe problem has to be the one the reader sees first.
|
|
860
|
+
toStdout(`\nbrowser engine: BROKEN — native dependencies were never built, so no search can run.\n`);
|
|
861
|
+
toStdout(` ${summary.browser.nativeDepsFix}\n`);
|
|
862
|
+
toStdout(` Underlying error: ${summary.browser.nativeDepsError}\n`);
|
|
863
|
+
}
|
|
841
864
|
if (!summary.browser.installed) {
|
|
842
865
|
toStdout(`\nstealth browser: NOT INSTALLED — run: ${summary.browser.fix}\n`);
|
|
843
866
|
toStdout(` ${summary.browser.note}\n`);
|
|
@@ -940,16 +963,21 @@ async function cmdSkill(s: NonNullable<ParsedArgs['skill']>): Promise<number> {
|
|
|
940
963
|
if (s.json) {
|
|
941
964
|
toStdout(pretty({
|
|
942
965
|
command: 'skill', action: 'status',
|
|
943
|
-
agents: agents.map((d) => ({ id: d.id, label: d.label, present: d.present, installed: d.installed, path: d.absSkillPath })),
|
|
966
|
+
agents: agents.map((d) => ({ id: d.id, label: d.label, present: d.present, installed: d.installed, broken: d.broken, path: d.absSkillPath })),
|
|
944
967
|
}));
|
|
945
968
|
return EXIT.OK;
|
|
946
969
|
}
|
|
947
970
|
toStdout(`pi-research agent skill — install status\n\n`);
|
|
948
971
|
for (const d of agents) {
|
|
972
|
+
// A broken install must not read as a working one. It said "installed (symlink)"
|
|
973
|
+
// for a link whose target had been deleted, so the only sign anything was wrong
|
|
974
|
+
// came later, from the harness, as "Cannot find module …/scripts/run.mjs".
|
|
975
|
+
const kind = d.installed === 'owned-copy' ? 'copy' : 'symlink';
|
|
949
976
|
const state = !d.present ? 'agent not detected'
|
|
950
977
|
: d.installed === 'none' ? 'not installed'
|
|
951
978
|
: d.installed === 'foreign' ? 'a different skill occupies this slot — left untouched'
|
|
952
|
-
: `
|
|
979
|
+
: d.broken ? `BROKEN ${kind} — target is missing or incomplete; re-run \`${BINARY_NAME} skill install\``
|
|
980
|
+
: `installed (${kind})`;
|
|
953
981
|
const loc = d.installed === 'owned-symlink' || d.installed === 'owned-copy' ? ` → ${d.absSkillPath}` : '';
|
|
954
982
|
toStdout(` ${d.label.padEnd(18)} ${state}${loc}\n`);
|
|
955
983
|
}
|
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.
|
|
@@ -124,6 +124,27 @@ export function isPoolShutdownError(error: unknown): boolean {
|
|
|
124
124
|
);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A native addon that is missing or unloadable, i.e. a BROKEN INSTALL rather than
|
|
129
|
+
* anything about the page, the network or the load on the machine.
|
|
130
|
+
*
|
|
131
|
+
* The case that produced this: npm 12 turns `allowScripts` OFF by default, so a
|
|
132
|
+
* plain `npm install` no longer runs dependency lifecycle scripts — including the
|
|
133
|
+
* one that fetches/builds `better-sqlite3`, which `camoufox-js` needs to launch a
|
|
134
|
+
* browser at all. Every browser worker then dies with "Could not locate the
|
|
135
|
+
* bindings file", every query fails, and the run reported that DuckDuckGo might be
|
|
136
|
+
* unreachable or the machine under extreme load. Neither was true, and the advice
|
|
137
|
+
* that follows from each is useless. An install fault has to say so.
|
|
138
|
+
*/
|
|
139
|
+
const NATIVE_BINDING_FAILURE =
|
|
140
|
+
/Could not locate the bindings file|ERR_DLOPEN_FAILED|NODE_MODULE_VERSION|was compiled against a different Node\.js version|invalid ELF header|Cannot find module '[^']*\.node'/;
|
|
141
|
+
|
|
142
|
+
/** True when the failure is a missing/unloadable native addon — a broken install. */
|
|
143
|
+
export function isNativeBindingError(error: unknown): boolean {
|
|
144
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
145
|
+
return NATIVE_BINDING_FAILURE.test(msg);
|
|
146
|
+
}
|
|
147
|
+
|
|
127
148
|
/** Playwright navigation failures whose cause is at the remote end — see below. */
|
|
128
149
|
const BENIGN_NAVIGATION_FAILURE =
|
|
129
150
|
/page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
|
|
@@ -12,6 +12,7 @@ import { platform, homedir } from 'node:os';
|
|
|
12
12
|
import type { Config } from '../../config.ts';
|
|
13
13
|
import { getConfig } from '../../config.ts';
|
|
14
14
|
import { getLogger } from '../../logger.ts';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
15
16
|
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// Binary Cache Management
|
|
@@ -310,6 +311,48 @@ export function resolveHeadlessMode(): boolean | 'virtual' {
|
|
|
310
311
|
*
|
|
311
312
|
* Single point of control: all existing skipTests() checks use this function.
|
|
312
313
|
*/
|
|
314
|
+
/**
|
|
315
|
+
* Whether the browser stack's NATIVE dependencies can actually load.
|
|
316
|
+
*
|
|
317
|
+
* `isBrowserAvailable()` answers a different question — is the camoufox binary on
|
|
318
|
+
* disk — and a browser can be fully downloaded and still unable to launch. camoufox-js
|
|
319
|
+
* requires `better-sqlite3`, whose binding is produced by a dependency INSTALL SCRIPT,
|
|
320
|
+
* and npm 12 turns those off by default. The module then imports fine and throws only
|
|
321
|
+
* when first used, so nothing notices until every browser worker dies mid-run with
|
|
322
|
+
* "Could not locate the bindings file" and the run reports a network problem it does
|
|
323
|
+
* not have.
|
|
324
|
+
*
|
|
325
|
+
* Measured, not assumed: of this package's native dependencies, only better-sqlite3
|
|
326
|
+
* fails a scripts-blocked install. onnxruntime-node ships its binding inside its own
|
|
327
|
+
* tarball, and lancedb, impit and html-to-markdown all resolve prebuilt platform
|
|
328
|
+
* packages, so the knowledge store keeps working while search is dead — exactly the
|
|
329
|
+
* split seen in the field.
|
|
330
|
+
*
|
|
331
|
+
* Opening an in-memory database is the only check that settles it: resolving the
|
|
332
|
+
* module or stat-ing a path does not, because the failure is in the binding lookup at
|
|
333
|
+
* first use. It costs a few milliseconds and is confined to diagnostics.
|
|
334
|
+
*/
|
|
335
|
+
export function probeBrowserNativeDeps(
|
|
336
|
+
/** Injected for tests. Production callers pass nothing. */
|
|
337
|
+
load: () => void = defaultNativeDepLoad,
|
|
338
|
+
): { ok: true } | { ok: false; error: string } {
|
|
339
|
+
try {
|
|
340
|
+
load();
|
|
341
|
+
return { ok: true };
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// First line only: the bindings error enumerates every path it tried, which is
|
|
344
|
+
// pages of noise in a status block. The rest is in the log if it is wanted.
|
|
345
|
+
return { ok: false, error: err instanceof Error ? err.message.split('\n')[0]!.trim() : String(err) };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function defaultNativeDepLoad(): void {
|
|
350
|
+
const require = createRequire(import.meta.url);
|
|
351
|
+
const Database = require('better-sqlite3') as new (p: string) => { close(): void };
|
|
352
|
+
const db = new Database(':memory:');
|
|
353
|
+
db.close();
|
|
354
|
+
}
|
|
355
|
+
|
|
313
356
|
export function isBrowserAvailable(): boolean {
|
|
314
357
|
if (isFullMockMode()) return false;
|
|
315
358
|
try {
|
|
@@ -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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
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
|
-
|
|
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]
|
|
@@ -325,6 +325,14 @@ export interface DetectedHarness extends HarnessDef {
|
|
|
325
325
|
absSkillPath: string;
|
|
326
326
|
/** Current install state of THIS skill at that path. */
|
|
327
327
|
installed: 'none' | 'owned-symlink' | 'owned-copy' | 'foreign';
|
|
328
|
+
/**
|
|
329
|
+
* Ours, but not usable: the symlink dangles, or the directory it resolves to is
|
|
330
|
+
* missing SKILL.md / scripts/run.mjs. Reported because the alternative is what
|
|
331
|
+
* actually happened — `skill status` said "installed (symlink)" for a link whose
|
|
332
|
+
* target had been deleted, so the only signal the user got was the harness failing
|
|
333
|
+
* later with "Cannot find module …/scripts/run.mjs".
|
|
334
|
+
*/
|
|
335
|
+
broken: boolean;
|
|
328
336
|
}
|
|
329
337
|
|
|
330
338
|
export function detectHarnesses(opts?: { home?: string }): DetectedHarness[] {
|
|
@@ -342,10 +350,28 @@ export function detectHarnesses(opts?: { home?: string }): DetectedHarness[] {
|
|
|
342
350
|
else installed = 'foreign';
|
|
343
351
|
}
|
|
344
352
|
} catch { /* none */ }
|
|
345
|
-
|
|
353
|
+
const broken = (installed === 'owned-symlink' || installed === 'owned-copy')
|
|
354
|
+
&& !isUsableSkillDir(absSkillPath);
|
|
355
|
+
return { ...h, present, absSkillsDir, absSkillPath, installed, broken };
|
|
346
356
|
});
|
|
347
357
|
}
|
|
348
358
|
|
|
359
|
+
/**
|
|
360
|
+
* The two files a skill directory is useless without: the manifest a harness reads to
|
|
361
|
+
* discover the skill, and the launcher it executes. A link or copy can exist while the
|
|
362
|
+
* thing it points at does not — the target was deleted, moved, or partially removed —
|
|
363
|
+
* and the harness then fails at invocation time with "Cannot find module …/run.mjs",
|
|
364
|
+
* which says nothing about the install being the problem. Checked rather than assumed
|
|
365
|
+
* so both `skill status` and the startup reconcile can tell a live install from a
|
|
366
|
+
* hollow one.
|
|
367
|
+
*/
|
|
368
|
+
function isUsableSkillDir(p: string): boolean {
|
|
369
|
+
try {
|
|
370
|
+
return fs.existsSync(path.join(p, 'SKILL.md'))
|
|
371
|
+
&& fs.existsSync(path.join(p, 'scripts', 'run.mjs'));
|
|
372
|
+
} catch { return false; }
|
|
373
|
+
}
|
|
374
|
+
|
|
349
375
|
function isSymlinkPresent(p: string): boolean {
|
|
350
376
|
try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; }
|
|
351
377
|
}
|
|
@@ -695,12 +721,23 @@ export function reconcileSkillInstalls(opts: InstallOptions = {}): ReconcileResu
|
|
|
695
721
|
// Only ever touch links we own; a foreign link at this path stays + tracked.
|
|
696
722
|
if (!isOwnedSymlink(e.path)) { keep.push(e); continue; }
|
|
697
723
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
|
|
724
|
+
// A target that resolves but has been gutted (partial delete, interrupted
|
|
725
|
+
// install, pruned tree) is as dead as one that is gone — and it fails later,
|
|
726
|
+
// at invocation, with an error that never mentions the install. Treat both as
|
|
727
|
+
// needing repair.
|
|
728
|
+
const targetLive = fs.existsSync(e.path) && isUsableSkillDir(e.path); // existsSync follows the link
|
|
729
|
+
|
|
730
|
+
// A link pointing at a DIFFERENT but perfectly live pi-research install is left
|
|
731
|
+
// alone. Re-pointing it was a silent hijack: reconcile runs on every
|
|
732
|
+
// engine-touching CLI invocation, from whichever copy of the package is being
|
|
733
|
+
// invoked, so any second install captured the user's skill links just by being
|
|
734
|
+
// run once. docs/SDK.md tells users to `npm install @lincoln504/pi-research` INTO
|
|
735
|
+
// their project, so the second install is a documented, ordinary setup — and when
|
|
736
|
+
// that project was later moved or cleaned, the global skill died with an opaque
|
|
737
|
+
// "Cannot find module .../run.mjs". Self-healing only needs the dangling case
|
|
738
|
+
// (`!targetLive`), which is what an update that relocates the package produces;
|
|
739
|
+
// choosing between two live installs is what the explicit `skill install` is for.
|
|
740
|
+
if (!targetLive) {
|
|
704
741
|
if (source !== null) {
|
|
705
742
|
// Re-point to the current package source (handles update/reinstall).
|
|
706
743
|
try {
|
package/src/utils/text-utils.ts
CHANGED
|
@@ -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.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { runWorkerSearch } from '../infrastructure/browser/task-execution-service.ts';
|
|
8
8
|
import { getMaxWorkers, COLD_START_ALLOWANCE_MS } from '../infrastructure/browser/config.ts';
|
|
9
|
-
import { isTaskTimeoutError } from '../infrastructure/browser/browser-error-utils.ts';
|
|
9
|
+
import { isTaskTimeoutError, isNativeBindingError } from '../infrastructure/browser/browser-error-utils.ts';
|
|
10
10
|
import { logger } from '../logger.ts';
|
|
11
11
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
12
12
|
import { normalizeUrl } from '../utils/url-utils.ts';
|
|
@@ -321,6 +321,24 @@ export async function performSearch(
|
|
|
321
321
|
reason = `${timeoutCount} queries timed out and ${errorCount} queries failed`;
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
// A missing native addon is a BROKEN INSTALL, and the generic advice below
|
|
325
|
+
// actively misleads: it sends the reader to check their network and their
|
|
326
|
+
// machine load when neither is involved and neither can be acted on. npm 12
|
|
327
|
+
// defaults `allowScripts` to off, so a plain install no longer builds
|
|
328
|
+
// camoufox-js's better-sqlite3 dependency and every browser worker dies
|
|
329
|
+
// identically. Say that instead, and say what fixes it.
|
|
330
|
+
if (isNativeBindingError(sampleWorkerError)) {
|
|
331
|
+
metrics.increment('browser_search_total_failures_total', 1, { cause: 'native_binding' });
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Search completely failed: ${reason}, all with a missing native module. ` +
|
|
334
|
+
`This is an incomplete install, not a network problem: the browser engine's ` +
|
|
335
|
+
`native dependencies were never built. npm 12 does not run dependency install ` +
|
|
336
|
+
`scripts by default — reinstall allowing them (npm install @lincoln504/pi-research ` +
|
|
337
|
+
`--allow-scripts, or npm approve-scripts --allow-scripts-pending), then retry.` +
|
|
338
|
+
(sampleWorkerError ? ` Last worker error: ${sampleWorkerError}` : '')
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
324
342
|
throw new Error(
|
|
325
343
|
`Search completely failed: ${reason}. ` +
|
|
326
344
|
`Browser workers may be unavailable, DuckDuckGo is unreachable, or the system is under extreme load.` +
|