@lincoln504/pi-research 1.4.3 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.mjs +396 -125
- 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 +17 -4
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +131 -2
- package/docs/CONFIGURATION.md +2 -2
- package/docs/SDK.md +1 -1
- package/package.json +3 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +31 -9
- package/src/constants.ts +23 -4
- package/src/core/interfaces/state-manager-interfaces.ts +8 -0
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +39 -4
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +20 -3
- package/src/infrastructure/browser/thread-worker.mjs +17 -4
- package/src/infrastructure/embedding/embedding-factory.ts +34 -0
- package/src/infrastructure/embedding/embedding-server.ts +34 -9
- package/src/infrastructure/state/state-manager.ts +15 -0
- package/src/knowledge/embedder.ts +36 -7
- package/src/knowledge/store-operations.ts +47 -38
- package/src/knowledge/store.ts +86 -8
- package/src/knowledge/writer-queue.ts +4 -2
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/deep-research-orchestrator.ts +15 -3
- package/src/orchestration/quick-research-orchestrator.ts +37 -11
- package/src/orchestration/research-orchestration-service.ts +21 -2
- package/src/orchestration/research-synthesis-service.ts +36 -0
- package/src/orchestration/researcher-executor.ts +46 -17
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +36 -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 +65 -52
- package/src/stackexchange/index.ts +8 -0
- package/src/tools/health-tool-definition.ts +11 -1
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +58 -10
- package/src/tools/research-tool-definition.ts +27 -16
- package/src/tools/scrape.ts +8 -1
- package/src/tools/stackexchange.ts +14 -7
- package/src/utils/llm-usage.ts +4 -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/scraper-utils.ts +6 -2
- package/src/web-research/web-scraper.ts +9 -0
- package/src/youtube/index.ts +11 -1
- package/src/youtube/potoken.ts +20 -2
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
|
|
@@ -84,6 +84,20 @@ export function createStackexchangeTool(options: {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
const p = params as StackExchangeParams;
|
|
88
|
+
const command = p.command;
|
|
89
|
+
|
|
90
|
+
// Command-name validation is part of validate-BEFORE-charge too: the schema
|
|
91
|
+
// types `command` as free-text, so an unknown name used to pass Value.Check,
|
|
92
|
+
// spend a MAX_GATHERING_CALLS slot, and then die in the dispatch switch with
|
|
93
|
+
// zero work done — the error message even blamed rate limiting.
|
|
94
|
+
if (!command || typeof command !== 'string' || !['search', 'get', 'user', 'site'].includes(command)) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: `Invalid stackexchange command: ${String(command)}. Valid commands: search, get, user, site.` }],
|
|
97
|
+
details: { error: 'invalid_parameters' },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
87
101
|
// Record call in tracker - returns false if limit reached
|
|
88
102
|
const allowed = tracker.recordCall('stackexchange');
|
|
89
103
|
if (!allowed) {
|
|
@@ -93,13 +107,6 @@ export function createStackexchangeTool(options: {
|
|
|
93
107
|
};
|
|
94
108
|
}
|
|
95
109
|
|
|
96
|
-
const p = params as StackExchangeParams;
|
|
97
|
-
const command = p.command;
|
|
98
|
-
|
|
99
|
-
if (!command || typeof command !== 'string') {
|
|
100
|
-
throw new Error('Stack Exchange command is required and must be a string');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
110
|
try {
|
|
104
111
|
return await stackexchangeCommand({
|
|
105
112
|
command,
|
package/src/utils/llm-usage.ts
CHANGED
|
@@ -63,7 +63,10 @@ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
|
|
|
63
63
|
*
|
|
64
64
|
* Zero-valued counters are still emitted (when the provider reported the field at all)
|
|
65
65
|
* so "cache is off" and "provider does not report caching" stay distinguishable: the
|
|
66
|
-
* first shows the counter at 0, the second omits it.
|
|
66
|
+
* first shows the counter at 0, the second omits it. Caveat: the caller gates on
|
|
67
|
+
* `tokens > 0 || cost > 0`, so a response whose usage is all-zero across the board
|
|
68
|
+
* emits nothing — harmless in practice because cache fields are summed into `tokens`,
|
|
69
|
+
* so a reported cache split implies a nonzero total.
|
|
67
70
|
*/
|
|
68
71
|
function recordCacheTokens(parsed: Partial<TokenUsage>, labels: Record<string, string>): void {
|
|
69
72
|
if (typeof parsed.cacheRead === 'number') {
|
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 {
|
|
@@ -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
|
|
@@ -195,12 +195,15 @@ export class ToolUsageTracker {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/**
|
|
198
|
-
* Create default tool limits for a researcher
|
|
198
|
+
* Create default tool limits for a researcher.
|
|
199
|
+
*
|
|
200
|
+
* @param cachingActive - Whether prompt caching is active for this run's
|
|
201
|
+
* resolved model; see getMaxScrapeBatches in constants.ts.
|
|
199
202
|
*/
|
|
200
|
-
export function createDefaultToolLimits(config?: Config): ToolLimits {
|
|
203
|
+
export function createDefaultToolLimits(config?: Config, cachingActive = false): ToolLimits {
|
|
201
204
|
return {
|
|
202
205
|
gathering: getMaxGatheringCalls(config),
|
|
203
|
-
scrape: getMaxScrapeBatches(config),
|
|
206
|
+
scrape: getMaxScrapeBatches(config, cachingActive),
|
|
204
207
|
search: 1,
|
|
205
208
|
youtube_transcript: 1,
|
|
206
209
|
grep: MAX_GREP_CALLS,
|
|
@@ -35,8 +35,10 @@ import { isCloudflareBlockError, isTaskTimeoutError, isPoolShutdownError } from
|
|
|
35
35
|
* and extractPdfToMarkdown (`Could not extract content from PDF`, `PDF too
|
|
36
36
|
* large`) — a malformed/encrypted/scanned-image-only PDF, or one that simply
|
|
37
37
|
* exceeds the deliberate 100MB policy cap, is routine on the open web and
|
|
38
|
-
* just as non-actionable as a stub page or a 404; the
|
|
39
|
-
*
|
|
38
|
+
* just as non-actionable as a stub page or a 404; the 25MB HTML policy caps
|
|
39
|
+
* (`HTML response too large` from the fetch layer, `Browser HTML too large`
|
|
40
|
+
* from the browser layer) are the same deliberate-cap class; the remaining
|
|
41
|
+
* cases delegate to the shared browser-layer predicates. Deliberately does NOT
|
|
40
42
|
* match 'PDF extraction unavailable' (a pdf-oxide-wasm native-module load
|
|
41
43
|
* failure) — that is an infrastructure fault affecting every URL, not a
|
|
42
44
|
* routine per-PDF outcome, and must still surface at ERROR.
|
|
@@ -48,6 +50,8 @@ export function isBenignScrapeFailure(error: unknown): boolean {
|
|
|
48
50
|
msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
|
|
49
51
|
msg.startsWith('Could not extract content from PDF') || // malformed/encrypted/scanned-only PDF
|
|
50
52
|
msg.startsWith('PDF too large') || // exceeds the deliberate 100MB policy cap
|
|
53
|
+
msg.startsWith('HTML response too large') || // fetch-layer 25MB policy cap — same class as the PDF cap
|
|
54
|
+
msg.startsWith('Browser HTML too large') || // browser-layer 25MB policy cap
|
|
51
55
|
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
52
56
|
isCloudflareBlockError(error) ||
|
|
53
57
|
isTaskTimeoutError(error) ||
|
|
@@ -75,6 +75,15 @@ async function convertToMarkdown(html: string): Promise<string> {
|
|
|
75
75
|
return converter(html);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// TODO(hardening, medium): this synchronous WASM parse runs on the MAIN
|
|
79
|
+
// process's event loop — a large/complex PDF can block it for seconds,
|
|
80
|
+
// starving every concurrent scrape, the TUI render loop, and timers. The
|
|
81
|
+
// correct fix is a worker_threads-based extraction (mirroring the browser
|
|
82
|
+
// pool's CPU isolation pattern), but it is deliberately NOT done in this
|
|
83
|
+
// pass: pdf-oxide-wasm is dynamically imported here and unit tests mock the
|
|
84
|
+
// module via vitest, which cannot reach into a real worker thread — moving
|
|
85
|
+
// the parse requires a companion test-seam redesign first. Bounded today by
|
|
86
|
+
// MAX_PDF_SIZE (100MB) and the scrape task's own deadline.
|
|
78
87
|
async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
79
88
|
if (bytes.length > MAX_PDF_SIZE) {
|
|
80
89
|
const sizeMB = Math.round(bytes.length / 1024 / 1024);
|
package/src/youtube/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type { AgentToolResult } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { selectVideoIds } from './video-id.ts';
|
|
11
11
|
import { fetchVideoTranscripts, type VideoTranscript } from './transcript-client.ts';
|
|
12
12
|
import { metrics } from '../utils/metrics.ts';
|
|
13
|
+
import { truncateWithMarker } from '../utils/text-utils.ts';
|
|
14
|
+
import { MAX_SCRAPE_CONTENT_CHARS_PER_DOC } from '../constants.ts';
|
|
13
15
|
|
|
14
16
|
export interface YoutubeTranscriptCommandOptions {
|
|
15
17
|
urls: string[];
|
|
@@ -131,7 +133,15 @@ function formatResults(results: VideoTranscript[], rejected: string[]): string {
|
|
|
131
133
|
// model can copy verbatim; omitting the line entirely for untitled videos
|
|
132
134
|
// left them without any prompt-visible citation string to reuse.
|
|
133
135
|
md += `**Cite as:** ${r.url}${r.title ? ` — '${r.title}'${r.author ? ` by ${r.author}` : ''}` : ''}\n`;
|
|
134
|
-
|
|
136
|
+
// Per-document cap on content entering LLM context — parity with the
|
|
137
|
+
// scrape tool's own MAX_SCRAPE_CONTENT_CHARS_PER_DOC guard. The raw HTTP
|
|
138
|
+
// body is capped at 32MB (readTextCapped) and the knowledge-store
|
|
139
|
+
// ingestion path truncates too, but this tool's direct response to the
|
|
140
|
+
// calling agent had no cap of its own: a single long/dense-captioned
|
|
141
|
+
// video (a multi-hour livestream VOD, worst case with maxVideos up to 5)
|
|
142
|
+
// could inject an unbounded, multi-megabyte payload straight into the
|
|
143
|
+
// researcher's context.
|
|
144
|
+
md += `\n${truncateWithMarker(r.text ?? '', MAX_SCRAPE_CONTENT_CHARS_PER_DOC)}\n\n---\n\n`;
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
if (failed.length > 0) {
|
package/src/youtube/potoken.ts
CHANGED
|
@@ -110,7 +110,22 @@ export async function mintPoTokens(
|
|
|
110
110
|
throw new Error('PoToken minter is wedged by a prior mint that never settled; refusing to start a new mint until it resolves.');
|
|
111
111
|
}
|
|
112
112
|
// Chain on the mutex so only one bridged section runs at a time.
|
|
113
|
-
|
|
113
|
+
// Re-check `wedged` at DEQUEUE time, not only at the entry check above: a
|
|
114
|
+
// caller already queued on the chain when the hard release fires would
|
|
115
|
+
// otherwise start its own doMint() while the abandoned mint may still be
|
|
116
|
+
// mid-bridge — the exact double-bridge globalThis corruption the flag
|
|
117
|
+
// exists to prevent. (No false positive in the normal path: the
|
|
118
|
+
// wedge-clearing handler below is registered on `settled` BEFORE the race
|
|
119
|
+
// that dequeues the next caller, so a clean settle clears the flag first.)
|
|
120
|
+
let enteredMint = false;
|
|
121
|
+
const startIfSafe = () => {
|
|
122
|
+
if (wedged) {
|
|
123
|
+
throw new Error('PoToken minter is wedged by a prior mint that never settled; refusing to start a queued mint until it resolves.');
|
|
124
|
+
}
|
|
125
|
+
enteredMint = true;
|
|
126
|
+
return doMint(identifiers, opts);
|
|
127
|
+
};
|
|
128
|
+
const run = mintChain.then(startIfSafe, startIfSafe);
|
|
114
129
|
// Keep the chain alive regardless of this call's success/failure, but bound
|
|
115
130
|
// how long the NEXT caller waits on it: if `run` never settles, force the
|
|
116
131
|
// chain open after the hard timeout rather than wedging every future
|
|
@@ -128,7 +143,10 @@ export async function mintPoTokens(
|
|
|
128
143
|
const settled = run.then(() => undefined, () => undefined);
|
|
129
144
|
// Once doMint() actually settles (success or failure), its own `finally`
|
|
130
145
|
// always runs unbridge() for real — safe for a new mint to bridge again.
|
|
131
|
-
|
|
146
|
+
// Gated on `enteredMint`: a caller REJECTED at dequeue by the wedge check
|
|
147
|
+
// also settles, and letting it clear the flag would reopen minting while
|
|
148
|
+
// the abandoned mint's bridge may still be installed.
|
|
149
|
+
settled.then(() => { if (enteredMint) wedged = false; });
|
|
132
150
|
let hardTimer: NodeJS.Timeout;
|
|
133
151
|
const hardRelease = new Promise<void>(resolve => {
|
|
134
152
|
hardTimer = setTimeout(() => {
|