@lincoln504/pi-research 1.4.1 → 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 +449 -187
- 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 +91 -59
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +120 -0
- package/docs/CONFIGURATION.md +2 -2
- 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 +36 -13
- package/src/constants.ts +23 -4
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +67 -9
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
- package/src/infrastructure/browser/config.ts +40 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
- package/src/infrastructure/browser/thread-worker-messaging.ts +65 -4
- package/src/infrastructure/browser/thread-worker.mjs +91 -59
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/embedding/embedding-server.ts +54 -0
- package/src/knowledge/embedder-init.ts +3 -2
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +85 -5
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/deep-research-orchestrator.ts +3 -1
- 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 +35 -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 +16 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +37 -4
- package/src/tools/research-tool-definition.ts +54 -12
- package/src/tools/scrape.ts +8 -1
- package/src/tools/security.ts +13 -10
- package/src/utils/coverage-digest.ts +1 -1
- package/src/utils/host-config.ts +12 -1
- package/src/utils/log-utils.ts +15 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/browser-search.ts +11 -4
- package/src/web-research/web-scraper.ts +36 -16
- package/src/youtube/index.ts +11 -1
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
|
@@ -21,7 +21,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
|
|
|
21
21
|
import type { Config } from '../config.ts';
|
|
22
22
|
import { logger } from '../logger.ts';
|
|
23
23
|
|
|
24
|
-
import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
|
|
24
|
+
import { resolveResearchModel, isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
|
|
25
25
|
import { getRuntimeForRegistry } from '../core/llm/model-registry-factory.ts';
|
|
26
26
|
import { boundSessionAbort } from './abort-utils.ts';
|
|
27
27
|
|
|
@@ -86,7 +86,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
|
|
|
86
86
|
logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
|
|
87
87
|
|
|
88
88
|
try {
|
|
89
|
-
const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
|
|
89
|
+
const tracker = new ToolUsageTracker(createDefaultToolLimits(config, isPromptCachingActiveForModel(modelToUse)));
|
|
90
90
|
|
|
91
91
|
// Use provided closures or fallback to safe dummies
|
|
92
92
|
const globalLinks = updateGlobalLinks || (() => {});
|
|
@@ -518,8 +518,22 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
518
518
|
|
|
519
519
|
// If this was the last research run in the Pi session, clean up the state
|
|
520
520
|
// but preserve steering messages if any remain (they might arrive between
|
|
521
|
-
// the last research end and the next research start)
|
|
522
|
-
|
|
521
|
+
// the last research end and the next research start).
|
|
522
|
+
//
|
|
523
|
+
// `state.aborts` is included alongside `order`/`panels`: those two are
|
|
524
|
+
// populated ONLY by the TUI path (registerSessionPanel), so for a purely
|
|
525
|
+
// headless multi-run session they are always empty — including while
|
|
526
|
+
// OTHER headless runs are still genuinely active. Gating on order/panels
|
|
527
|
+
// alone made this block treat every single headless run's end as "the
|
|
528
|
+
// last one," which both cleared `aborts` for still-running siblings
|
|
529
|
+
// (corrupting getActiveResearchRunCount for any caller relying on it) and,
|
|
530
|
+
// combined with a caller that clears steering right after seeing count 0,
|
|
531
|
+
// could wipe a message this very block had just decided to preserve one
|
|
532
|
+
// statement earlier. `aborts.delete(researchId)` above already removed
|
|
533
|
+
// this run's own entry, so `aborts.size === 0` here means no run of any
|
|
534
|
+
// kind (TUI or headless) remains — the same atomic, single-synchronous-
|
|
535
|
+
// function guarantee `order`/`panels` already had for the TUI-only case.
|
|
536
|
+
if (state.order.length === 0 && state.panels.size === 0 && state.aborts.size === 0) {
|
|
523
537
|
// Drop steering messages this run already consumed ('active') — and any
|
|
524
538
|
// 'popped' remnants, which are likewise already resolved — before deciding
|
|
525
539
|
// whether to keep the session alive. Without this, a message the run that
|
|
@@ -540,10 +554,11 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
540
554
|
clearPendingRefresh(sid);
|
|
541
555
|
piSessions.delete(sid);
|
|
542
556
|
} else {
|
|
543
|
-
// Clear research-specific state but preserve the session and steering
|
|
557
|
+
// Clear research-specific state but preserve the session and steering.
|
|
558
|
+
// aborts is already empty (the outer gate above requires it), so no
|
|
559
|
+
// separate clear is needed for it here.
|
|
544
560
|
state.failures.clear();
|
|
545
561
|
state.failureReasons.clear();
|
|
546
|
-
state.aborts.clear();
|
|
547
562
|
}
|
|
548
563
|
}
|
|
549
564
|
}
|
|
@@ -703,6 +718,22 @@ export function getPiActivePanels(piSessionId: string | undefined): ResearchPane
|
|
|
703
718
|
return [...state.order].reverse().map(id => state.panels.get(id)!).filter(Boolean);
|
|
704
719
|
}
|
|
705
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Count of research runs currently active in a Pi session, TUI or headless.
|
|
723
|
+
*
|
|
724
|
+
* Unlike getPiActivePanels (populated only by registerSessionPanel, which the
|
|
725
|
+
* TUI path alone calls), `aborts` is registered by registerSessionAbort on
|
|
726
|
+
* EVERY run — headless included — and removed only by endResearchSession. Use
|
|
727
|
+
* this wherever "am I the last/only active run in this session" must hold for
|
|
728
|
+
* headless callers too (e.g. gating a shared-state clear like steering
|
|
729
|
+
* messages so a finishing run doesn't wipe a concurrent sibling's).
|
|
730
|
+
*/
|
|
731
|
+
export function getActiveResearchRunCount(piSessionId: string | undefined): number {
|
|
732
|
+
const sid = normalizeSessionId(piSessionId);
|
|
733
|
+
const state = piSessions.get(sid);
|
|
734
|
+
return state ? state.aborts.size : 0;
|
|
735
|
+
}
|
|
736
|
+
|
|
706
737
|
/**
|
|
707
738
|
* Get the ordered list of active research run IDs in a Pi session.
|
|
708
739
|
* Returns IDs in chronological order (oldest first, newest last).
|
|
@@ -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/src/sdk.ts
CHANGED
|
@@ -111,6 +111,7 @@ let _onSignal: ((signal: string) => void) | null = null;
|
|
|
111
111
|
let _sigintHandler: (() => void) | null = null;
|
|
112
112
|
let _sigtermHandler: (() => void) | null = null;
|
|
113
113
|
let _sighupHandler: (() => void) | null = null;
|
|
114
|
+
let _sigquitHandler: (() => void) | null = null;
|
|
114
115
|
let _sigbreakHandler: (() => void) | null = null;
|
|
115
116
|
let _shuttingDown = false;
|
|
116
117
|
// In-flight shutdown guard. A cooperative shutdownResearchSDK() can race a
|
|
@@ -147,9 +148,13 @@ function _registerSignalHandlers(): void {
|
|
|
147
148
|
_sigintHandler = () => _onSignal?.('SIGINT');
|
|
148
149
|
_sigtermHandler = () => _onSignal?.('SIGTERM');
|
|
149
150
|
_sighupHandler = () => _onSignal?.('SIGHUP');
|
|
151
|
+
_sigquitHandler = () => _onSignal?.('SIGQUIT');
|
|
150
152
|
process.on('SIGINT', _sigintHandler);
|
|
151
153
|
process.on('SIGTERM', _sigtermHandler);
|
|
152
154
|
process.on('SIGHUP', _sighupHandler);
|
|
155
|
+
// SIGQUIT has no Windows equivalent; registering the listener there is a
|
|
156
|
+
// documented no-op (Node never delivers it), so no platform guard is needed.
|
|
157
|
+
process.on('SIGQUIT', _sigquitHandler);
|
|
153
158
|
if (process.platform === 'win32') {
|
|
154
159
|
_sigbreakHandler = () => _onSignal?.('SIGBREAK');
|
|
155
160
|
process.on('SIGBREAK', _sigbreakHandler);
|
|
@@ -160,6 +165,7 @@ function _removeSignalHandlers(): void {
|
|
|
160
165
|
if (_sigintHandler) { process.removeListener('SIGINT', _sigintHandler); _sigintHandler = null; }
|
|
161
166
|
if (_sigtermHandler) { process.removeListener('SIGTERM', _sigtermHandler); _sigtermHandler = null; }
|
|
162
167
|
if (_sighupHandler) { process.removeListener('SIGHUP', _sighupHandler); _sighupHandler = null; }
|
|
168
|
+
if (_sigquitHandler) { process.removeListener('SIGQUIT', _sigquitHandler); _sigquitHandler = null; }
|
|
163
169
|
if (_sigbreakHandler) { process.removeListener('SIGBREAK', _sigbreakHandler); _sigbreakHandler = null; }
|
|
164
170
|
_onSignal = null;
|
|
165
171
|
}
|
|
@@ -369,6 +375,16 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
369
375
|
* Public initialization. Guarantees the SDK is ready for use.
|
|
370
376
|
*/
|
|
371
377
|
export async function initResearchSDK(options: ResearchSDKOptions = {}): Promise<void> {
|
|
378
|
+
// A shutdown may be in flight: isInitialized only flips to false near the END
|
|
379
|
+
// of _doShutdown (after container disposal), so without this a caller racing a
|
|
380
|
+
// shutdown would hit the isInitialized branch below, log its "already
|
|
381
|
+
// initialized" warning, and return believing the SDK is ready — while the
|
|
382
|
+
// container it would use is mid-teardown or about to be nulled out from under
|
|
383
|
+
// it. Wait the shutdown out first so this call observes the real post-shutdown
|
|
384
|
+
// state, mirroring shutdownResearchSDK's symmetric wait on _initPromise.
|
|
385
|
+
if (_shutdownPromise) {
|
|
386
|
+
await _shutdownPromise.catch(() => { /* a failed shutdown must not block a fresh init */ });
|
|
387
|
+
}
|
|
372
388
|
if (isInitialized) {
|
|
373
389
|
logger.warn('[SDK] Research SDK already initialized. Call shutdownResearchSDK() first if you want to re-initialize with new options.');
|
|
374
390
|
return;
|
|
@@ -480,7 +480,11 @@ export function uninstallSkill(toolIds?: string[], opts: InstallOptions = {}): U
|
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
for (const { tool, path: skillPath } of targets.values()) {
|
|
483
|
-
|
|
483
|
+
// undefined = every manifest entry; an explicit (including empty) list means
|
|
484
|
+
// exactly those tools. `toolIds.length` used to gate this, so `[]` was
|
|
485
|
+
// indistinguishable from `undefined` and silently uninstalled everything —
|
|
486
|
+
// inconsistent with this file's other list params, where empty means none.
|
|
487
|
+
if (toolIds !== undefined && !toolIds.includes(tool)) {
|
|
484
488
|
const e = manifest.entries.find(x => x.path === skillPath);
|
|
485
489
|
if (e) keep.push(e);
|
|
486
490
|
continue;
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from './output/compact.ts';
|
|
28
28
|
import type { ExtensionContext, AgentToolResult } from '@earendil-works/pi-coding-agent';
|
|
29
29
|
import { logger } from '../logger.ts';
|
|
30
|
+
import { metrics } from '../utils/metrics.ts';
|
|
30
31
|
|
|
31
32
|
function notify(
|
|
32
33
|
ctx: ExtensionContext,
|
|
@@ -86,6 +87,7 @@ export async function stackexchangeCommand(options: {
|
|
|
86
87
|
try {
|
|
87
88
|
// Check quota before making requests
|
|
88
89
|
if (client.isQuotaExhausted()) {
|
|
90
|
+
metrics.increment('stackexchange_quota_exhausted_total', 1);
|
|
89
91
|
const quota = client.getQuotaInfo();
|
|
90
92
|
notify(
|
|
91
93
|
ctx,
|
|
@@ -103,6 +105,7 @@ export async function stackexchangeCommand(options: {
|
|
|
103
105
|
|
|
104
106
|
// Warn if quota is low
|
|
105
107
|
if (client.isQuotaLow()) {
|
|
108
|
+
metrics.increment('stackexchange_quota_low_total', 1);
|
|
106
109
|
const quota = client.getQuotaInfo();
|
|
107
110
|
notify(
|
|
108
111
|
ctx,
|
|
@@ -193,24 +193,19 @@ export class StackExchangeClient {
|
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// Pure accessor — no metrics side effects. `stackexchangeCommand()` calls this
|
|
197
|
+
// repeatedly per invocation (pre-request gate checks, success footer, error
|
|
198
|
+
// footer) purely to read current state for display; incrementing counters here
|
|
199
|
+
// used to record 2-3x the real number of quota-low/exhausted events per call.
|
|
200
|
+
// The counters are incremented once, at the single gate check that actually
|
|
201
|
+
// observes each event (see stackexchange/index.ts).
|
|
196
202
|
getQuotaInfo(): { remaining: number; max: number; requestCount: number; lastBackoff: number | null } {
|
|
197
|
-
|
|
203
|
+
return {
|
|
198
204
|
remaining: this.quotaRemaining,
|
|
199
205
|
max: this.quotaMax,
|
|
200
206
|
requestCount: this.requestCount,
|
|
201
207
|
lastBackoff: this.lastBackoff,
|
|
202
208
|
};
|
|
203
|
-
|
|
204
|
-
// Track quota exhaustion events
|
|
205
|
-
if (this.isQuotaExhausted()) {
|
|
206
|
-
metrics.increment('stackexchange_quota_exhausted_total', 1);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (this.isQuotaLow()) {
|
|
210
|
-
metrics.increment('stackexchange_quota_low_total', 1);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return info;
|
|
214
209
|
}
|
|
215
210
|
|
|
216
211
|
isQuotaExhausted(): boolean {
|
package/src/tools/index.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* NOTE: `research_knowledge_search` is NOT included here. It is a top-level tool
|
|
8
8
|
* registered via pi.registerTool() in src/index.ts. Researcher sub-agents
|
|
9
|
-
* already receive knowledge
|
|
9
|
+
* already receive knowledge-store data in their initial user message (see
|
|
10
|
+
* researcher-executor.ts / quick-research-orchestrator.ts).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { ToolDefinition, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -427,6 +427,27 @@ export function isTransientSynthesisError(message: string): boolean {
|
|
|
427
427
|
);
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Split a knowledge-search prompt template at its USER_TURN marker into the
|
|
432
|
+
* static system-prompt part (the rules, byte-identical across calls so the
|
|
433
|
+
* provider can serve it from its prompt cache) and the per-call user-message
|
|
434
|
+
* part (where the {{...}} placeholders live). If the marker is absent —
|
|
435
|
+
* a shipped-file regression — returns the whole template as `system` and
|
|
436
|
+
* `user: null`, and the caller falls back to the legacy all-in-system shape,
|
|
437
|
+
* which is functionally correct (just uncacheable).
|
|
438
|
+
* Exported for unit testing.
|
|
439
|
+
*/
|
|
440
|
+
export function splitPromptAtUserTurn(template: string): { system: string; user: string | null } {
|
|
441
|
+
const open = template.indexOf('<!-- USER_TURN');
|
|
442
|
+
if (open < 0) return { system: template, user: null };
|
|
443
|
+
const close = template.indexOf('-->', open);
|
|
444
|
+
if (close < 0) return { system: template, user: null };
|
|
445
|
+
return {
|
|
446
|
+
system: template.slice(0, open).trimEnd(),
|
|
447
|
+
user: template.slice(close + '-->'.length).trimStart(),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
430
451
|
/** Exported for unit testing of the transient-retry loop. */
|
|
431
452
|
export async function runBackgroundExtraction(
|
|
432
453
|
model: Model<any>,
|
|
@@ -452,13 +473,20 @@ export async function runBackgroundExtraction(
|
|
|
452
473
|
// plain string replacement interprets `$&`, `` $` ``, `$'`, `$$` as substitution patterns and
|
|
453
474
|
// corrupts the extractor prompt (duplicating/eating template text) → wrong yes/maybe/no
|
|
454
475
|
// classification. A function replacer inserts the value literally. (Same class as commit 78c16f98.)
|
|
455
|
-
|
|
476
|
+
//
|
|
477
|
+
// The template splits at USER_TURN: the static rules become the system prompt
|
|
478
|
+
// (byte-identical across calls → prompt-cache reads instead of a fresh cache
|
|
479
|
+
// write per call) and the per-call sections go in the user message.
|
|
480
|
+
const { system, user } = splitPromptAtUserTurn(promptTemplate);
|
|
481
|
+
const fill = (t: string): string => t
|
|
456
482
|
.replace('{{queries}}', () => formatQueriesBlock(queries))
|
|
457
483
|
.replace('{{conversation_history}}', () => conversationHistory)
|
|
458
484
|
.replace('{{reference_documents}}', () => referenceDocuments);
|
|
459
485
|
|
|
460
|
-
const
|
|
486
|
+
const finalInstruction =
|
|
461
487
|
'Analyze the reference documents above and extract the answer using the required JSON format.';
|
|
488
|
+
const systemPrompt = user === null ? fill(system) : system;
|
|
489
|
+
const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
|
|
462
490
|
|
|
463
491
|
// Phase 4a: Stateless LLM call — no AgentSession, no side-effects.
|
|
464
492
|
// llmTimeout is resolved by the caller from the iface-aware Config so a
|
|
@@ -611,12 +639,17 @@ export async function triageRelevantUrls(
|
|
|
611
639
|
|
|
612
640
|
// FUNCTION replacers so literal `$`/`$&` in queries, descriptions or history are not
|
|
613
641
|
// treated as regex substitution patterns (same class as the synthesis prompt).
|
|
614
|
-
|
|
642
|
+
// Split at USER_TURN like the extractor: static rules stay in the system prompt
|
|
643
|
+
// (cacheable across calls), per-call sections go in the user message.
|
|
644
|
+
const { system, user } = splitPromptAtUserTurn(promptTemplate);
|
|
645
|
+
const fill = (t: string): string => t
|
|
615
646
|
.replace('{{queries}}', () => formatQueriesBlock(queries))
|
|
616
647
|
.replace('{{conversation_history}}', () => conversationHistory)
|
|
617
648
|
.replace('{{candidates}}', () => candidateBlock);
|
|
618
649
|
|
|
619
|
-
const
|
|
650
|
+
const finalInstruction = 'Return the JSON object listing the indices of the relevant candidates.';
|
|
651
|
+
const systemPrompt = user === null ? fill(system) : system;
|
|
652
|
+
const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
|
|
620
653
|
|
|
621
654
|
let responseText: string;
|
|
622
655
|
let attempt = 0;
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
import type { ExtendedExtensionContext } from '../types/extension-context.ts';
|
|
18
18
|
import type { ResearchDepth, CleanupContext } from '../types/index.ts';
|
|
19
19
|
import { Type, type Static } from 'typebox';
|
|
20
|
+
import { Value } from 'typebox/value';
|
|
20
21
|
import { validateConfig, getConfig, type ConfigInterface } from '../config.ts';
|
|
21
22
|
import { tryGetServiceContainerFromCtx, getService } from '../core/service-registry.ts';
|
|
22
23
|
|
|
@@ -28,11 +29,12 @@ import { redactSecrets } from '../utils/log-utils.ts';
|
|
|
28
29
|
import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
|
|
29
30
|
import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
|
|
30
31
|
import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
|
|
31
|
-
import { startResearchSession, endResearchSession, registerSessionAbort
|
|
32
|
+
import { startResearchSession, endResearchSession, registerSessionAbort } from '../orchestration/session-state.ts';
|
|
32
33
|
import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
|
|
33
34
|
import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
|
|
34
35
|
import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
|
|
35
36
|
import { HeadlessObserver } from '../orchestration/headless-observer.ts';
|
|
37
|
+
import { isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
|
|
36
38
|
|
|
37
39
|
import { ensureFunctionalHealth, createHealthMonitor } from '../tui/research-health.ts';
|
|
38
40
|
import { ErrorTracker, runWithTracker, type ErrorReport } from '../utils/error-tracker.ts';
|
|
@@ -118,6 +120,21 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
118
120
|
|
|
119
121
|
type ResearchParams = Static<typeof parameters>;
|
|
120
122
|
|
|
123
|
+
// Same field shapes as `parameters`, but WITHOUT the initialLinks count/length
|
|
124
|
+
// bounds — used only for the runtime execute() gate below. validateInitialLinks
|
|
125
|
+
// already enforces those same two bounds with a specific, friendly message
|
|
126
|
+
// ("at most N URLs...", "at most N characters..."); checking them again here
|
|
127
|
+
// would just produce the generic 'invalid_parameters' rejection first and mask
|
|
128
|
+
// that better message. Structural type-checking (initialLinks must still be an
|
|
129
|
+
// array of strings) is kept, since validateInitialLinks assumes that shape.
|
|
130
|
+
const executeShapeCheck = Type.Object({
|
|
131
|
+
query: Type.String(),
|
|
132
|
+
depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 3 })),
|
|
133
|
+
model: Type.Optional(Type.String()),
|
|
134
|
+
excludeTools: Type.Optional(Type.Array(Type.String())),
|
|
135
|
+
initialLinks: Type.Optional(Type.Array(Type.String())),
|
|
136
|
+
});
|
|
137
|
+
|
|
121
138
|
return {
|
|
122
139
|
name: 'research',
|
|
123
140
|
label: 'Research',
|
|
@@ -169,6 +186,17 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
169
186
|
};
|
|
170
187
|
}
|
|
171
188
|
|
|
189
|
+
// Every other tool in this codebase re-validates params against its own
|
|
190
|
+
// schema before trusting it (the host does not enforce this) — this was the
|
|
191
|
+
// one exception, relying entirely on incidental downstream checks (a
|
|
192
|
+
// typeof on query, a warn-gate on excludeTools) for fields that skip both.
|
|
193
|
+
if (!Value.Check(executeShapeCheck, params)) {
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: 'text', text: 'Error: invalid parameters for research tool.' }],
|
|
196
|
+
details: { error: 'invalid_parameters' },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
172
200
|
const { query, depth: rawDepth, model: modelId, excludeTools: paramExcludeTools, initialLinks } = params as ResearchParams;
|
|
173
201
|
|
|
174
202
|
// Enforce the same bounds the CLI applies to --initial-links: these links are
|
|
@@ -225,17 +253,21 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
225
253
|
if (observerState && panelState) {
|
|
226
254
|
try { stopObserverWaveAnimation(observerState, panelState); } catch (e) { logger.error('[research] Stop wave animation failed:', e); }
|
|
227
255
|
}
|
|
228
|
-
// Steering messages are shared per pi-session. Only reset them when THIS is
|
|
229
|
-
// the last active run — otherwise a finishing run wipes a concurrent sibling
|
|
230
|
-
// run's queued/active steering mid-flight. This run's panel is still
|
|
231
|
-
// registered here (endResearchSession runs later in cleanup()), so a count
|
|
232
|
-
// of <= 1 means no other run is active.
|
|
233
|
-
try {
|
|
234
|
-
if (getPiActivePanels(piSessionId).length <= 1) clearSteeringMessages(piSessionId);
|
|
235
|
-
} catch (e) { logger.error('[research] Clear steering messages failed:', e); }
|
|
236
256
|
if (detachAbortListener) {
|
|
237
257
|
try { detachAbortListener(); } catch { /* best-effort */ } finally { detachAbortListener = undefined; }
|
|
238
258
|
}
|
|
259
|
+
// cleanup() calls endResearchSession (directly in the headless closure,
|
|
260
|
+
// or via createCleanupFunction for TUI), which deregisters this run and,
|
|
261
|
+
// in the same synchronous call, decides whether it was the last active
|
|
262
|
+
// run (TUI or headless) in the pi-session and — only then — resets
|
|
263
|
+
// steering messages (preserving any still-genuinely-queued ones for the
|
|
264
|
+
// next run, dropping only ones this run already consumed). That single
|
|
265
|
+
// synchronous function is the right place for this decision: a separate
|
|
266
|
+
// check-then-clear here, even ordered after cleanup(), raced ahead of or
|
|
267
|
+
// fought with endResearchSession's own last-run/preserve logic — either
|
|
268
|
+
// reading stale counts before two racing siblings had both deregistered,
|
|
269
|
+
// or unconditionally wiping a message endResearchSession had just decided
|
|
270
|
+
// to preserve. See endResearchSession in session-state.ts.
|
|
239
271
|
if (cleanup) {
|
|
240
272
|
try { await cleanup(); } catch (cleanupError) { logger.error('[research] Cleanup failed:', cleanupError); }
|
|
241
273
|
}
|
|
@@ -328,7 +360,16 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
328
360
|
// Setup TUI observer
|
|
329
361
|
observerState = createObserverState();
|
|
330
362
|
observer = createResearchObserver(
|
|
331
|
-
{
|
|
363
|
+
{
|
|
364
|
+
panelState,
|
|
365
|
+
debouncedRefresh: () => tuiManager?.debouncedRefresh(),
|
|
366
|
+
renderImmediate: () => tuiManager?.renderImmediate(),
|
|
367
|
+
researchComplexity: depth ?? 1,
|
|
368
|
+
// Progress units are relative to the EFFECTIVE scrape-batch count,
|
|
369
|
+
// which is one higher when prompt caching is active for the run's
|
|
370
|
+
// resolved model — same value the researchers' trackers enforce.
|
|
371
|
+
cachingActive: isPromptCachingActiveForModel(selectedModel),
|
|
372
|
+
},
|
|
332
373
|
observerState
|
|
333
374
|
);
|
|
334
375
|
} else {
|
|
@@ -344,9 +385,10 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
344
385
|
// module-level piSessions map keyed by the STABLE pi session id, so
|
|
345
386
|
// without this every headless run leaked them for the process
|
|
346
387
|
// lifetime (the TUI branch frees them via createCleanupFunction, the
|
|
347
|
-
// SDK in its own finally
|
|
388
|
+
// SDK in its own finally). endResearchSession itself decides whether
|
|
389
|
+
// this was the last active run (TUI or headless) and, only then,
|
|
390
|
+
// resets steering messages — see its own comment in session-state.ts.
|
|
348
391
|
endResearchSession(piSessionId, sessionResearchId);
|
|
349
|
-
clearSteeringMessages(piSessionId);
|
|
350
392
|
};
|
|
351
393
|
|
|
352
394
|
// Minimal stub — only stopObserverWaveAnimation touches panelState in the
|
package/src/tools/scrape.ts
CHANGED
|
@@ -49,7 +49,14 @@ export function createScrapeTool(options: {
|
|
|
49
49
|
config?: Config;
|
|
50
50
|
}): ToolDefinition {
|
|
51
51
|
const config = options.config || getConfig(options.ctx.cwd);
|
|
52
|
-
|
|
52
|
+
// Prefer the tracker's own scrape limit when one is present: it was built via
|
|
53
|
+
// createDefaultToolLimits(config, cachingActive) and so already reflects any
|
|
54
|
+
// prompt-caching batch bonus (see getMaxScrapeBatches in constants.ts). Without
|
|
55
|
+
// this, the tool's own description/promptSnippet/protocol text (below) told the
|
|
56
|
+
// model a lower, non-caching-aware batch count than the tracker actually
|
|
57
|
+
// enforced — a real, if harmless, missed-opportunity: the model would stop one
|
|
58
|
+
// batch short of what it was actually allowed to use.
|
|
59
|
+
const maxScrapeBatches = options.tracker?.getToolLimit('scrape') ?? getMaxScrapeBatches(config);
|
|
53
60
|
const container = tryGetServiceContainerFromCtx(options.ctx);
|
|
54
61
|
|
|
55
62
|
// Fallback global state when no orchestration context is provided
|
package/src/tools/security.ts
CHANGED
|
@@ -98,16 +98,6 @@ export function createSecuritySearchTool(options: {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// Record call in tracker - returns false if limit reached
|
|
102
|
-
const allowed = options.tracker.recordCall('security_search');
|
|
103
|
-
if (!allowed) {
|
|
104
|
-
metrics.increment('tool_security_search_calls_total', 1, { status: 'rate_limited' });
|
|
105
|
-
return {
|
|
106
|
-
content: [{ type: 'text', text: options.tracker.getLimitMessage('security_search') }],
|
|
107
|
-
details: { blocked: true, reason: 'limit_reached' },
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
101
|
const p = params as SecuritySearchParamsType;
|
|
112
102
|
|
|
113
103
|
const terms = p.terms;
|
|
@@ -160,6 +150,19 @@ export function createSecuritySearchTool(options: {
|
|
|
160
150
|
}
|
|
161
151
|
const maxResults = p.maxResults ?? 20;
|
|
162
152
|
|
|
153
|
+
// Record call in tracker - returns false if limit reached. Deliberately AFTER
|
|
154
|
+
// every semantic-validation reject above (terms/databases/severity), same
|
|
155
|
+
// reasoning as the schema check: a rejected call did no work and must not
|
|
156
|
+
// permanently consume one of the researcher's shared MAX_GATHERING_CALLS slots.
|
|
157
|
+
const allowed = options.tracker.recordCall('security_search');
|
|
158
|
+
if (!allowed) {
|
|
159
|
+
metrics.increment('tool_security_search_calls_total', 1, { status: 'rate_limited' });
|
|
160
|
+
return {
|
|
161
|
+
content: [{ type: 'text', text: options.tracker.getLimitMessage('security_search') }],
|
|
162
|
+
details: { blocked: true, reason: 'limit_reached' },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
163
166
|
metrics.increment('tool_security_search_terms_total', terms.length);
|
|
164
167
|
metrics.increment('tool_security_search_databases_total', databases.length);
|
|
165
168
|
|
|
@@ -109,7 +109,7 @@ export interface SplitReport {
|
|
|
109
109
|
* better than one that reaches it short.
|
|
110
110
|
*/
|
|
111
111
|
export function splitCoverageDigest(report: string): SplitReport {
|
|
112
|
-
if (!report) return { digest: '', body: report
|
|
112
|
+
if (!report) return { digest: '', body: report };
|
|
113
113
|
|
|
114
114
|
const headerMatch = delimiterRegex(COVERAGE_DIGEST_HEADER).exec(report);
|
|
115
115
|
if (!headerMatch) return { digest: '', body: report };
|
package/src/utils/host-config.ts
CHANGED
|
@@ -27,11 +27,22 @@ let _configDirName: string | undefined;
|
|
|
27
27
|
* Resolution order: explicit env override (`PI_RESEARCH_CONFIG_DIR_NAME`) →
|
|
28
28
|
* the host `@earendil-works/pi-coding-agent` package (only where it is actually
|
|
29
29
|
* resolvable, i.e. the main pi process) → the stable default `.pi`.
|
|
30
|
+
*
|
|
31
|
+
* The override is a directory NAME, not a path: it is joined onto
|
|
32
|
+
* `os.homedir()` by every caller (getGlobalConfigDir() in config.ts), which in
|
|
33
|
+
* turn is the base for config.env (API keys, chmod 0600), the state dir
|
|
34
|
+
* (locks, run semaphores), and the knowledge_db. A value containing a path
|
|
35
|
+
* separator or `..` traverses out of HOME entirely — scripts/cleanup.cjs
|
|
36
|
+
* already rejects exactly that in its own copy of this resolution (see its
|
|
37
|
+
* `rawDirName`/`dirName` guard); this must reject it the same way, since this
|
|
38
|
+
* is the copy every actual run resolves secrets and state through.
|
|
30
39
|
*/
|
|
31
40
|
export function getConfigDirName(): string {
|
|
32
41
|
if (_configDirName !== undefined) return _configDirName;
|
|
33
42
|
const override = process.env['PI_RESEARCH_CONFIG_DIR_NAME'];
|
|
34
|
-
if (override)
|
|
43
|
+
if (override && !override.includes('/') && !override.includes('\\') && override !== '.' && override !== '..') {
|
|
44
|
+
return (_configDirName = override);
|
|
45
|
+
}
|
|
35
46
|
try {
|
|
36
47
|
const require = createRequire(import.meta.url);
|
|
37
48
|
const host = require('@earendil-works/pi-coding-agent') as { CONFIG_DIR_NAME?: string };
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -37,8 +37,11 @@ export function buildDefaultDebugLogPath(): string {
|
|
|
37
37
|
/**
|
|
38
38
|
* Check whether debug/verbose logging is enabled.
|
|
39
39
|
*
|
|
40
|
-
* Reads PI_RESEARCH_DEBUG env var.
|
|
41
|
-
* config.
|
|
40
|
+
* Reads PI_RESEARCH_DEBUG env var. Config SAVES sync config.DEBUG into this
|
|
41
|
+
* env var (see config.ts buildEnvValues); plain config LOADS do not mutate
|
|
42
|
+
* process.env, so a DEBUG=true line in config.env enables verbose logging for
|
|
43
|
+
* a process only after a save has run in it — export PI_RESEARCH_DEBUG=true
|
|
44
|
+
* in the environment to guarantee it from process start.
|
|
42
45
|
* Set to "true" to enable INFO+DEBUG logs.
|
|
43
46
|
*/
|
|
44
47
|
export function isVerboseFromEnv(): boolean {
|
|
@@ -255,6 +258,16 @@ export function redactSecrets(message: string): string {
|
|
|
255
258
|
// never negative; it still prefers the larger rawLength when the
|
|
256
259
|
// REDACT_SCAN_LENGTH cut discarded raw content redaction then shrank.
|
|
257
260
|
out = `${out.slice(0, keep)}…[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
|
|
261
|
+
} else if (rawLength > scanEnd) {
|
|
262
|
+
// The scanEnd cut (line 220) already dropped `rawLength - scanEnd` raw
|
|
263
|
+
// chars before redaction ever ran. If redaction then net-shrinks the
|
|
264
|
+
// scanned prefix enough that `out.length` no longer exceeds
|
|
265
|
+
// MAX_LOG_MESSAGE_LENGTH (e.g. many long hex/JWT runs each collapsed to
|
|
266
|
+
// the short literal '[REDACTED]'), the branch above never fires and this
|
|
267
|
+
// message would otherwise read as complete — silently hiding the tail of
|
|
268
|
+
// a large scraped page or provider error body with no indication
|
|
269
|
+
// anything was cut.
|
|
270
|
+
out = `${out}…[truncated ${rawLength - scanEnd} chars]`;
|
|
258
271
|
}
|
|
259
272
|
return out;
|
|
260
273
|
}
|
|
@@ -48,6 +48,12 @@ export interface ResearchStats {
|
|
|
48
48
|
errors: number;
|
|
49
49
|
/** Total LLM tokens consumed. */
|
|
50
50
|
tokens: number;
|
|
51
|
+
/** Prompt-cache READ tokens (prompt prefix served from the provider's cache at the
|
|
52
|
+
* discounted rate). 0 when the provider reported none or does not report caching. */
|
|
53
|
+
cacheReadTokens: number;
|
|
54
|
+
/** Prompt-cache WRITE tokens (prefix stored into the provider's cache, billed at a
|
|
55
|
+
* premium). Reads pinned at 0 while writes climb = a caching regression. */
|
|
56
|
+
cacheWriteTokens: number;
|
|
51
57
|
/** Total cost in USD across all LLM calls. */
|
|
52
58
|
cost: number;
|
|
53
59
|
/** Tool usage counts. */
|
|
@@ -193,6 +199,12 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
|
|
|
193
199
|
// Tokens — sum all label variants
|
|
194
200
|
const tokens = sumCounter(counters, 'llm_tokens_total');
|
|
195
201
|
|
|
202
|
+
// Prompt-cache split (recordLlmUsage emits these only when the provider reports
|
|
203
|
+
// the fields, so 0 here covers both "cache off" and "not reported" — the raw
|
|
204
|
+
// counters keep those distinguishable for anyone reading the snapshot directly).
|
|
205
|
+
const cacheReadTokens = sumCounter(counters, 'llm_cache_read_tokens_total');
|
|
206
|
+
const cacheWriteTokens = sumCounter(counters, 'llm_cache_write_tokens_total');
|
|
207
|
+
|
|
196
208
|
// Cost — sum all label variants
|
|
197
209
|
const cost = sumCounter(counters, 'llm_cost_total');
|
|
198
210
|
|
|
@@ -251,6 +263,8 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
|
|
|
251
263
|
urlsFailed,
|
|
252
264
|
errors,
|
|
253
265
|
tokens,
|
|
266
|
+
cacheReadTokens,
|
|
267
|
+
cacheWriteTokens,
|
|
254
268
|
cost,
|
|
255
269
|
toolUsage,
|
|
256
270
|
};
|
|
@@ -315,7 +329,13 @@ export function buildResearchSummary(stats: ResearchStats): string {
|
|
|
315
329
|
// --- Resource line: tokens, cost, duration ---
|
|
316
330
|
const resourceParts: string[] = [];
|
|
317
331
|
if (stats.tokens > 0) {
|
|
318
|
-
|
|
332
|
+
// When prompt caching did real work, say so inline: cache reads are prompt
|
|
333
|
+
// tokens billed at the provider's discounted cached rate, and this is the
|
|
334
|
+
// only user-visible confirmation that caching is active for their model.
|
|
335
|
+
const cached = stats.cacheReadTokens > 0
|
|
336
|
+
? ` (**${formatTokens(stats.cacheReadTokens)}** from cache)`
|
|
337
|
+
: '';
|
|
338
|
+
resourceParts.push(`**${formatTokens(stats.tokens)}** tokens${cached}`);
|
|
319
339
|
}
|
|
320
340
|
// Render the cost whenever tokens were actually billed-through, INCLUDING when it
|
|
321
341
|
// is exactly zero. Gating on `cost > 0` hid a real defect for weeks: a model whose
|