@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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +396 -125
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +17 -4
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +131 -2
  9. package/docs/CONFIGURATION.md +2 -2
  10. package/docs/SDK.md +1 -1
  11. package/package.json +3 -2
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +31 -9
  15. package/src/constants.ts +23 -4
  16. package/src/core/interfaces/state-manager-interfaces.ts +8 -0
  17. package/src/core/llm/research-model-resolver.ts +38 -0
  18. package/src/core/planning-service.ts +39 -4
  19. package/src/index.ts +3 -1
  20. package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
  21. package/src/infrastructure/browser/thread-worker-messaging.ts +20 -3
  22. package/src/infrastructure/browser/thread-worker.mjs +17 -4
  23. package/src/infrastructure/embedding/embedding-factory.ts +34 -0
  24. package/src/infrastructure/embedding/embedding-server.ts +34 -9
  25. package/src/infrastructure/state/state-manager.ts +15 -0
  26. package/src/knowledge/embedder.ts +36 -7
  27. package/src/knowledge/store-operations.ts +47 -38
  28. package/src/knowledge/store.ts +86 -8
  29. package/src/knowledge/writer-queue.ts +4 -2
  30. package/src/observers/research-observer-impl.ts +14 -6
  31. package/src/orchestration/deep-research-orchestrator.ts +15 -3
  32. package/src/orchestration/quick-research-orchestrator.ts +37 -11
  33. package/src/orchestration/research-orchestration-service.ts +21 -2
  34. package/src/orchestration/research-synthesis-service.ts +36 -0
  35. package/src/orchestration/researcher-executor.ts +46 -17
  36. package/src/orchestration/researcher.ts +2 -2
  37. package/src/orchestration/session-state.ts +36 -4
  38. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  39. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  40. package/src/prompts/researcher.md +2 -14
  41. package/src/sdk.ts +65 -52
  42. package/src/stackexchange/index.ts +8 -0
  43. package/src/tools/health-tool-definition.ts +11 -1
  44. package/src/tools/index.ts +2 -1
  45. package/src/tools/research-knowledge-search.ts +58 -10
  46. package/src/tools/research-tool-definition.ts +27 -16
  47. package/src/tools/scrape.ts +8 -1
  48. package/src/tools/stackexchange.ts +14 -7
  49. package/src/utils/llm-usage.ts +4 -1
  50. package/src/utils/log-utils.ts +5 -2
  51. package/src/utils/metrics-summary.ts +21 -1
  52. package/src/utils/tool-usage-tracker.ts +6 -3
  53. package/src/web-research/scraper-utils.ts +6 -2
  54. package/src/web-research/web-scraper.ts +9 -0
  55. package/src/youtube/index.ts +11 -1
  56. package/src/youtube/potoken.ts +20 -2
@@ -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
- const maxScrapeBatches = getMaxScrapeBatches(config);
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,
@@ -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') {
@@ -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. This env var is kept in sync with
41
- * config.DEBUG whenever the config is loaded or saved (see config.ts).
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
- resourceParts.push(`**${formatTokens(stats.tokens)}** tokens`);
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 remaining cases
39
- * delegate to the shared browser-layer predicates. Deliberately does NOT
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);
@@ -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
- md += `\n${r.text}\n\n---\n\n`;
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) {
@@ -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
- const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
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
- settled.then(() => { wedged = false; });
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(() => {