@lincoln504/pi-research 1.4.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.mjs +192 -45
- package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
- package/dist/prompts/research-knowledge-search-extractor.md +2 -0
- package/dist/prompts/researcher.md +2 -14
- package/dist/thread-worker.mjs +1 -1
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +45 -1
- package/docs/CONFIGURATION.md +1 -1
- package/docs/SDK.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +26 -9
- package/src/constants.ts +23 -4
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +12 -1
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +5 -1
- package/src/infrastructure/browser/thread-worker.mjs +1 -1
- package/src/infrastructure/embedding/embedding-server.ts +29 -9
- package/src/knowledge/store.ts +60 -4
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/quick-research-orchestrator.ts +29 -10
- package/src/orchestration/research-synthesis-service.ts +32 -0
- package/src/orchestration/researcher-executor.ts +31 -16
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +19 -4
- package/src/prompts/research-knowledge-relevance-triage.md +2 -0
- package/src/prompts/research-knowledge-search-extractor.md +2 -0
- package/src/prompts/researcher.md +2 -14
- package/src/sdk.ts +6 -0
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +37 -4
- package/src/tools/research-tool-definition.ts +27 -16
- package/src/tools/scrape.ts +8 -1
- package/src/utils/log-utils.ts +5 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/web-scraper.ts +9 -0
- package/src/youtube/index.ts +11 -1
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
|
|
|
64
64
|
### Why pi-research
|
|
65
65
|
|
|
66
66
|
- **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
|
-
- **You own your index.** Every finding
|
|
67
|
+
- **You own your index.** 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
68
|
- **Cited reports rather than raw dumps.** You get one sourced Markdown report instead of raw web content pasted into the conversation. Context efficient and easy to verify.
|
|
69
69
|
- **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything. It can read files, so treat it as you would any read-only agent with access to your machine.
|
|
70
70
|
- **Search a little or a lot.** Depth levels span a quick pass to a large-scale investigation.
|
package/dist/cli.mjs
CHANGED
|
@@ -2643,10 +2643,10 @@ function loadEnvFiles(cwd, iface) {
|
|
|
2643
2643
|
try {
|
|
2644
2644
|
logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
|
|
2645
2645
|
legacyEnv = parseDotEnv(fs5.readFileSync(legacyPath, "utf-8"));
|
|
2646
|
-
Object.assign(merged, legacyEnv);
|
|
2647
2646
|
const legacyLocal = Object.fromEntries(
|
|
2648
2647
|
Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
2649
2648
|
);
|
|
2649
|
+
Object.assign(merged, legacyLocal);
|
|
2650
2650
|
const existingLocal = Object.fromEntries(
|
|
2651
2651
|
Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
2652
2652
|
);
|
|
@@ -2676,7 +2676,10 @@ function loadEnvFiles(cwd, iface) {
|
|
|
2676
2676
|
}
|
|
2677
2677
|
}
|
|
2678
2678
|
} else if (Object.keys(merged).length === 0 && !fs5.existsSync(legacyPath) && !fs5.existsSync(globalPath)) {
|
|
2679
|
-
|
|
2679
|
+
if (!warnedMissingConfigWorkspaces.has(normalizedCwd)) {
|
|
2680
|
+
warnedMissingConfigWorkspaces.add(normalizedCwd);
|
|
2681
|
+
logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
|
|
2682
|
+
}
|
|
2680
2683
|
}
|
|
2681
2684
|
warnLegacyEnvVarsOnce(merged);
|
|
2682
2685
|
return merged;
|
|
@@ -2965,7 +2968,7 @@ function parseEnvEnum(env, key, allowed, def) {
|
|
|
2965
2968
|
logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(", ")}). Using default "${def}".`);
|
|
2966
2969
|
return def;
|
|
2967
2970
|
}
|
|
2968
|
-
var ConfigSchema, DEFAULTS, LOCAL_SCOPE_KEYS, USER_MIGRATION_KEYS, RECLAIM_FILE_SWEEP_AGE_MS, LEGACY_ENV_VARS, warnedLegacyEnvVars, configCache;
|
|
2971
|
+
var ConfigSchema, DEFAULTS, LOCAL_SCOPE_KEYS, USER_MIGRATION_KEYS, RECLAIM_FILE_SWEEP_AGE_MS, LEGACY_ENV_VARS, warnedLegacyEnvVars, warnedMissingConfigWorkspaces, configCache;
|
|
2969
2972
|
var init_config = __esm({
|
|
2970
2973
|
"src/config.ts"() {
|
|
2971
2974
|
"use strict";
|
|
@@ -3184,6 +3187,7 @@ var init_config = __esm({
|
|
|
3184
3187
|
PROXY_URL: "no longer read \u2014 the SearXNG proxy path was removed in v1.0.0 and searches/scrapes connect directly"
|
|
3185
3188
|
};
|
|
3186
3189
|
warnedLegacyEnvVars = false;
|
|
3190
|
+
warnedMissingConfigWorkspaces = /* @__PURE__ */ new Set();
|
|
3187
3191
|
configCache = /* @__PURE__ */ new Map();
|
|
3188
3192
|
}
|
|
3189
3193
|
});
|
|
@@ -3196,10 +3200,11 @@ function getMaxGatheringCalls(config) {
|
|
|
3196
3200
|
return MAX_GATHERING_CALLS;
|
|
3197
3201
|
}
|
|
3198
3202
|
}
|
|
3199
|
-
function getMaxScrapeBatches(config) {
|
|
3203
|
+
function getMaxScrapeBatches(config, cachingActive = false) {
|
|
3200
3204
|
try {
|
|
3201
3205
|
const batches = (config || getConfig()).MAX_SCRAPE_BATCHES;
|
|
3202
|
-
|
|
3206
|
+
if (batches === 0 || batches > 99) return 999999;
|
|
3207
|
+
return cachingActive ? Math.min(batches + 1, 99) : batches;
|
|
3203
3208
|
} catch {
|
|
3204
3209
|
return 2;
|
|
3205
3210
|
}
|
|
@@ -5506,20 +5511,37 @@ var init_embedding_server = __esm({
|
|
|
5506
5511
|
});
|
|
5507
5512
|
}
|
|
5508
5513
|
/**
|
|
5509
|
-
* Resolve once no native call is in flight
|
|
5510
|
-
* to wait out a still-running
|
|
5511
|
-
*
|
|
5512
|
-
*
|
|
5513
|
-
*
|
|
5514
|
-
*
|
|
5515
|
-
* leak-to-process-exit
|
|
5514
|
+
* Resolve once no native call is in flight AND no queued task is still due
|
|
5515
|
+
* to run. Used by EmbeddingServer.shutdown() to wait out a still-running
|
|
5516
|
+
* inference before disposing the pipeline — without this, disposal could
|
|
5517
|
+
* run concurrently with a legitimately slow (not hung) embed/embedMany call
|
|
5518
|
+
* on the same re-entrant-locked GPU context, the exact native-segfault
|
|
5519
|
+
* class this queue exists to prevent. No timeout: a promise that truly
|
|
5520
|
+
* never settles is already an accepted, pre-existing leak-to-process-exit
|
|
5521
|
+
* scenario elsewhere in this class (see the poison path's
|
|
5516
5522
|
* disposeEmbedder=false handling) — better to leak than risk concurrent
|
|
5517
5523
|
* native access.
|
|
5524
|
+
*
|
|
5525
|
+
* Checking only `currentWork` is NOT enough with a second task already
|
|
5526
|
+
* queued: a task's `run()` clears `currentWork` to null in its `finally`
|
|
5527
|
+
* (once its native call has settled), and `pump()` only claims it again for
|
|
5528
|
+
* the next task on a LATER microtask tick, once `run()` for the current
|
|
5529
|
+
* task has fully unwound and `await entry.run()` resumes. If a caller's own
|
|
5530
|
+
* check happens to land in that gap, `currentWork` reads as null even
|
|
5531
|
+
* though a second native call is about to start (or a queue in the process
|
|
5532
|
+
* of unwinding to its next task hasn't reached it yet) — exactly the window
|
|
5533
|
+
* disposal must not run in. `running` stays true for the queue's entire
|
|
5534
|
+
* drain, across every task in `tasks`, not just the current one, so it has
|
|
5535
|
+
* no equivalent gap and closes this one.
|
|
5518
5536
|
*/
|
|
5519
5537
|
async waitForIdle() {
|
|
5520
|
-
while (this.currentWork) {
|
|
5521
|
-
|
|
5522
|
-
|
|
5538
|
+
while (this.currentWork || this.running) {
|
|
5539
|
+
if (this.currentWork) {
|
|
5540
|
+
await this.currentWork.catch(() => {
|
|
5541
|
+
});
|
|
5542
|
+
} else {
|
|
5543
|
+
await Promise.resolve();
|
|
5544
|
+
}
|
|
5523
5545
|
}
|
|
5524
5546
|
}
|
|
5525
5547
|
poison(err) {
|
|
@@ -7683,10 +7705,31 @@ var init_store = __esm({
|
|
|
7683
7705
|
this.table = await this.db.openTable(this.tableName);
|
|
7684
7706
|
} else {
|
|
7685
7707
|
logger.error("[store] Failed to persist canonical table name after rename \u2014 reverting to keep manifest and disk in sync.");
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
7708
|
+
try {
|
|
7709
|
+
await fsPromises.rename(canonicalDir, tempDir);
|
|
7710
|
+
this.tableName = tempTableName;
|
|
7711
|
+
this.table = await this.db.openTable(this.tableName);
|
|
7712
|
+
logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
|
|
7713
|
+
} catch (revertErr) {
|
|
7714
|
+
logger.error(`[store] Revert rename also failed; data remains on disk at ${canonicalDir}: ${revertErr}`);
|
|
7715
|
+
this.tableName = canonicalName;
|
|
7716
|
+
let reopened = false;
|
|
7717
|
+
try {
|
|
7718
|
+
this.table = await this.db.openTable(this.tableName);
|
|
7719
|
+
reopened = true;
|
|
7720
|
+
} catch (openErr) {
|
|
7721
|
+
logger.error(`[store] CRITICAL: could not reopen the table at '${canonicalName}' after a failed rename revert (${openErr}). Data should still be intact on disk at ${canonicalDir}, but this process has no working table handle for it. Attempting the manifest repair anyway so a restart can find the data; verify ${canonicalDir} exists if problems persist.`);
|
|
7722
|
+
}
|
|
7723
|
+
try {
|
|
7724
|
+
if (await this.saveManifest()) {
|
|
7725
|
+
logger.warn(`[store] Recovered: manifest now points at '${canonicalName}', matching the data's actual on-disk location${reopened ? "" : " (this process still has no working table handle \u2014 restart to serve it)"}.`);
|
|
7726
|
+
} else {
|
|
7727
|
+
logger.error(`[store] CRITICAL: could not persist a manifest matching disk after a failed rename revert. Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}', which no longer exists. If this process exits before a future successful manifest save, the next start will not find the data automatically \u2014 manually repair the manifest to point at '${canonicalName}' before restarting.`);
|
|
7728
|
+
}
|
|
7729
|
+
} catch (manifestErr) {
|
|
7730
|
+
logger.error(`[store] CRITICAL: manifest repair threw after a failed rename revert (${manifestErr}). Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}'. Manually repair the manifest to point at '${canonicalName}' before restarting.`);
|
|
7731
|
+
}
|
|
7732
|
+
}
|
|
7690
7733
|
}
|
|
7691
7734
|
} catch (renameErr) {
|
|
7692
7735
|
logger.warn(`[store] Directory rename failed, persisting temp table name in manifest: ${renameErr}`);
|
|
@@ -9292,6 +9335,15 @@ function resolveResearchModel(options) {
|
|
|
9292
9335
|
}
|
|
9293
9336
|
throw new Error(buildNoModelAvailableMessage());
|
|
9294
9337
|
}
|
|
9338
|
+
function isPromptCachingActiveForModel(model) {
|
|
9339
|
+
if (!model) return false;
|
|
9340
|
+
if (model.api === "anthropic-messages") return true;
|
|
9341
|
+
if (model.api === "openai-completions") {
|
|
9342
|
+
const compat = model.compat;
|
|
9343
|
+
return compat?.cacheControlFormat === "anthropic";
|
|
9344
|
+
}
|
|
9345
|
+
return false;
|
|
9346
|
+
}
|
|
9295
9347
|
var init_research_model_resolver = __esm({
|
|
9296
9348
|
"src/core/llm/research-model-resolver.ts"() {
|
|
9297
9349
|
"use strict";
|
|
@@ -9429,6 +9481,7 @@ __export(research_knowledge_search_exports, {
|
|
|
9429
9481
|
isTransientSynthesisError: () => isTransientSynthesisError,
|
|
9430
9482
|
rebuildDocuments: () => rebuildDocuments,
|
|
9431
9483
|
runBackgroundExtraction: () => runBackgroundExtraction,
|
|
9484
|
+
splitPromptAtUserTurn: () => splitPromptAtUserTurn,
|
|
9432
9485
|
triageRelevantUrls: () => triageRelevantUrls
|
|
9433
9486
|
});
|
|
9434
9487
|
import { Type as Type10 } from "typebox";
|
|
@@ -9572,13 +9625,28 @@ function isTransientSynthesisError(message) {
|
|
|
9572
9625
|
// connection surfaces as `TypeError: terminated` (cause "other side closed" / UND_ERR_SOCKET).
|
|
9573
9626
|
m.includes("terminated") || m.includes("other side closed") || m.includes("socket hang up") || m.includes("und_err");
|
|
9574
9627
|
}
|
|
9628
|
+
function splitPromptAtUserTurn(template) {
|
|
9629
|
+
const open5 = template.indexOf("<!-- USER_TURN");
|
|
9630
|
+
if (open5 < 0) return { system: template, user: null };
|
|
9631
|
+
const close = template.indexOf("-->", open5);
|
|
9632
|
+
if (close < 0) return { system: template, user: null };
|
|
9633
|
+
return {
|
|
9634
|
+
system: template.slice(0, open5).trimEnd(),
|
|
9635
|
+
user: template.slice(close + "-->".length).trimStart()
|
|
9636
|
+
};
|
|
9637
|
+
}
|
|
9575
9638
|
async function runBackgroundExtraction(model, auth, conversationHistory, queries, referenceDocuments, llmTimeout, maxTokens, thinkingLevel, signal, sessionId) {
|
|
9576
9639
|
const promptTemplate = loadPrompt("research-knowledge-search-extractor");
|
|
9577
9640
|
if (!promptTemplate) {
|
|
9578
9641
|
throw new Error("research-knowledge-search prompt template not found");
|
|
9579
9642
|
}
|
|
9580
|
-
const
|
|
9581
|
-
const
|
|
9643
|
+
const { system, user } = splitPromptAtUserTurn(promptTemplate);
|
|
9644
|
+
const fill = (t) => t.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{reference_documents}}", () => referenceDocuments);
|
|
9645
|
+
const finalInstruction = "Analyze the reference documents above and extract the answer using the required JSON format.";
|
|
9646
|
+
const systemPrompt = user === null ? fill(system) : system;
|
|
9647
|
+
const userMessage = user === null ? finalInstruction : `${fill(user)}
|
|
9648
|
+
|
|
9649
|
+
${finalInstruction}`;
|
|
9582
9650
|
let responseText;
|
|
9583
9651
|
let attempt = 0;
|
|
9584
9652
|
for (; ; ) {
|
|
@@ -9666,8 +9734,13 @@ async function triageRelevantUrls(model, auth, conversationHistory, queries, can
|
|
|
9666
9734
|
return `[${i}] ${c.url}
|
|
9667
9735
|
${desc}`;
|
|
9668
9736
|
}).join("\n\n");
|
|
9669
|
-
const
|
|
9670
|
-
const
|
|
9737
|
+
const { system, user } = splitPromptAtUserTurn(promptTemplate);
|
|
9738
|
+
const fill = (t) => t.replace("{{queries}}", () => formatQueriesBlock(queries)).replace("{{conversation_history}}", () => conversationHistory).replace("{{candidates}}", () => candidateBlock);
|
|
9739
|
+
const finalInstruction = "Return the JSON object listing the indices of the relevant candidates.";
|
|
9740
|
+
const systemPrompt = user === null ? fill(system) : system;
|
|
9741
|
+
const userMessage = user === null ? finalInstruction : `${fill(user)}
|
|
9742
|
+
|
|
9743
|
+
${finalInstruction}`;
|
|
9671
9744
|
let responseText;
|
|
9672
9745
|
let attempt = 0;
|
|
9673
9746
|
for (; ; ) {
|
|
@@ -11250,7 +11323,12 @@ function isDegradableLlmError(error, signal) {
|
|
|
11250
11323
|
function salvageReportText(raw) {
|
|
11251
11324
|
const trimmed = raw.trim();
|
|
11252
11325
|
if (trimmed.length <= 50) return "";
|
|
11253
|
-
|
|
11326
|
+
let sniff = trimmed;
|
|
11327
|
+
const fence = sniff.match(/^```[a-zA-Z0-9_-]*[ \t]*\r?\n/);
|
|
11328
|
+
if (fence) {
|
|
11329
|
+
sniff = sniff.slice(fence[0].length).replace(/\r?\n```\s*$/, "").trim();
|
|
11330
|
+
}
|
|
11331
|
+
if (sniff.startsWith("{") && /"(?:action|content|researchers)"\s*:/.test(sniff)) {
|
|
11254
11332
|
return "";
|
|
11255
11333
|
}
|
|
11256
11334
|
return raw;
|
|
@@ -18621,7 +18699,7 @@ init_metrics();
|
|
|
18621
18699
|
init_cancellation();
|
|
18622
18700
|
function createScrapeTool(options) {
|
|
18623
18701
|
const config = options.config || getConfig(options.ctx.cwd);
|
|
18624
|
-
const maxScrapeBatches = getMaxScrapeBatches(config);
|
|
18702
|
+
const maxScrapeBatches = options.tracker?.getToolLimit("scrape") ?? getMaxScrapeBatches(config);
|
|
18625
18703
|
const container = tryGetServiceContainerFromCtx(options.ctx);
|
|
18626
18704
|
const fallbackState = {
|
|
18627
18705
|
version: 1,
|
|
@@ -22120,6 +22198,8 @@ var PermanentError = class extends Error {
|
|
|
22120
22198
|
|
|
22121
22199
|
// src/youtube/index.ts
|
|
22122
22200
|
init_metrics();
|
|
22201
|
+
init_text_utils();
|
|
22202
|
+
init_constants();
|
|
22123
22203
|
function buildTranscriptDocument(r) {
|
|
22124
22204
|
const header = [];
|
|
22125
22205
|
if (r.title) header.push(`# ${r.title}`);
|
|
@@ -22197,7 +22277,7 @@ function formatResults(results, rejected) {
|
|
|
22197
22277
|
md += `**Cite as:** ${r.url}${r.title ? ` \u2014 '${r.title}'${r.author ? ` by ${r.author}` : ""}` : ""}
|
|
22198
22278
|
`;
|
|
22199
22279
|
md += `
|
|
22200
|
-
${r.text}
|
|
22280
|
+
${truncateWithMarker(r.text ?? "", MAX_SCRAPE_CONTENT_CHARS_PER_DOC)}
|
|
22201
22281
|
|
|
22202
22282
|
---
|
|
22203
22283
|
|
|
@@ -22536,10 +22616,10 @@ var ToolUsageTracker = class {
|
|
|
22536
22616
|
this.usage.clear();
|
|
22537
22617
|
}
|
|
22538
22618
|
};
|
|
22539
|
-
function createDefaultToolLimits(config) {
|
|
22619
|
+
function createDefaultToolLimits(config, cachingActive = false) {
|
|
22540
22620
|
return {
|
|
22541
22621
|
gathering: getMaxGatheringCalls(config),
|
|
22542
|
-
scrape: getMaxScrapeBatches(config),
|
|
22622
|
+
scrape: getMaxScrapeBatches(config, cachingActive),
|
|
22543
22623
|
search: 1,
|
|
22544
22624
|
youtube_transcript: 1,
|
|
22545
22625
|
grep: MAX_GREP_CALLS,
|
|
@@ -22603,7 +22683,7 @@ async function createResearcherSession(options) {
|
|
|
22603
22683
|
});
|
|
22604
22684
|
logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
|
|
22605
22685
|
try {
|
|
22606
|
-
const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
|
|
22686
|
+
const tracker = new ToolUsageTracker(createDefaultToolLimits(config, isPromptCachingActiveForModel(modelToUse)));
|
|
22607
22687
|
const globalLinks = updateGlobalLinks || (() => {
|
|
22608
22688
|
});
|
|
22609
22689
|
const customTools = createResearchTools({
|
|
@@ -22831,7 +22911,7 @@ function endResearchSession(piSessionId, researchId) {
|
|
|
22831
22911
|
state.order.splice(index, 1);
|
|
22832
22912
|
notifyOrderChange(sid);
|
|
22833
22913
|
}
|
|
22834
|
-
if (state.order.length === 0 && state.panels.size === 0) {
|
|
22914
|
+
if (state.order.length === 0 && state.panels.size === 0 && state.aborts.size === 0) {
|
|
22835
22915
|
state.steeringMessages = state.steeringMessages.filter((m) => m.status === "queued");
|
|
22836
22916
|
const hasRemainingSteering = state.steeringMessages.length > 0;
|
|
22837
22917
|
if (!hasRemainingSteering) {
|
|
@@ -22843,7 +22923,6 @@ function endResearchSession(piSessionId, researchId) {
|
|
|
22843
22923
|
} else {
|
|
22844
22924
|
state.failures.clear();
|
|
22845
22925
|
state.failureReasons.clear();
|
|
22846
|
-
state.aborts.clear();
|
|
22847
22926
|
}
|
|
22848
22927
|
}
|
|
22849
22928
|
}
|
|
@@ -23007,6 +23086,7 @@ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
|
|
|
23007
23086
|
let lastError;
|
|
23008
23087
|
const researcherExecutionStartMs = Date.now();
|
|
23009
23088
|
const deliveredSteeringIds = /* @__PURE__ */ new Set();
|
|
23089
|
+
const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "").replace("{{digest_section}}", () => RESEARCHER_DIGEST_SECTION).trim();
|
|
23010
23090
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
23011
23091
|
if (signal?.aborted) throw new Error("Aborted");
|
|
23012
23092
|
if (stopRequested()) {
|
|
@@ -23040,14 +23120,25 @@ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
|
|
|
23040
23120
|
const allSteering = getSteeringMessages(sessionId);
|
|
23041
23121
|
let steeringSection = "";
|
|
23042
23122
|
if (allSteering.length > 0) {
|
|
23043
|
-
steeringSection = "
|
|
23123
|
+
steeringSection = "### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n" + allSteering.map((m) => {
|
|
23044
23124
|
deliveredSteeringIds.add(m.id);
|
|
23045
23125
|
return `- ${m.text}`;
|
|
23046
23126
|
}).join("\n");
|
|
23047
23127
|
}
|
|
23048
|
-
const
|
|
23128
|
+
const userMessage = [
|
|
23129
|
+
`Topic: ${researcherConfig.name}
|
|
23130
|
+
Goal: ${researcherConfig.goal}`,
|
|
23131
|
+
storeSection.trim(),
|
|
23132
|
+
evidenceSection.trim(),
|
|
23133
|
+
previousQueriesSection.trim(),
|
|
23134
|
+
steeringSection,
|
|
23135
|
+
"Perform your research and submit your full report now."
|
|
23136
|
+
].filter(Boolean).join("\n\n");
|
|
23049
23137
|
logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:
|
|
23050
|
-
${
|
|
23138
|
+
${researcherSystemPrompt}
|
|
23139
|
+
|
|
23140
|
+
User Message:
|
|
23141
|
+
${userMessage}`);
|
|
23051
23142
|
const workerExclude = ["search"];
|
|
23052
23143
|
const mergedExclude = [.../* @__PURE__ */ new Set([...workerExclude, ...options.excludeTools || []])];
|
|
23053
23144
|
const scrapeEnabled = !mergedExclude.includes("scrape") && process.env["PI_RESEARCH_MOCK_SCRAPE"] !== "true";
|
|
@@ -23057,7 +23148,7 @@ ${prompt}`);
|
|
|
23057
23148
|
cwd: ctx.cwd,
|
|
23058
23149
|
ctxModel: model,
|
|
23059
23150
|
modelRegistry: ctx.modelRegistry,
|
|
23060
|
-
systemPrompt:
|
|
23151
|
+
systemPrompt: researcherSystemPrompt,
|
|
23061
23152
|
extensionCtx: ctx,
|
|
23062
23153
|
excludeTools: mergedExclude,
|
|
23063
23154
|
researcherId: id,
|
|
@@ -23172,10 +23263,7 @@ ${prompt}`);
|
|
|
23172
23263
|
});
|
|
23173
23264
|
let abortCleanup;
|
|
23174
23265
|
try {
|
|
23175
|
-
const promptPromise = session.prompt(
|
|
23176
|
-
Goal: ${researcherConfig.goal}
|
|
23177
|
-
|
|
23178
|
-
Perform your research and submit your full report now.`);
|
|
23266
|
+
const promptPromise = session.prompt(userMessage);
|
|
23179
23267
|
promptPromise.catch((err) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
|
|
23180
23268
|
await Promise.race([
|
|
23181
23269
|
promptPromise,
|
|
@@ -23279,6 +23367,7 @@ init_logger();
|
|
|
23279
23367
|
init_config();
|
|
23280
23368
|
init_text_utils();
|
|
23281
23369
|
init_constants();
|
|
23370
|
+
init_research_model_resolver();
|
|
23282
23371
|
|
|
23283
23372
|
// src/orchestration/headless-observer.ts
|
|
23284
23373
|
init_logger();
|
|
@@ -23474,7 +23563,10 @@ var QuickResearchOrchestrator = class {
|
|
|
23474
23563
|
}
|
|
23475
23564
|
}
|
|
23476
23565
|
const researcherPromptTemplate = loadPrompt("researcher");
|
|
23477
|
-
const
|
|
23566
|
+
const cachingActiveForBudget = isPromptCachingActiveForModel(
|
|
23567
|
+
resolveResearchModel({ modelRegistry: ctx.modelRegistry, config: this.config, hostModel: model, cwd: ctx.cwd })
|
|
23568
|
+
);
|
|
23569
|
+
const maxScrapeBatches = getMaxScrapeBatches(this.config, cachingActiveForBudget);
|
|
23478
23570
|
const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? "unlimited" : maxScrapeBatches.toString();
|
|
23479
23571
|
const quickEvidenceSection = `## Search
|
|
23480
23572
|
You have access to the \`search\` tool. You get EXACTLY ONE search call \u2014 make it count.
|
|
@@ -23497,9 +23589,19 @@ Prioritize primary sources and authoritative data.`;
|
|
|
23497
23589
|
evidenceLines.push("## Initial Links\nInvestigate these provided URLs first:\n" + this.options.initialLinks.map((l) => `- ${l}`).join("\n"));
|
|
23498
23590
|
}
|
|
23499
23591
|
evidenceLines.push(quickEvidenceSection);
|
|
23500
|
-
const prompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{
|
|
23592
|
+
const prompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "- `search`: Perform broad web searches (Round 1 only).").replace("{{digest_section}}", "").trim();
|
|
23593
|
+
const userMessage = [
|
|
23594
|
+
`Goal: ${query}`,
|
|
23595
|
+
storeSection.trim(),
|
|
23596
|
+
evidenceLines.join("\n\n").trim(),
|
|
23597
|
+
steeringSection.trim(),
|
|
23598
|
+
"Perform your research and submit your full report now."
|
|
23599
|
+
].filter(Boolean).join("\n\n");
|
|
23501
23600
|
logger.debug(`[QuickOrchestrator] System Prompt:
|
|
23502
|
-
${prompt}
|
|
23601
|
+
${prompt}
|
|
23602
|
+
|
|
23603
|
+
User Message:
|
|
23604
|
+
${userMessage}`);
|
|
23503
23605
|
let lastSeenSearchCount = 0;
|
|
23504
23606
|
const { session, resolvedModel } = await createResearcherSession({
|
|
23505
23607
|
cwd: ctx.cwd,
|
|
@@ -23593,7 +23695,7 @@ ${prompt}`);
|
|
|
23593
23695
|
});
|
|
23594
23696
|
let abortCleanup;
|
|
23595
23697
|
try {
|
|
23596
|
-
const promptPromise = session.prompt(
|
|
23698
|
+
const promptPromise = session.prompt(userMessage);
|
|
23597
23699
|
promptPromise.catch((err) => logger.debug(`[QuickOrchestrator] Background session prompt rejection: ${err.message}`));
|
|
23598
23700
|
await Promise.race([
|
|
23599
23701
|
promptPromise,
|
|
@@ -24743,6 +24845,10 @@ init_service_registry();
|
|
|
24743
24845
|
init_service_names();
|
|
24744
24846
|
var NO_SOURCES_NOTE = "CITED LINKS\n_No web sources were successfully retrieved for this report \u2014 searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._";
|
|
24745
24847
|
var UNVERIFIED_LINKS_NOTE = "_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented \u2014 check each one before relying on it._";
|
|
24848
|
+
var WEAK_GROUNDING_MIN_SOURCES = 3;
|
|
24849
|
+
function weakGroundingNote(count) {
|
|
24850
|
+
return `_Grounding notice: only ${count} source${count === 1 ? "" : "s"} could be verified for this report. Claims not attributable to the cited source` + (count === 1 ? "" : "s") + " above should be treated as unverified \u2014 cross-check anything load-bearing before relying on it._";
|
|
24851
|
+
}
|
|
24746
24852
|
function redactUnverifiedProseUrls(text, trusted) {
|
|
24747
24853
|
if (trusted.size === 0) return text;
|
|
24748
24854
|
let removed = 0;
|
|
@@ -24985,6 +25091,14 @@ Research performed using ${modelId}`;
|
|
|
24985
25091
|
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
24986
25092
|
}
|
|
24987
25093
|
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
25094
|
+
if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
|
|
25095
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) \u2014 appending grounding notice.`);
|
|
25096
|
+
return `${body}
|
|
25097
|
+
|
|
25098
|
+
${weakGroundingNote(globalCitations.length)}
|
|
25099
|
+
|
|
25100
|
+
${verifiedLinksSection}`;
|
|
25101
|
+
}
|
|
24988
25102
|
return `${body}
|
|
24989
25103
|
|
|
24990
25104
|
${verifiedLinksSection}`;
|
|
@@ -24993,6 +25107,14 @@ ${verifiedLinksSection}`;
|
|
|
24993
25107
|
const validIds = new Set(globalCitations.map((c) => c.id));
|
|
24994
25108
|
let prose = rewriteCitationMarkers(synthesis.trim(), /* @__PURE__ */ new Map(), (n) => !validIds.has(n));
|
|
24995
25109
|
prose = redactUnverifiedProseUrls(prose, trustedUrls);
|
|
25110
|
+
if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
|
|
25111
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) \u2014 appending grounding notice.`);
|
|
25112
|
+
return `${prose}
|
|
25113
|
+
|
|
25114
|
+
${weakGroundingNote(globalCitations.length)}
|
|
25115
|
+
|
|
25116
|
+
${verifiedLinksSection}`;
|
|
25117
|
+
}
|
|
24996
25118
|
return `${prose}
|
|
24997
25119
|
|
|
24998
25120
|
${verifiedLinksSection}`;
|
|
@@ -25227,6 +25349,8 @@ function extractRunStats(snapshot) {
|
|
|
25227
25349
|
};
|
|
25228
25350
|
const errors = sumCounter(counters, "researcher_errors_total") + sumCounter(counters, "llm_api_errors_total");
|
|
25229
25351
|
const tokens = sumCounter(counters, "llm_tokens_total");
|
|
25352
|
+
const cacheReadTokens = sumCounter(counters, "llm_cache_read_tokens_total");
|
|
25353
|
+
const cacheWriteTokens = sumCounter(counters, "llm_cache_write_tokens_total");
|
|
25230
25354
|
const cost = sumCounter(counters, "llm_cost_total");
|
|
25231
25355
|
let durationMs = 0;
|
|
25232
25356
|
for (const key of Object.keys(histograms)) {
|
|
@@ -25261,6 +25385,8 @@ function extractRunStats(snapshot) {
|
|
|
25261
25385
|
urlsFailed,
|
|
25262
25386
|
errors,
|
|
25263
25387
|
tokens,
|
|
25388
|
+
cacheReadTokens,
|
|
25389
|
+
cacheWriteTokens,
|
|
25264
25390
|
cost,
|
|
25265
25391
|
toolUsage
|
|
25266
25392
|
};
|
|
@@ -25292,6 +25418,7 @@ var _onSignal = null;
|
|
|
25292
25418
|
var _sigintHandler = null;
|
|
25293
25419
|
var _sigtermHandler = null;
|
|
25294
25420
|
var _sighupHandler = null;
|
|
25421
|
+
var _sigquitHandler = null;
|
|
25295
25422
|
var _sigbreakHandler = null;
|
|
25296
25423
|
var _shuttingDown = false;
|
|
25297
25424
|
var _shutdownPromise = null;
|
|
@@ -25312,9 +25439,11 @@ function _registerSignalHandlers() {
|
|
|
25312
25439
|
_sigintHandler = () => _onSignal?.("SIGINT");
|
|
25313
25440
|
_sigtermHandler = () => _onSignal?.("SIGTERM");
|
|
25314
25441
|
_sighupHandler = () => _onSignal?.("SIGHUP");
|
|
25442
|
+
_sigquitHandler = () => _onSignal?.("SIGQUIT");
|
|
25315
25443
|
process.on("SIGINT", _sigintHandler);
|
|
25316
25444
|
process.on("SIGTERM", _sigtermHandler);
|
|
25317
25445
|
process.on("SIGHUP", _sighupHandler);
|
|
25446
|
+
process.on("SIGQUIT", _sigquitHandler);
|
|
25318
25447
|
if (process.platform === "win32") {
|
|
25319
25448
|
_sigbreakHandler = () => _onSignal?.("SIGBREAK");
|
|
25320
25449
|
process.on("SIGBREAK", _sigbreakHandler);
|
|
@@ -25333,6 +25462,10 @@ function _removeSignalHandlers() {
|
|
|
25333
25462
|
process.removeListener("SIGHUP", _sighupHandler);
|
|
25334
25463
|
_sighupHandler = null;
|
|
25335
25464
|
}
|
|
25465
|
+
if (_sigquitHandler) {
|
|
25466
|
+
process.removeListener("SIGQUIT", _sigquitHandler);
|
|
25467
|
+
_sigquitHandler = null;
|
|
25468
|
+
}
|
|
25336
25469
|
if (_sigbreakHandler) {
|
|
25337
25470
|
process.removeListener("SIGBREAK", _sigbreakHandler);
|
|
25338
25471
|
_sigbreakHandler = null;
|
|
@@ -26019,10 +26152,17 @@ function makeProgressObserver() {
|
|
|
26019
26152
|
}
|
|
26020
26153
|
break;
|
|
26021
26154
|
}
|
|
26022
|
-
case "complete":
|
|
26155
|
+
case "complete": {
|
|
26023
26156
|
toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
|
|
26024
26157
|
`);
|
|
26158
|
+
const reportText = typeof data?.result === "string" ? data.result : "";
|
|
26159
|
+
if (reportText.includes("No web sources were successfully retrieved") || reportText.includes("None of the links above could be verified")) {
|
|
26160
|
+
toStderr(" \u2022 WARNING: no web sources could be retrieved \u2014 the report is unverified\n");
|
|
26161
|
+
} else if (reportText.includes("Grounding notice: only")) {
|
|
26162
|
+
toStderr(" \u2022 WARNING: weakly grounded report (fewer than 3 verified sources) \u2014 treat unattributed claims as unverified\n");
|
|
26163
|
+
}
|
|
26025
26164
|
break;
|
|
26165
|
+
}
|
|
26026
26166
|
case "error":
|
|
26027
26167
|
toStderr(` \u2022 error: ${data?.message}
|
|
26028
26168
|
`);
|
|
@@ -26338,7 +26478,7 @@ Configure in:
|
|
|
26338
26478
|
config: getConfig(process.cwd(), "cli"),
|
|
26339
26479
|
ignoreGlobalConfig: true
|
|
26340
26480
|
});
|
|
26341
|
-
toStderr(`[pi-research] searching knowledge store (${queries.length}
|
|
26481
|
+
toStderr(`[pi-research] searching knowledge store (${queries.length} ${queries.length === 1 ? "query" : "queries"})\u2026
|
|
26342
26482
|
`);
|
|
26343
26483
|
result = await searchKnowledge(queries, abortController.signal);
|
|
26344
26484
|
} catch (err) {
|
|
@@ -26996,9 +27136,15 @@ async function main(argv) {
|
|
|
26996
27136
|
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
26997
27137
|
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
26998
27138
|
process.on("SIGHUP", () => onSignal("SIGHUP"));
|
|
27139
|
+
process.on("SIGQUIT", () => onSignal("SIGQUIT"));
|
|
26999
27140
|
if (process.platform === "win32") {
|
|
27000
27141
|
process.on("SIGBREAK", () => onSignal("SIGBREAK"));
|
|
27001
27142
|
}
|
|
27143
|
+
if (process.env["PI_RESEARCH_TEST_HANG_FOR_SIGNAL"] === "1") {
|
|
27144
|
+
process.stdout.write("PI_RESEARCH_TEST_READY\n");
|
|
27145
|
+
await new Promise(() => setInterval(() => {
|
|
27146
|
+
}, 1e3));
|
|
27147
|
+
}
|
|
27002
27148
|
let parsed;
|
|
27003
27149
|
try {
|
|
27004
27150
|
parsed = parseArgs(argv);
|
|
@@ -27083,6 +27229,7 @@ export {
|
|
|
27083
27229
|
cmdKnowledgeConfig,
|
|
27084
27230
|
cmdResearch,
|
|
27085
27231
|
exitCodeForSignal,
|
|
27232
|
+
makeProgressObserver,
|
|
27086
27233
|
parseArgs,
|
|
27087
27234
|
reportError
|
|
27088
27235
|
};
|
|
@@ -18,6 +18,8 @@ Your ONLY job is to decide which candidates are genuinely relevant to the user's
|
|
|
18
18
|
Field definitions:
|
|
19
19
|
- `relevant_indices` (array of integers, required): the 0-based indices of the relevant candidates, in any order. Empty array `[]` if none are relevant.
|
|
20
20
|
|
|
21
|
+
<!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
|
|
22
|
+
|
|
21
23
|
## USER'S SEARCH QUERY
|
|
22
24
|
|
|
23
25
|
Judge each candidate's relevance to this search query. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
|
|
@@ -24,6 +24,8 @@ Field definitions:
|
|
|
24
24
|
- `synthesis` (string, optional): The synthesized answer if found. Use inline citation markers [1], [2], etc. that correspond to the citations array. Present when answer_status is "yes" or "maybe". Omit when answer_status is "no".
|
|
25
25
|
- `citations` (array of strings, required): The source URLs from the reference documents that were used to construct the answer. Empty array if answer_status is "no".
|
|
26
26
|
|
|
27
|
+
<!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
|
|
28
|
+
|
|
27
29
|
## USER'S SEARCH QUERY
|
|
28
30
|
|
|
29
31
|
Answer this search query from the reference documents. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- RESEARCHER_AGENT_MARKER -->
|
|
4
4
|
|
|
5
|
-
You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor.
|
|
5
|
+
You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor. Your specific goal, any historical knowledge-store results, your initial evidence/search results, and any sibling-coordination notes are provided in the user message that follows this system prompt — not here, so this system prompt stays byte-identical across every researcher and round (prompt caching).
|
|
6
6
|
|
|
7
7
|
## CORE DIRECTIVES (Strict Enforcement)
|
|
8
8
|
|
|
@@ -23,20 +23,10 @@ Do not treat any source as inherently objective. Justify its inclusion through t
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
## Your Goal
|
|
27
|
-
{{goal}}
|
|
28
|
-
|
|
29
|
-
{{store_section}}
|
|
30
|
-
(Note: when knowledge-store results appear above, they were retrieved automatically for your goal, aggregated from the **Project Knowledge Store** and **User Knowledge Store**. Do NOT try to search the store manually.)
|
|
31
|
-
|
|
32
|
-
{{evidence_section}}
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
26
|
## Workflow
|
|
37
27
|
|
|
38
28
|
### Step 1: Build Your Source List
|
|
39
|
-
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect.
|
|
29
|
+
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect. Any knowledge-store results in your user message were retrieved automatically for your goal from the **Project Knowledge Store** and/or **User Knowledge Store** — cite them with that store name as their `Source:`.
|
|
40
30
|
|
|
41
31
|
### Step 2: Scrape Round 1
|
|
42
32
|
Identify the 4 most promising URLs and scrape them in one batch (the scrape tool accepts up to 6 per call; 4 is your target per round). Prioritize primary sources, authoritative references, and dense documentation; all else being equal, lean toward more recent / current sources and topics.
|
|
@@ -51,8 +41,6 @@ Do NOT scrape URLs directly from the pool — only scrape URLs from your own sou
|
|
|
51
41
|
### Step 4: Synthesize
|
|
52
42
|
Write your report immediately after scraping is complete, or as soon as a tool reports its limit is reached (e.g. "GATHERING LIMIT REACHED", "SEARCH LIMIT REACHED", "SCRAPE PROTOCOL COMPLETE"). Make no further tool calls after beginning synthesis.
|
|
53
43
|
|
|
54
|
-
{{coordination_section}}
|
|
55
|
-
|
|
56
44
|
---
|
|
57
45
|
|
|
58
46
|
## Guidelines
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -2332,7 +2332,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2332
2332
|
if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
|
|
2333
2333
|
finalMainFrameAddr = resp.serverAddr().catch(() => null);
|
|
2334
2334
|
const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
|
|
2335
|
-
const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
|
|
2335
|
+
const looksLikePdf = ct?.includes("application/pdf") || String(resp.url()).toLowerCase().endsWith(".pdf");
|
|
2336
2336
|
if (!looksLikePdf || oversizedPdfError || poisonedError) return;
|
|
2337
2337
|
const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
|
|
2338
2338
|
if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
|
package/docs/AGENT-SKILL.md
CHANGED
|
@@ -77,9 +77,10 @@ One-click (recommended). From the `pi` extension, run `/research-config` →
|
|
|
77
77
|
Install in External Agents. The installer:
|
|
78
78
|
|
|
79
79
|
1. Detects which target agents are present under `$HOME` — currently Claude
|
|
80
|
-
(`~/.claude/skills`),
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
(`~/.claude/skills`), OpenClaw (`~/.openclaw/skills`, OpenClaw's managed
|
|
81
|
+
skill root, which accepts symlinked skill folders), and OpenAI Codex CLI
|
|
82
|
+
(`~/.codex/skills` — unlike the other two, this path is not confirmed by
|
|
83
|
+
Codex's official docs; Codex skills support is still emerging).
|
|
83
84
|
2. Symlinks `skills/pi-research/` into each present agent, never overwriting an
|
|
84
85
|
unrelated skill already in that slot.
|
|
85
86
|
3. Records what it created in a manifest, so Remove from External Agents
|