@lincoln504/pi-research 1.3.10 → 1.3.11

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 CHANGED
@@ -66,7 +66,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
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
67
  - **You own your index.** Every finding is written 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
- - **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything. It can read and search files, so treat it as you would any read-only agent with access to your machine.
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.
71
71
 
72
72
  ### Requirements / limitations
package/dist/cli.mjs CHANGED
@@ -2148,13 +2148,18 @@ function isPlausibleCitationUrl(url) {
2148
2148
  }
2149
2149
  }
2150
2150
  function lastCitedLinksHeaderIndex(text) {
2151
+ return lastCitedLinksHeader(text).textStart;
2152
+ }
2153
+ function lastCitedLinksHeader(text) {
2151
2154
  const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
2152
- let last = -1;
2155
+ let lineStart = -1;
2156
+ let textStart = -1;
2153
2157
  let m;
2154
2158
  while ((m = re.exec(text)) !== null) {
2155
- last = m.index + m[1].length + m[2].length;
2159
+ lineStart = m.index + m[1].length;
2160
+ textStart = m.index + m[1].length + m[2].length;
2156
2161
  }
2157
- return last;
2162
+ return { lineStart, textStart };
2158
2163
  }
2159
2164
  function parseCitations(report) {
2160
2165
  const headerIdx = lastCitedLinksHeaderIndex(report);
@@ -2662,7 +2667,6 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2662
2667
  PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
2663
2668
  PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
2664
2669
  PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
2665
- PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
2666
2670
  PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
2667
2671
  PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
2668
2672
  ...config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {},
@@ -2827,7 +2831,6 @@ function createConfig(env, processEnv) {
2827
2831
  LLM_THINKING_LEVEL: parseEnvEnum(e, "PI_RESEARCH_LLM_THINKING_LEVEL", ["off", "minimal", "low", "medium", "high"], DEFAULTS.LLM_THINKING_LEVEL),
2828
2832
  PLANNING_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_PLANNING_MAX_TOKENS", DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
2829
2833
  SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_SYNTHESIS_MAX_TOKENS", DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
2830
- RESEARCHER_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_RESEARCHER_MAX_TOKENS", DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
2831
2834
  MIGRATION_STRATEGY: parseEnvEnum(e, "PI_RESEARCH_MIGRATION_STRATEGY", ["drop", "re-embed", "backup"], DEFAULTS.MIGRATION_STRATEGY),
2832
2835
  CONSOLE_LOG: parseEnvBool(e, "PI_RESEARCH_CONSOLE_LOG", DEFAULTS.CONSOLE_LOG),
2833
2836
  RESEARCH_MODEL: parseEnvString(e, "PI_RESEARCH_MODEL", DEFAULTS.RESEARCH_MODEL),
@@ -2891,7 +2894,7 @@ function parseEnvNumber(env, key, def, min, max, integer = false) {
2891
2894
  return n;
2892
2895
  }
2893
2896
  function parseEnvBool(env, key, def) {
2894
- const v = env[key];
2897
+ const v = env[key]?.trim();
2895
2898
  if (v === void 0 || v === "") return def;
2896
2899
  return v.toLowerCase() === "true";
2897
2900
  }
@@ -2899,7 +2902,7 @@ function parseEnvString(env, key, def) {
2899
2902
  return env[key] || def;
2900
2903
  }
2901
2904
  function parseEnvEnum(env, key, allowed, def) {
2902
- const raw = env[key];
2905
+ const raw = env[key]?.trim();
2903
2906
  if (raw === void 0 || raw === "") return def;
2904
2907
  if (allowed.includes(raw)) return raw;
2905
2908
  logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(", ")}). Using default "${def}".`);
@@ -3015,15 +3018,6 @@ var init_config = __esm({
3015
3018
  * the model's real maxTokens at call time.
3016
3019
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
3017
3020
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
3018
- /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
3019
- * Without a cap the session inherits the model's full catalog ceiling as the
3020
- * per-request max_tokens — ~230k for large-context models — which providers that
3021
- * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
3022
- * reject outright on low balances, even though the actual response would cost
3023
- * cents. Default 16384: researcher turns are tool calls plus a cited report, far
3024
- * below this. Clamped to the model's real ceiling at session build time.
3025
- * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
3026
- RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
3027
3021
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
3028
3022
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
3029
3023
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -3060,7 +3054,8 @@ var init_config = __esm({
3060
3054
  /**
3061
3055
  * Comma-separated list of research tools to DISABLE for every researcher in a run
3062
3056
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
3063
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
3057
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
3058
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
3064
3059
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
3065
3060
  * tools are both removed from each researcher's toolset AND named in the
3066
3061
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -3101,7 +3096,6 @@ var init_config = __esm({
3101
3096
  "PI_RESEARCH_LLM_THINKING_LEVEL",
3102
3097
  "PI_RESEARCH_PLANNING_MAX_TOKENS",
3103
3098
  "PI_RESEARCH_SYNTHESIS_MAX_TOKENS",
3104
- "PI_RESEARCH_RESEARCHER_MAX_TOKENS",
3105
3099
  "PI_RESEARCH_MIGRATION_STRATEGY",
3106
3100
  "PI_RESEARCH_CONSOLE_LOG",
3107
3101
  "PI_RESEARCH_MODEL",
@@ -3146,7 +3140,7 @@ function getMaxScrapeBatches(config) {
3146
3140
  function resolveExcludedTools(callerExclusions, configDisabled = []) {
3147
3141
  return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
3148
3142
  }
3149
- var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS;
3143
+ var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS, RESEARCH_TOOL_NAMES;
3150
3144
  var init_constants = __esm({
3151
3145
  "src/constants.ts"() {
3152
3146
  "use strict";
@@ -3178,6 +3172,15 @@ var init_constants = __esm({
3178
3172
  MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
3179
3173
  MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
3180
3174
  DEFAULT_EXCLUDED_TOOLS = ["grep"];
3175
+ RESEARCH_TOOL_NAMES = [
3176
+ "search",
3177
+ "scrape",
3178
+ "security_search",
3179
+ "stackexchange",
3180
+ "youtube_transcript",
3181
+ "grep",
3182
+ "read"
3183
+ ];
3181
3184
  }
3182
3185
  });
3183
3186
 
@@ -5395,6 +5398,9 @@ var init_store = __esm({
5395
5398
  logger.warn("[store] Failed to read table schema:", schemaErr);
5396
5399
  }
5397
5400
  } else {
5401
+ if (this.options.embedder.getDimension() === null) {
5402
+ await this.withEmbedderReconnect((embedder) => embedder.embedMany([" "]));
5403
+ }
5398
5404
  this.table = await this.createTable();
5399
5405
  }
5400
5406
  await this.evictOldRecords();
@@ -5776,6 +5782,45 @@ var init_store = __esm({
5776
5782
  }
5777
5783
  }
5778
5784
  }
5785
+ /**
5786
+ * Refresh the timestamp of one CURRENT generation for url+type — the
5787
+ * revalidation "touch" for the writer-queue's content-unchanged skip. Without
5788
+ * it a stable page kept its original timestamp forever: with
5789
+ * KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS set, the serve-age gate then treated the
5790
+ * entry as permanently stale (a live re-scrape on every run, for exactly the
5791
+ * content that never changes), and TTL eviction eventually deleted an entry
5792
+ * that had been re-verified byte-identical possibly hours earlier.
5793
+ */
5794
+ async touchByUrlAndType(url, ingestionType, generationTs, newTs) {
5795
+ return this.trackOperation("touchByUrlAndType", void 0, async () => {
5796
+ let retryCount = 0;
5797
+ const MAX_RETRIES = 5;
5798
+ const BASE_DELAY = 100;
5799
+ const scopeFilter = this.getScopeFilter();
5800
+ while (retryCount <= MAX_RETRIES) {
5801
+ const table = await this.getFreshTable();
5802
+ try {
5803
+ const escapedUrl = url.replace(/'/g, "''");
5804
+ const escapedType = ingestionType.replace(/'/g, "''");
5805
+ await table.update({
5806
+ where: `url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND timestamp = ${Math.floor(generationTs)} AND (${scopeFilter})`,
5807
+ valuesSql: { timestamp: String(Math.floor(newTs)) }
5808
+ });
5809
+ logger.log(`[store] Refreshed timestamp for unchanged ${ingestionType} chunks of ${url}`);
5810
+ return;
5811
+ } catch (err) {
5812
+ if (isLanceCommitConflict(err)) {
5813
+ retryCount++;
5814
+ if (retryCount > MAX_RETRIES) throw err;
5815
+ const delay2 = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
5816
+ await new Promise((r) => setTimeout(r, delay2));
5817
+ continue;
5818
+ }
5819
+ throw err;
5820
+ }
5821
+ }
5822
+ });
5823
+ }
5779
5824
  async findDocumentsByUrl(url) {
5780
5825
  return this.trackOperation("findDocumentsByUrl", [], async () => {
5781
5826
  const table = await this.getFreshTable();
@@ -6013,7 +6058,10 @@ var init_store = __esm({
6013
6058
  backupDirs.map(async (name) => ({ name, ts: await this.backupDirTimestamp(name) }))
6014
6059
  );
6015
6060
  ranked.sort((a, b) => a.ts - b.ts);
6016
- for (const { name } of ranked.slice(0, -1)) {
6061
+ const ttlDays = this.options.ttlDays ?? 30;
6062
+ const backupCutoff = Math.trunc(Date.now() - ttlDays * 24 * 60 * 60 * 1e3);
6063
+ for (const { name, ts } of ranked.slice(0, -1)) {
6064
+ if (ts >= backupCutoff) continue;
6017
6065
  const fullPath = path13.join(this.options.dbDir, name);
6018
6066
  logger.info(`[store] Removing superseded backup directory: ${name}`);
6019
6067
  await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
@@ -6309,6 +6357,17 @@ var init_writer_queue = __esm({
6309
6357
  metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
6310
6358
  });
6311
6359
  }
6360
+ if (this.options.store.touchByUrlAndType) {
6361
+ try {
6362
+ const recheck = (await this.options.store.findByUrl(normalizedUrl)).filter((c) => c.metadata["ingestionType"] === incomingType);
6363
+ const stillNewest = recheck.length > 0 && Math.max(...recheck.map((d) => Number(d.timestamp) || 0)) === newestTs;
6364
+ if (stillNewest) {
6365
+ await this.options.store.touchByUrlAndType(normalizedUrl, incomingType, newestTs, Date.now());
6366
+ }
6367
+ } catch (err) {
6368
+ logger.warn(`[writer-queue] Timestamp refresh for unchanged ${normalizedUrl} (${incomingType}) failed:`, err);
6369
+ }
6370
+ }
6312
6371
  logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
6313
6372
  return;
6314
6373
  }
@@ -6787,7 +6846,17 @@ async function loadPipelineWithTimeout(model, device, timeoutMs, useCache) {
6787
6846
  interOpNumThreads: 1
6788
6847
  }
6789
6848
  });
6790
- return await withTimeout3(pipelinePromise, timeoutMs, errorMessage);
6849
+ let timedOut = true;
6850
+ try {
6851
+ const p = await withTimeout3(pipelinePromise, timeoutMs, errorMessage);
6852
+ timedOut = false;
6853
+ return p;
6854
+ } finally {
6855
+ if (timedOut) {
6856
+ pipelinePromise.then((late) => late.dispose?.()).catch(() => {
6857
+ });
6858
+ }
6859
+ }
6791
6860
  });
6792
6861
  return { pipeline: loadedPipeline, errorMessage };
6793
6862
  }
@@ -10432,7 +10501,7 @@ function normalizeCitations(reports) {
10432
10501
  localToGlobal.set(localKey, globalId);
10433
10502
  }
10434
10503
  });
10435
- const headerIdx = lastCitedLinksHeaderIndex(report);
10504
+ const headerIdx = lastCitedLinksHeader(report).lineStart;
10436
10505
  let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
10437
10506
  content = rewriteCitationMarkers(
10438
10507
  content,
@@ -21549,8 +21618,6 @@ async function createResearcherSession(options) {
21549
21618
  cwd
21550
21619
  });
21551
21620
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
21552
- const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
21553
- const sessionModel = modelToUse.maxTokens > outputCap ? { ...modelToUse, maxTokens: outputCap } : modelToUse;
21554
21621
  try {
21555
21622
  const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
21556
21623
  const globalLinks = updateGlobalLinks || (() => {
@@ -21591,7 +21658,7 @@ async function createResearcherSession(options) {
21591
21658
  sessionManager: SessionManager.inMemory(),
21592
21659
  // Each researcher gets its own isolated session
21593
21660
  settingsManager: researcherSettings,
21594
- model: sessionModel,
21661
+ model: modelToUse,
21595
21662
  // pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
21596
21663
  // with the async `modelRuntime`. Pass the runtime backing OUR registry when
21597
21664
  // this run built one (standalone CLI/SDK): it carries any explicit API key
@@ -21622,7 +21689,7 @@ async function createResearcherSession(options) {
21622
21689
  throw new Error("Session creation returned invalid result");
21623
21690
  }
21624
21691
  sessionRef.session = result.session;
21625
- return { session: result.session, resolvedModel: sessionModel };
21692
+ return { session: result.session, resolvedModel: modelToUse };
21626
21693
  } catch (tailError) {
21627
21694
  const session = result?.session;
21628
21695
  if (session && typeof session.abort === "function") {
@@ -23104,6 +23171,13 @@ var ResearchOrchestrationService = class {
23104
23171
  });
23105
23172
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
23106
23173
  } catch (err) {
23174
+ const isTerminal = err instanceof ResearchRunCapacityError || err instanceof Error && err.name === "AbortError";
23175
+ if (isTerminal) {
23176
+ try {
23177
+ observer?.onError?.(err);
23178
+ } catch {
23179
+ }
23180
+ }
23107
23181
  if (err instanceof ResearchRunCapacityError) throw err;
23108
23182
  if (err instanceof Error && err.name === "AbortError") throw err;
23109
23183
  logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
@@ -23855,7 +23929,7 @@ Research performed using ${modelId}`;
23855
23929
  if (globalId !== void 0) localToGlobal.set(localKey, globalId);
23856
23930
  else droppedNumbers.add(localKey);
23857
23931
  });
23858
- let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
23932
+ let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
23859
23933
  if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
23860
23934
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
23861
23935
  }
@@ -24778,6 +24852,7 @@ Total cost: ${costStr}`;
24778
24852
  // src/cli.ts
24779
24853
  init_config();
24780
24854
  init_model_registry_factory();
24855
+ init_constants();
24781
24856
  import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
24782
24857
  function readPkgVersion() {
24783
24858
  try {
@@ -24898,14 +24973,15 @@ function makeProgressObserver() {
24898
24973
  var ConfigFileError = class extends Error {
24899
24974
  };
24900
24975
  function bridgeConfigEnv(explicitConfigPath) {
24901
- const basePath = explicitConfigPath ?? getGlobalEnvFilePath();
24976
+ const globalBasePath = getGlobalEnvFilePath();
24977
+ const basePath = explicitConfigPath ?? globalBasePath;
24902
24978
  const cliOverlayPath = getInterfaceEnvFilePath("cli");
24903
24979
  let loaded = false;
24904
24980
  const merged = {};
24905
24981
  const fromExplicitBase = /* @__PURE__ */ new Set();
24906
- const layers = explicitConfigPath !== void 0 ? [cliOverlayPath, basePath] : [basePath, cliOverlayPath];
24982
+ const layers = explicitConfigPath !== void 0 ? [globalBasePath, cliOverlayPath, explicitConfigPath] : [globalBasePath, cliOverlayPath];
24907
24983
  for (const filePath of layers) {
24908
- const isExplicit = explicitConfigPath !== void 0 && filePath === basePath;
24984
+ const isExplicit = explicitConfigPath !== void 0 && filePath === explicitConfigPath;
24909
24985
  try {
24910
24986
  if (!fs17.existsSync(filePath)) {
24911
24987
  if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
@@ -25042,7 +25118,11 @@ async function detectCredentials(explicitModel) {
25042
25118
  modelFrom,
25043
25119
  piAuthPresent,
25044
25120
  piModelsPresent,
25045
- problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
25121
+ // A model configured with no usable key is a different (and misleading to
25122
+ // conflate) gap than nothing configured at all: SKILL.md tells agents to relay
25123
+ // this text verbatim, so "No model … is configured" pointed users who HAD set
25124
+ // PI_RESEARCH_MODEL at the wrong knob.
25125
+ problem: model ? `A model is configured (${model}) but no API key is available for ${provider ? `provider "${provider}"` : "its provider"}. Provide one via the PI_RESEARCH_API_KEY environment variable (with PI_RESEARCH_PROVIDER), or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json).` : "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
25046
25126
  };
25047
25127
  }
25048
25128
  function configBlock(det, extraNote) {
@@ -25112,6 +25192,8 @@ Error: ${vmsg}
25112
25192
  ...args.excludeTools ? { excludeTools: args.excludeTools } : {},
25113
25193
  ...args.initialLinks ? { initialLinks: args.initialLinks } : {}
25114
25194
  }, abortController.signal);
25195
+ const outcome = getLastResearcherOutcome();
25196
+ const errorReport = getLastErrorReport();
25115
25197
  let reportPath = null;
25116
25198
  const exportCfg = getConfig(process.cwd(), "cli");
25117
25199
  if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
@@ -25126,8 +25208,6 @@ Error: ${vmsg}
25126
25208
  exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
25127
25209
  }
25128
25210
  if (args.json) {
25129
- const outcome = getLastResearcherOutcome();
25130
- const errorReport = getLastErrorReport();
25131
25211
  toStdout(pretty({
25132
25212
  ok: true,
25133
25213
  depth,
@@ -25204,8 +25284,11 @@ Configure in:
25204
25284
  if (activeResearchAbortController === abortController) activeResearchAbortController = null;
25205
25285
  await safeShutdown();
25206
25286
  }
25287
+ if (cancellationRequested) {
25288
+ exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
25289
+ }
25207
25290
  if (json) {
25208
- toStdout(pretty({ ok: true, ...result }));
25291
+ toStdout(pretty({ ok: true, ...cancellationRequested ? { cancelled: true } : {}, ...result }));
25209
25292
  } else {
25210
25293
  toStdout(result.text.endsWith("\n") ? result.text : result.text + "\n");
25211
25294
  }
@@ -25540,6 +25623,7 @@ COMMANDS
25540
25623
  --dry-run | --json Plan only; JSON output.
25541
25624
 
25542
25625
  help, --help, -h Show this help.
25626
+ --version, -v Print the pi-research version.
25543
25627
 
25544
25628
  CONFIGURE
25545
25629
  Credentials are resolved in this order (first match wins):
@@ -25729,6 +25813,12 @@ function parseArgs(argv) {
25729
25813
  } else if (a === "--exclude-tools") {
25730
25814
  const v = takeOptionValue(rest, ++i, "--exclude-tools");
25731
25815
  excludeTools = v.split(",").map((s) => s.trim()).filter(Boolean);
25816
+ const unknown = excludeTools.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
25817
+ if (unknown.length > 0) {
25818
+ throw new UsageError(
25819
+ `--exclude-tools: unknown tool name${unknown.length === 1 ? "" : "s"} ${unknown.map((t) => `"${t}"`).join(", ")}. Valid names: ${RESEARCH_TOOL_NAMES.join(", ")}.`
25820
+ );
25821
+ }
25732
25822
  if (excludeTools.length === 0) excludeTools = void 0;
25733
25823
  } else if (a === "--initial-links") {
25734
25824
  let j = i + 1;
@@ -25742,6 +25832,7 @@ function parseArgs(argv) {
25742
25832
  }
25743
25833
  j++;
25744
25834
  }
25835
+ if (j === i + 1) throw new UsageError("--initial-links requires at least one URL.");
25745
25836
  i = j - 1;
25746
25837
  } else if (a === "--config") {
25747
25838
  configPath = takeOptionValue(rest, ++i, "--config");
@@ -2544,15 +2544,6 @@ var ConfigSchema = Type.Object({
2544
2544
  * the model's real maxTokens at call time.
2545
2545
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2546
2546
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2547
- /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
2548
- * Without a cap the session inherits the model's full catalog ceiling as the
2549
- * per-request max_tokens — ~230k for large-context models — which providers that
2550
- * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
2551
- * reject outright on low balances, even though the actual response would cost
2552
- * cents. Default 16384: researcher turns are tool calls plus a cited report, far
2553
- * below this. Clamped to the model's real ceiling at session build time.
2554
- * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
2555
- RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2556
2547
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2557
2548
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2558
2549
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -2589,7 +2580,8 @@ var ConfigSchema = Type.Object({
2589
2580
  /**
2590
2581
  * Comma-separated list of research tools to DISABLE for every researcher in a run
2591
2582
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
2592
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
2583
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
2584
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
2593
2585
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
2594
2586
  * tools are both removed from each researcher's toolset AND named in the
2595
2587
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -12,7 +12,7 @@ pi CLI
12
12
  └── pi-research extension (src/index.ts)
13
13
  ├── Registered Tools research, health, research_knowledge_search (always registered; reports why when the store is disabled)
14
14
  ├── Commands /research, /research-config, /knowledge-store
15
- ├── Events input (mid-run steering), session_shutdown (cleanup)
15
+ ├── Events input (mid-run steering), session_shutdown (cleanup), session_before_compact / session_compact, before_agent_start, after_provider_response
16
16
  └── Layers
17
17
  ├── Orchestration quick/deep research coordination
18
18
  ├── Agent Tools search, scrape, youtube_transcript, security_search, stackexchange, grep, read
@@ -106,10 +106,10 @@ agent to invoke:
106
106
  |------|-------|------|---------|
107
107
  | `search` | ✓ | — | DuckDuckGo Lite via the stealth browser |
108
108
  | `scrape` | ✓ | ✓ | Batch page fetch → Markdown via the stealth browser (up to 6 URLs per call) |
109
- | `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos, one call per researcher) |
109
+ | `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos by default, configurable 1–5; one call per researcher) |
110
110
  | `security_search` | ✓ | ✓ | NVD, CISA KEV, GitHub Advisories, OSV |
111
111
  | `stackexchange` | ✓ | ✓ | Stack Exchange network |
112
- | `grep` | — | — | Local ripgrep (from pi-coding-agent) — off by default, see below |
112
+ | `grep` | — | — | Local ripgrep (from pi-coding-agent) — always excluded, see below |
113
113
  | `read` | ✓ | ✓ | Local file reads (from pi-coding-agent) |
114
114
 
115
115
  In deep research `search` is excluded — the coordinator runs the search burst and hands
@@ -290,11 +290,12 @@ src/
290
290
  ### Key design decisions
291
291
 
292
292
  Read-only researchers — researcher agents are limited to the tool set above. They cannot
293
- write files, spawn processes, or make arbitrary network calls. They *can* read and grep
294
- files: `read` and `grep` are registered unconditionally and the researcher exclusion list
295
- (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) does not cover them. The `cwd` passed
296
- to those tools is a resolution base, not a jail — an absolute path resolves to itself — so
297
- the boundary is "no mutation", not "only this directory".
293
+ write files, spawn processes, or make arbitrary network calls. They *can* read files:
294
+ `read` is registered and the researcher exclusion list (`bash`, `write`, `edit`, `repl`,
295
+ `git`, `terminal`) does not cover it. Local `grep` is registered but always excluded (see
296
+ the tool table above). The `cwd` passed to `read` is a resolution base, not a jail — an
297
+ absolute path resolves to itself — so the boundary is "no mutation", not "only this
298
+ directory".
298
299
 
299
300
  Worker pool over direct browser — browser processes are isolated in workers so a crash
300
301
  in one cannot affect the orchestrator or other sessions.
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.11] - 2026-08-14
9
+
10
+ Alongside the revert below, a four-way independent audit (docs-vs-code
11
+ consistency, orchestration/TUI/citations, the knowledge store, and CLI/config
12
+ precedence) swept the areas least recently re-checked, and an independent
13
+ fresh-eyes review of the resulting delta caught two follow-on defects (fixed
14
+ below). Regression tests cover the highest-severity fixes — the `--config`
15
+ credential drop (mutation-verified), the revalidation touch, the tool-name and
16
+ initial-links validation, the exit-78 message split, and the citation marker —
17
+ plus live end-to-end verification of the `--config` and validation behavior;
18
+ the remaining fixes are covered by the existing suites they run under.
19
+
20
+ ### Fixed
21
+
22
+ - **`--config` silently dropped credentials stored in the base `config.env`.** With an explicit `--config <path>`, the env bridge layered only `cli.env` + the named file — the global base was never read. Typed config keys survived (getConfig reads the base independently), but the env-only keys the bridge exists for — `PI_RESEARCH_API_KEY`, `PI_RESEARCH_PROVIDER`, and the security-API keys — vanished, so `research "q" --config ci.env` exited 78 ("no model or API key") on a correctly configured machine, contradicting the flag's own help text. The global base now always layers underneath (base → cli.env → named file). Reproduced live before the fix; regression test fails against the pre-fix code.
23
+ - **Two sessions configured with different embedding models could permanently destroy knowledge-store data.** Every session opens the same physical table, and a model mismatch migrates it aside as a backup — so two sessions with different `PI_RESEARCH_EMBEDDING_MODEL` values alternate migrations, and the backup pruner's keep-only-newest rule deleted the only surviving copy of the first model's data after just two alternations, silently. Superseded backups are now retained until they age past `CACHE_TTL_DAYS` (a backup older than the TTL holds only rows eviction would have deleted anyway), so alternation can no longer destroy data inside the retention window.
24
+ - **A stable page became permanently "stale" once `KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS` was set.** The over-age read is treated as a miss and re-scraped, but the re-ingest's content-hash dedup skipped the write without refreshing the timestamp — so the entry could never satisfy the serve-age gate again (a live scrape on every run, for exactly the content that never changes) and TTL eviction eventually deleted an entry re-verified byte-identical possibly hours earlier. The unchanged-content skip now touches the current generation's timestamp — guarded by a just-in-time newest-generation re-check so a concurrent process's newer ingest can't be outranked by the touch.
25
+ - **An interrupted `knowledge` search could report success.** `cmdKnowledge` wired the abort signal but never consulted the cancellation latch on the resolve path — its own comment named "resolve normally and report ok:true/exit 0 for an interrupted run" as the failure being prevented, and only the throwing half was handled. It now mirrors `cmdResearch`: exit 128+N and a `cancelled: true` JSON field.
26
+ - **Cancelled `--json` research output nondeterministically lost its diagnostics.** The signal handler's fire-and-forget shutdown resets the SDK globals that `researchers`/`trackedErrors` are read from; reading them at JSON-emit time raced it. They are now snapshotted as soon as the run resolves, which shrinks the race window to near zero (a shutdown that completes before the run's continuation is even scheduled can still win — strictly narrower than before).
27
+ - **Fresh-store init could burn all 5 retries against a never-initialized embedder.** The model-mismatch local fallback embedder is constructed uninitialized, and only the migration branch warmed it — the no-existing-table branch went straight to `createTable()`, which throws "embedder dimension unknown", failing every retry identically. The fresh-table branch now warms the embedder the same way.
28
+ - **Headless pi-extension runs leaked per-run session state for the process lifetime.** The non-TUI cleanup path cleared steering messages but never called `endResearchSession`, so every run left its failure-count entries and AbortController in the module-level session map (the TUI and SDK paths both free them).
29
+ - **A run that failed while queued for a run slot fired no terminal observer event.** Capacity exhaustion or cancel-while-queued rethrew before any orchestrator existed, so an observer that had just been told `run_queued` never received `onError` — violating the "exactly one of onComplete/onError" contract. The terminal callback now fires on both paths.
30
+ - **The rebuilt report carried a stray `##`/`**`/`>` line when the model wrote a decorated CITED LINKS header.** The header detector deliberately tolerates markdown prefixes, but both body-slicing consumers cut at the header *text*, leaving the tolerated marker dangling at the end of the body. Both now slice at the start of the header line.
31
+ - **`--exclude-tools` accepted unknown tool names silently** (a typo excluded nothing for the whole billed run — the repository's own test fixture had exactly this bug, excluding a tool named `security` that does not exist); names are now validated against the canonical set. The fresh-eyes review then caught the validator omitting `read` — excluding researchers' local-file reads is a legitimate hardening move that had always worked and would have started failing on the CLI; `read` is in the valid set. **A bare `--initial-links` with zero URLs** likewise proceeded silently and now fails fast like every other value-taking flag.
32
+ - **The exit-78 message conflated "nothing configured" with "model configured, key missing".** SKILL.md tells agents to relay the message verbatim, so users who had set `PI_RESEARCH_MODEL` were pointed at the wrong knob. The two cases now produce distinct messages.
33
+ - **`PI_RESEARCH_*` boolean/enum env vars silently mis-parsed with surrounding whitespace** (`'true '` read as false); both parsers now trim like the numeric parser always has. **On a model-load timeout, the still-loading ONNX pipeline now gets disposed** when it eventually resolves instead of leaking one native session per retry.
34
+
35
+ ### Documentation
36
+
37
+ - `.env.example` corrections: `PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE` described a wider hole than the loopback-only scope it actually has (the same overstatement 1.3.9 fixed in CONFIGURATION.md); `PI_RESEARCH_LLM_TIMEOUT_MS` still showed the old 10-minute ceiling (raised to 30 in the schema); the "exhaustive reference" claim is honored again by documenting `PLAYWRIGHT_BROWSERS_PATH`, `XDG_CACHE_HOME`, and the skill launcher's `PI_RESEARCH_BIN`/`PI_RESEARCH_PATH`.
38
+ - ARCHITECTURE.md's "Key design decisions" still said researchers "can read and grep files" — stale since grep became default-excluded, and wrong outright since 1.3.10 made the exclusion unconditional. Rewritten, along with the tool table's "off by default" wording, the extension event list, and the fixed "≤3 videos" phrasing (configurable 1–5). README's "read and search files" corrected to read-only-files. CONFIGURATION.md now states that provider-native key variables are honored only as real environment variables, and the CLI help finally mentions `--version`/`-v` (implemented all along).
39
+
40
+ ### Removed
41
+
42
+ - **The 1.3.10 researcher `max_tokens` cap (`PI_RESEARCH_RESEARCHER_MAX_TOKENS`) is reverted.** It solved a non-problem and created a real risk. The 402 it "fixed" only fires when an OpenRouter balance is nearly exhausted, and that error already names the remedy — it is the provider's top-up prompt, not a client defect. Meanwhile the cap re-introduced the exact failure class `PLANNING_MAX_TOKENS` was raised to escape in 1.2.x: reasoning tokens count against `max_tokens` on OpenAI-compatible APIs, so with `PI_RESEARCH_LLM_THINKING_LEVEL` raised — or a model that cannot fully disable thinking — a 16k ceiling can be consumed before or during the report and truncate it. It also second-guessed the model catalog: pi sessions everywhere else honor the catalog `maxTokens`, and a user who wants a different ceiling can already set one per model via `modelOverrides` in `~/.pi/agent/models.json`, which covers every session this package creates. Researcher sessions once again inherit the model's catalog ceiling unmodified.
43
+
8
44
  ## [1.3.10] - 2026-08-14
9
45
 
10
46
  The post-release verification round for 1.3.9 kept digging and found that the
@@ -21,7 +57,7 @@ whole delta then caught two follow-on defects, fixed below.
21
57
  ### Fixed
22
58
 
23
59
  - **Every fetch scrape failed instantly; the whole path silently ran on the browser fallback.** `getSsrfSafeDispatcher()` built an `Agent` from the undici 8.x this package depends on and handed it to Node's **global** `fetch()` — which is backed by the undici compiled *into* the runtime (6.x on Node 22, 7.x on Node 25). Cross-major dispatchers are rejected outright (`UND_ERR_INVALID_ARG: invalid onRequestStart method`), so every fetch attempt threw before a single packet left the machine, burned one retry, and fell back to the stealth browser: ~3.4 s per page against the ~0.9 s the fetch path delivers, the browser pool saturated with work it never needed — and the connect-time SSRF DNS pin, the entire reason the dispatcher exists, never engaged once (request-time validation still applied throughout, so this was defence-in-depth loss, not an open hole). Nothing caught it because every test of the layer stubs the global `fetch`, and the fallback kept scrapes succeeding. The replacement, `getSsrfSafeFetcher()`, returns the Agent *paired with undici's own `fetch`*, making the invalid combination unrepresentable, and a new test drives the real fetch path against a real loopback socket with the browser layer disabled — it fails against the pre-fix code with the exact production error. Measured on live runs: 0 fetch / 5 browser successes before, 4 fetch / 0 browser after.
24
- - **Researchers sent the model's full ~230k catalog ceiling as `max_tokens` — and OpenRouter 402'd every call on a low balance.** The researcher session inherited the model's catalog `maxTokens` as its per-request output cap. Providers that budget-check `max_tokens` up front rejected the call before running it: observed live as `402: You requested up to 231037 tokens, but can only afford 24467` on every researcher, failing entire runs whose real cost was cents. Researcher output per call — tool calls plus a cited report — is nowhere near that. New `PI_RESEARCH_RESEARCHER_MAX_TOKENS` knob (default 16384, range 1024–131072), clamped to the model's real ceiling, applied in the shared session factory so deep and quick sub-agents are both covered.
60
+ - **Researchers sent the model's full ~230k catalog ceiling as `max_tokens` — and OpenRouter 402'd every call on a low balance.** The researcher session inherited the model's catalog `maxTokens` as its per-request output cap. Providers that budget-check `max_tokens` up front rejected the call before running it: observed live as `402: You requested up to 231037 tokens, but can only afford 24467` on every researcher, failing entire runs whose real cost was cents. Researcher output per call — tool calls plus a cited report — is nowhere near that. New `PI_RESEARCH_RESEARCHER_MAX_TOKENS` knob (default 16384, range 1024–131072), clamped to the model's real ceiling, applied in the shared session factory so deep and quick sub-agents are both covered. *(Reverted in 1.3.11 — see above.)*
25
61
  - **`--exclude-tools` silently granted researchers local-filesystem `grep`.** A non-empty exclusion list *replaced* the default exclusions instead of adding to them, so `--exclude-tools stackexchange` enabled a capability as a side effect of a flag documented as "disable internal tools". The same defect was fixed for `PI_RESEARCH_DISABLED_TOOLS` in 1.3.0 with the rationale "a subtract-only setting must never grant a capability"; the caller-facing surface kept the replacement semantics until now. Both surfaces are strictly additive over the default exclusions. (Behavioral change: there is no longer any way to hand researchers `grep`, which ARCHITECTURE.md previously documented as a quirk of the replacement rule.)
26
62
  - **A misconfigured $0 price table was silent on depth-0 runs.** Both researcher `message_end` accounting sites hand-copied the extract→increment→emit block instead of calling `recordLlmUsage`, so `warnIfUnpriced` — added in 1.1.2 precisely because a hand-written `models.json` entry silently discards pi's pricing — never ran for researcher usage. Depth-1+ runs still warned via the coordinator; depth-0 runs have no coordinator and reported `$0.0000` with no diagnostic at all. Metric labels and observer payloads are unchanged (`onTokensConsumed` now fires before `onResearcherProgress` rather than after — no consumer is order-sensitive), and the deep path keeps its `safeObserve` isolation.
27
63
  - **A connect-time SSRF block was filed as a generic fetch error.** undici never surfaces the connector's `ESSRFBLOCKED` error directly — `fetch` throws `TypeError: fetch failed` with the real error down the `cause` chain — so the message-based classification at both tracking sites missed it and a DNS-rebinding block would have been invisible in the SSRF metrics. Unreachable before this release (the dispatcher path had never executed); now that it is live, `isSsrfBlockError()` walks the bounded cause chain. Diagnostics-only: the block itself always held.
@@ -69,7 +69,7 @@ Research
69
69
  | `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
70
70
  | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 1–3 | Depth for `/research` and the CLI when `--depth` is omitted (1=normal, 2=deep, 3=ultra). |
71
71
  | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). |
72
- | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). Local `grep` has its own separate budget. |
72
+ | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
73
73
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
@@ -114,7 +114,6 @@ These are advanced, env-only knobs (not in the TUI).
114
114
  | `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, evaluator, synthesis, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
115
115
  | `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the plan + mid-round evaluator decision. Clamped to the model's real ceiling. |
116
116
  | `PI_RESEARCH_SYNTHESIS_MAX_TOKENS` | `32768` | 1024–131072 | Max output tokens for the final synthesized report. Clamped to the model's real ceiling. |
117
- | `PI_RESEARCH_RESEARCHER_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens per researcher LLM call (deep and quick). Without it the model's full catalog ceiling is sent as `max_tokens`, which OpenRouter budget-checks against remaining credits and rejects on low balances. |
118
117
 
119
118
  Knowledge store
120
119
 
@@ -136,7 +135,7 @@ API keys
136
135
 
137
136
  | Variable | Description |
138
137
  |----------|-------------|
139
- | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. |
138
+ | `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. Note: provider-native variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, …) are honored only as **real environment variables** — placing one in `config.env` / `cli.env` does nothing (the files bridge only `PI_RESEARCH_*` keys plus `STACKEXCHANGE_API_KEY` / `GITHUB_TOKEN` / `NVD_API_KEY`). |
140
139
  | `STACKEXCHANGE_API_KEY` | Raises the Stack Exchange tool's limit from 300/day to 10,000/day. Obtain at <https://stackapps.com/apps/oauth>. |
141
140
  | `GITHUB_TOKEN` | Raises the security tool's GitHub Advisory limit from 60/hr to 5000/hr (any default-scope token). |
142
141
  | `NVD_API_KEY` | Raises the security tool's NVD limit ~10× and tightens request spacing. Request at <https://nvd.nist.gov/developers/request-an-api-key>. Recommended when using severity-filtered security searches: those issue a second (CVSS v2) NVD query to catch v2-only CVEs, which roughly doubles request time against the 6 s/request unauthenticated throttle. |
@@ -159,6 +158,7 @@ Diagnostics & platform
159
158
  | `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` | _(unset)_ | Set `1` during `npm install` to skip the camoufox browser download (fetched lazily on first use instead; standard Playwright convention). |
160
159
  | `PLAYWRIGHT_BROWSERS_PATH` | _(user cache)_ | Standard Playwright variable, honoured here too: overrides where the camoufox binary is stored and looked up, and is passed through to browser workers. |
161
160
  | `XDG_CACHE_HOME` | `~/.cache` | Standard XDG variable. When set, every `~/.cache/pi-research/...` path below is rooted at `$XDG_CACHE_HOME/pi-research/...` instead. |
161
+ | `PI_RESEARCH_BIN` (alias `PI_RESEARCH_PATH`) | _(auto)_ | Agent-skill launcher only: explicit path to the pi-research engine binary when auto-resolution (PATH → local install → npx) should be bypassed. See [skills/pi-research/references/configuration.md](../skills/pi-research/references/configuration.md). |
162
162
  | `PLAYWRIGHT_INSTALL_DEPS` | _(unset)_ | Linux only. Set `true` during `npm install` to also install system libraries via `npx playwright install-deps` (same as `npm run install:system-deps`). |
163
163
  | `PI_RESEARCH_STRICT_SETUP` | _(unset)_ | Read by the bundled `scripts/setup.cjs` during `npm install`. Set `1`/`true` to make a failed browser download fail the install instead of deferring it to first use. |
164
164
  | `PI_RESEARCH_CONFIG_DIR_NAME` | `.pi` | Override the host config-directory name under your home dir (advanced; e.g. set to share another harness's config root). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.10",
3
+ "version": "1.3.11",
4
4
  "description": "Free unlimited web search and deep research for the Pi coding agent. Local knowledge store. Also works from Claude Code, Codex, and OpenClaw.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.3.10", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.11", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/src/cli.ts CHANGED
@@ -44,6 +44,7 @@ import { exportResearchReport, appendExportMessage } from './utils/research-expo
44
44
  import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFilePath, getStateDir, saveConfig, resetConfig, describeKnowledgeStoreMode, isProjectScopedKey, parseDotEnv } from './config.ts';
45
45
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
46
46
  import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
47
+ import { RESEARCH_TOOL_NAMES } from './constants.ts';
47
48
 
48
49
  // ---------------------------------------------------------------------------
49
50
  // Constants
@@ -234,7 +235,8 @@ export class ConfigFileError extends Error {}
234
235
  * the real environment, so real shell exports always win.
235
236
  */
236
237
  export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; loaded: boolean } {
237
- const basePath = explicitConfigPath ?? getGlobalEnvFilePath();
238
+ const globalBasePath = getGlobalEnvFilePath();
239
+ const basePath = explicitConfigPath ?? globalBasePath;
238
240
  const cliOverlayPath = getInterfaceEnvFilePath('cli');
239
241
  let loaded = false;
240
242
  // Merge base then overlay into one map FIRST (overlay wins), and only then
@@ -252,13 +254,18 @@ export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; lo
252
254
  // the user passed on this exact invocation is the most specific intent, and the
253
255
  // ambient cli.env overlay silently overriding it re-created the very failure
254
256
  // this bridge exists to fix (a key set in `--config ci.env` simply not applying).
257
+ // The GLOBAL base always stays underneath: this bridge exists for env-only keys
258
+ // (PI_RESEARCH_API_KEY & co.) that getConfig never carries, so dropping the base
259
+ // here silently dropped credentials stored only in config.env — `--config extra.env`
260
+ // exit-78'd on a correctly configured machine while the help text promised
261
+ // "base config.env keys it does not set still apply".
255
262
  const layers = explicitConfigPath !== undefined
256
- ? [cliOverlayPath, basePath]
257
- : [basePath, cliOverlayPath];
263
+ ? [globalBasePath, cliOverlayPath, explicitConfigPath]
264
+ : [globalBasePath, cliOverlayPath];
258
265
  for (const filePath of layers) {
259
266
  // Only the EXPLICIT --config file fails loudly (see ConfigFileError above);
260
267
  // the ambient layers keep their missing/unreadable tolerance unchanged.
261
- const isExplicit = explicitConfigPath !== undefined && filePath === basePath;
268
+ const isExplicit = explicitConfigPath !== undefined && filePath === explicitConfigPath;
262
269
  try {
263
270
  if (!fs.existsSync(filePath)) {
264
271
  if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
@@ -495,11 +502,20 @@ async function detectCredentials(explicitModel?: string): Promise<CredentialDete
495
502
  modelFrom,
496
503
  piAuthPresent,
497
504
  piModelsPresent,
498
- problem:
499
- 'No model or API key is configured for pi-research. Provide credentials via environment ' +
500
- 'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
501
- "pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
502
- 'apiKey in ~/.pi/agent/models.json).',
505
+ // A model configured with no usable key is a different (and misleading to
506
+ // conflate) gap than nothing configured at all: SKILL.md tells agents to relay
507
+ // this text verbatim, so "No model … is configured" pointed users who HAD set
508
+ // PI_RESEARCH_MODEL at the wrong knob.
509
+ problem: model
510
+ ? `A model is configured (${model}) but no API key is available for ` +
511
+ `${provider ? `provider "${provider}"` : 'its provider'}. Provide one via the ` +
512
+ 'PI_RESEARCH_API_KEY environment variable (with PI_RESEARCH_PROVIDER), or, if you use pi, ' +
513
+ "via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
514
+ 'apiKey in ~/.pi/agent/models.json).'
515
+ : 'No model or API key is configured for pi-research. Provide credentials via environment ' +
516
+ 'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
517
+ "pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
518
+ 'apiKey in ~/.pi/agent/models.json).',
503
519
  };
504
520
  }
505
521
 
@@ -604,6 +620,14 @@ export async function cmdResearch(args: ResearchArgs): Promise<number> {
604
620
  ...(args.initialLinks ? { initialLinks: args.initialLinks } : {}),
605
621
  }, abortController.signal);
606
622
 
623
+ // Snapshot the run diagnostics NOW, before any further await: on a cancelled
624
+ // run, onSignal's fire-and-forget safeShutdown runs concurrently with this
625
+ // tail and resets these SDK globals — reading them at JSON-emit time made
626
+ // `researchers`/`trackedErrors` silently vanish from a cancelled-but-
627
+ // successful payload depending on who won the race.
628
+ const outcome = getLastResearcherOutcome();
629
+ const errorReport = getLastErrorReport();
630
+
607
631
  // Optionally persist the report to a file (opt-in via
608
632
  // PI_RESEARCH_REPORT_EXPORT_ENABLED). The SDK is a pure library and does not
609
633
  // write files; the CLI front-end does, mirroring the pi tool path.
@@ -636,8 +660,7 @@ export async function cmdResearch(args: ResearchArgs): Promise<number> {
636
660
  // (ISSUES-ANALYSIS-2026-07-09.md, Symptom 4): a caller reading only stdout/JSON
637
661
  // had no way to distinguish "thin report, little material out there" from "thin
638
662
  // report because most researchers died mid-run" without opening the log file.
639
- const outcome = getLastResearcherOutcome();
640
- const errorReport = getLastErrorReport();
663
+ // (outcome/errorReport were snapshotted right after the run resolved — see above.)
641
664
  toStdout(pretty({
642
665
  ok: true,
643
666
  depth,
@@ -719,11 +742,20 @@ export async function cmdKnowledge(queries: string[], json?: boolean): Promise<n
719
742
  await safeShutdown();
720
743
  }
721
744
 
745
+ // Same latch as cmdResearch's post-run check: the abort signal's checks inside
746
+ // searchKnowledge are point-in-time, so a Ctrl-C landing after the last one lets
747
+ // the search resolve normally — without this, an interrupted run raced onSignal's
748
+ // flushAndExit and could report ok:true / exit 0. The rationale comment above
749
+ // named exactly this failure; only the throwing half was previously handled.
750
+ if (cancellationRequested) {
751
+ exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
752
+ }
753
+
722
754
  if (json) {
723
755
  // `ok: true` makes `ok` a reliable success/failure discriminant across every
724
756
  // knowledge JSON variant (disabled-store above, reportError's ok:false below,
725
757
  // and this success shape) — additive only, every existing field is unchanged.
726
- toStdout(pretty({ ok: true, ...result }));
758
+ toStdout(pretty({ ok: true, ...(cancellationRequested ? { cancelled: true } : {}), ...result }));
727
759
  } else {
728
760
  toStdout((result.text.endsWith('\n') ? result.text : result.text + '\n'));
729
761
  }
@@ -1158,6 +1190,7 @@ COMMANDS
1158
1190
  --dry-run | --json Plan only; JSON output.
1159
1191
 
1160
1192
  help, --help, -h Show this help.
1193
+ --version, -v Print the pi-research version.
1161
1194
 
1162
1195
  CONFIGURE
1163
1196
  Credentials are resolved in this order (first match wins):
@@ -1372,6 +1405,15 @@ export function parseArgs(argv: string[]): ParsedArgs {
1372
1405
  } else if (a === '--exclude-tools') {
1373
1406
  const v = takeOptionValue(rest, ++i, '--exclude-tools');
1374
1407
  excludeTools = v.split(',').map((s) => s.trim()).filter(Boolean);
1408
+ // Unknown names fail fast: the merge downstream is a blind Set union, so a
1409
+ // typo ("securty_search") silently excluded nothing while the user believed
1410
+ // the tool was off for the whole (billed) run.
1411
+ const unknown = excludeTools.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
1412
+ if (unknown.length > 0) {
1413
+ throw new UsageError(
1414
+ `--exclude-tools: unknown tool name${unknown.length === 1 ? '' : 's'} ` +
1415
+ `${unknown.map((t) => `"${t}"`).join(', ')}. Valid names: ${RESEARCH_TOOL_NAMES.join(', ')}.`);
1416
+ }
1375
1417
  if (excludeTools.length === 0) excludeTools = undefined;
1376
1418
  } else if (a === '--initial-links') {
1377
1419
  // Consume until the next flag or end. Validate each entry: these are templated
@@ -1392,6 +1434,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
1392
1434
  }
1393
1435
  j++;
1394
1436
  }
1437
+ // Every other value-taking flag fails fast on a missing value; a bare
1438
+ // `--initial-links` (immediately followed by another flag or end of args)
1439
+ // silently proceeding with zero links was the one exception.
1440
+ if (j === i + 1) throw new UsageError('--initial-links requires at least one URL.');
1395
1441
  i = j - 1;
1396
1442
  } else if (a === '--config') {
1397
1443
  configPath = takeOptionValue(rest, ++i, '--config');
package/src/config.ts CHANGED
@@ -122,15 +122,6 @@ export const ConfigSchema = Type.Object({
122
122
  * the model's real maxTokens at call time.
123
123
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
124
124
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
125
- /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
126
- * Without a cap the session inherits the model's full catalog ceiling as the
127
- * per-request max_tokens — ~230k for large-context models — which providers that
128
- * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
129
- * reject outright on low balances, even though the actual response would cost
130
- * cents. Default 16384: researcher turns are tool calls plus a cited report, far
131
- * below this. Clamped to the model's real ceiling at session build time.
132
- * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
133
- RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
134
125
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
135
126
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
136
127
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -167,7 +158,8 @@ export const ConfigSchema = Type.Object({
167
158
  /**
168
159
  * Comma-separated list of research tools to DISABLE for every researcher in a run
169
160
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
170
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
161
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
162
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
171
163
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
172
164
  * tools are both removed from each researcher's toolset AND named in the
173
165
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -242,7 +234,6 @@ const USER_MIGRATION_KEYS = [
242
234
  'PI_RESEARCH_LLM_THINKING_LEVEL',
243
235
  'PI_RESEARCH_PLANNING_MAX_TOKENS',
244
236
  'PI_RESEARCH_SYNTHESIS_MAX_TOKENS',
245
- 'PI_RESEARCH_RESEARCHER_MAX_TOKENS',
246
237
  'PI_RESEARCH_MIGRATION_STRATEGY',
247
238
  'PI_RESEARCH_CONSOLE_LOG',
248
239
  'PI_RESEARCH_MODEL',
@@ -875,7 +866,6 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
875
866
  PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
876
867
  PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
877
868
  PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
878
- PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
879
869
  PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
880
870
  PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
881
871
  ...(config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {}),
@@ -1106,7 +1096,6 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
1106
1096
  LLM_THINKING_LEVEL: parseEnvEnum(e, 'PI_RESEARCH_LLM_THINKING_LEVEL', ['off', 'minimal', 'low', 'medium', 'high'] as const, DEFAULTS.LLM_THINKING_LEVEL),
1107
1097
  PLANNING_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_PLANNING_MAX_TOKENS', DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
1108
1098
  SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_SYNTHESIS_MAX_TOKENS', DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
1109
- RESEARCHER_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_RESEARCHER_MAX_TOKENS', DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
1110
1099
  MIGRATION_STRATEGY: parseEnvEnum(e, 'PI_RESEARCH_MIGRATION_STRATEGY', ['drop', 're-embed', 'backup'] as const, DEFAULTS.MIGRATION_STRATEGY),
1111
1100
  CONSOLE_LOG: parseEnvBool(e, 'PI_RESEARCH_CONSOLE_LOG', DEFAULTS.CONSOLE_LOG),
1112
1101
  RESEARCH_MODEL: parseEnvString(e, 'PI_RESEARCH_MODEL', DEFAULTS.RESEARCH_MODEL),
@@ -1226,7 +1215,9 @@ function parseEnvNumber(env: Record<string, string | undefined>, key: string, de
1226
1215
  }
1227
1216
 
1228
1217
  function parseEnvBool(env: Record<string, string | undefined>, key: string, def: boolean): boolean {
1229
- const v = env[key];
1218
+ // Trim like parseEnvNumber does: file values arrive trimmed via parseDotEnv, but a
1219
+ // real env var `PI_RESEARCH_X='true '` otherwise silently read false.
1220
+ const v = env[key]?.trim();
1230
1221
  if (v === undefined || v === '') return def;
1231
1222
  // Strict by design: only the literal 'true' (any case) is truthy; every other value is false.
1232
1223
  // This is intentional and asserted in config.test.ts ('1'/'yes' → false) — do not loosen it.
@@ -1249,7 +1240,7 @@ function parseEnvEnum<T extends string>(
1249
1240
  allowed: readonly T[],
1250
1241
  def: T,
1251
1242
  ): T {
1252
- const raw = env[key];
1243
+ const raw = env[key]?.trim();
1253
1244
  if (raw === undefined || raw === '') return def;
1254
1245
  if ((allowed as readonly string[]).includes(raw)) return raw as T;
1255
1246
  logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(', ')}). Using default "${def}".`);
package/src/constants.ts CHANGED
@@ -182,6 +182,19 @@ export const MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
182
182
  */
183
183
  export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
184
184
 
185
+ /**
186
+ * The complete set of excludable research tool names — the vocabulary
187
+ * `--exclude-tools` / `excludeTools` / PI_RESEARCH_DISABLED_TOOLS accept.
188
+ * Kept here (next to DEFAULT_EXCLUDED_TOOLS) so validation and the exclusion
189
+ * machinery cannot drift; the DISABLED_TOOLS doc in config.ts names the same set.
190
+ * `read` is a researcher tool too (local file reads) — excluding it is a
191
+ * legitimate hardening move, and omitting it from this list broke exactly that
192
+ * on the CLI when validation was introduced.
193
+ */
194
+ export const RESEARCH_TOOL_NAMES: readonly string[] = [
195
+ 'search', 'scrape', 'security_search', 'stackexchange', 'youtube_transcript', 'grep', 'read',
196
+ ];
197
+
185
198
  /**
186
199
  * Resolve the effective researcher exclusion list.
187
200
  *
@@ -2544,15 +2544,6 @@ var ConfigSchema = Type.Object({
2544
2544
  * the model's real maxTokens at call time.
2545
2545
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2546
2546
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2547
- /** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
2548
- * Without a cap the session inherits the model's full catalog ceiling as the
2549
- * per-request max_tokens — ~230k for large-context models — which providers that
2550
- * budget-check max_tokens up front (OpenRouter 402s against remaining credits)
2551
- * reject outright on low balances, even though the actual response would cost
2552
- * cents. Default 16384: researcher turns are tool calls plus a cited report, far
2553
- * below this. Clamped to the model's real ceiling at session build time.
2554
- * Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
2555
- RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2556
2547
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2557
2548
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2558
2549
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
@@ -2589,7 +2580,8 @@ var ConfigSchema = Type.Object({
2589
2580
  /**
2590
2581
  * Comma-separated list of research tools to DISABLE for every researcher in a run
2591
2582
  * (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
2592
- * search, scrape, security_search, stackexchange, youtube_transcript, grep.
2583
+ * search, scrape, security_search, stackexchange, youtube_transcript, grep, read
2584
+ * (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
2593
2585
  * Disable-only: the list is merged into the per-run excludeTools stream, so disabled
2594
2586
  * tools are both removed from each researcher's toolset AND named in the
2595
2587
  * coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
@@ -42,7 +42,23 @@ export async function loadPipelineWithTimeout(
42
42
  interOpNumThreads: 1,
43
43
  },
44
44
  });
45
- return await withTimeout(pipelinePromise, timeoutMs, errorMessage);
45
+ // withTimeout races but cannot cancel: on timeout the underlying pipeline()
46
+ // keeps loading in the background and its eventual ONNX session would leak
47
+ // (the caller's catch disposes this.pipeline, which was never assigned).
48
+ // Dispose the late arrival when it eventually resolves — one orphaned native
49
+ // session per retry adds up on a slow disk.
50
+ let timedOut = true;
51
+ try {
52
+ const p = await withTimeout(pipelinePromise, timeoutMs, errorMessage);
53
+ timedOut = false;
54
+ return p;
55
+ } finally {
56
+ if (timedOut) {
57
+ pipelinePromise
58
+ .then((late) => (late as unknown as { dispose?: () => Promise<void> }).dispose?.())
59
+ .catch(() => { /* load failed after the timeout — nothing to dispose */ });
60
+ }
61
+ }
46
62
  });
47
63
 
48
64
  return { pipeline: loadedPipeline, errorMessage };
@@ -290,6 +290,15 @@ export class KnowledgeStore implements IKnowledgeStore {
290
290
  logger.warn('[store] Failed to read table schema:', schemaErr);
291
291
  }
292
292
  } else {
293
+ // Same warm-up as the migration branch above: createTable() throws
294
+ // "embedder dimension unknown" on a never-initialized embedder. This
295
+ // branch (no existing table — fresh dbDir or first-ever run) hit exactly
296
+ // that when the embedding factory handed back its model-mismatch local
297
+ // fallback, which is constructed but never initialize()d: every one of
298
+ // the caller's init retries burned against the same cached instance.
299
+ if (this.options.embedder.getDimension() === null) {
300
+ await this.withEmbedderReconnect(embedder => embedder.embedMany([' ']));
301
+ }
293
302
  this.table = await this.createTable();
294
303
  }
295
304
 
@@ -787,6 +796,49 @@ export class KnowledgeStore implements IKnowledgeStore {
787
796
  }
788
797
  }
789
798
 
799
+ /**
800
+ * Refresh the timestamp of one CURRENT generation for url+type — the
801
+ * revalidation "touch" for the writer-queue's content-unchanged skip. Without
802
+ * it a stable page kept its original timestamp forever: with
803
+ * KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS set, the serve-age gate then treated the
804
+ * entry as permanently stale (a live re-scrape on every run, for exactly the
805
+ * content that never changes), and TTL eviction eventually deleted an entry
806
+ * that had been re-verified byte-identical possibly hours earlier.
807
+ */
808
+ async touchByUrlAndType(url: string, ingestionType: string, generationTs: number, newTs: number): Promise<void> {
809
+ return this.trackOperation('touchByUrlAndType', undefined, async () => {
810
+ let retryCount = 0;
811
+ const MAX_RETRIES = 5;
812
+ const BASE_DELAY = 100;
813
+ const scopeFilter = this.getScopeFilter();
814
+ while (retryCount <= MAX_RETRIES) {
815
+ const table = await this.getFreshTable();
816
+ try {
817
+ const escapedUrl = url.replace(/'/g, "''");
818
+ const escapedType = ingestionType.replace(/'/g, "''");
819
+ // valuesSql with integer-coerced literals: timestamp is an Int64 column,
820
+ // and both inputs are integer-coerced so the SQL can never carry
821
+ // anything but digits.
822
+ await table.update({
823
+ where: `url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND timestamp = ${Math.floor(generationTs)} AND (${scopeFilter})`,
824
+ valuesSql: { timestamp: String(Math.floor(newTs)) },
825
+ });
826
+ logger.log(`[store] Refreshed timestamp for unchanged ${ingestionType} chunks of ${url}`);
827
+ return;
828
+ } catch (err) {
829
+ if (isLanceCommitConflict(err)) {
830
+ retryCount++;
831
+ if (retryCount > MAX_RETRIES) throw err;
832
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
833
+ await new Promise(r => setTimeout(r, delay));
834
+ continue;
835
+ }
836
+ throw err;
837
+ }
838
+ }
839
+ });
840
+ }
841
+
790
842
  async findDocumentsByUrl(url: string): Promise<StoreDocument[]> {
791
843
  // Coordinate with close(): findByUrl runs via the writer-queue in the post-run
792
844
  // background window (link-description storage), exactly when a Ctrl+C-driven
@@ -1179,7 +1231,18 @@ export class KnowledgeStore implements IKnowledgeStore {
1179
1231
  backupDirs.map(async name => ({ name, ts: await this.backupDirTimestamp(name) }))
1180
1232
  );
1181
1233
  ranked.sort((a, b) => a.ts - b.ts);
1182
- for (const { name } of ranked.slice(0, -1)) {
1234
+ // Retain older backups until they age past the store's own TTL, not just
1235
+ // the single newest one. Two sessions configured with DIFFERENT embedding
1236
+ // models alternate migrations against the shared table (each open sees
1237
+ // "wrong model" → backup → fresh table), and keep-only-newest deleted the
1238
+ // only surviving copy of the first model's data after just two
1239
+ // alternations — silent, permanent loss. A backup older than ttlDays holds
1240
+ // only rows eviction would have deleted anyway, so time-based retention
1241
+ // matches the store's own data-retention contract.
1242
+ const ttlDays = this.options.ttlDays ?? 30;
1243
+ const backupCutoff = Math.trunc(Date.now() - (ttlDays * 24 * 60 * 60 * 1000));
1244
+ for (const { name, ts } of ranked.slice(0, -1)) {
1245
+ if (ts >= backupCutoff) continue;
1183
1246
  const fullPath = path.join(this.options.dbDir, name);
1184
1247
  logger.info(`[store] Removing superseded backup directory: ${name}`);
1185
1248
  await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
@@ -18,6 +18,9 @@ interface WriterQueueOptions {
18
18
  findByUrl: (url: string) => Promise<StoreDocument[]>;
19
19
  deleteByUrlAndType: (url: string, type: string, olderThan?: number) => Promise<void>;
20
20
  addDocuments: (docs: StoreDocument[]) => Promise<void>;
21
+ /** Revalidation touch for the content-unchanged skip (see that path). Optional
22
+ * so lightweight test doubles need not implement it. */
23
+ touchByUrlAndType?: (url: string, type: string, generationTs: number, newTs: number) => Promise<void>;
21
24
  };
22
25
  chunker: Chunker | null;
23
26
  }
@@ -199,6 +202,31 @@ export class WriterQueue implements IWriterQueue {
199
202
  metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
200
203
  });
201
204
  }
205
+ // The content was just re-fetched and verified byte-identical — record
206
+ // that as freshness. Without this touch, a stable page's timestamp never
207
+ // moved: the serve-age gate treated it as permanently stale (live scrape
208
+ // every run) and TTL eviction eventually deleted a revalidated entry.
209
+ //
210
+ // Re-check the newest generation first: a CONCURRENT process may have
211
+ // ingested changed content for this url+type since our dedup read, and
212
+ // stamping our (now-old) generation with a later timestamp would make the
213
+ // stale rows outrank the genuinely newer ones. The touch predicate is
214
+ // pinned to `timestamp = newestTs`, so the residual window is only the
215
+ // gap between this re-read and the update — self-healing on the next
216
+ // real scrape either way.
217
+ if (this.options.store.touchByUrlAndType) {
218
+ try {
219
+ const recheck = (await this.options.store.findByUrl(normalizedUrl))
220
+ .filter(c => c.metadata['ingestionType'] === incomingType);
221
+ const stillNewest = recheck.length > 0
222
+ && Math.max(...recheck.map(d => Number(d.timestamp) || 0)) === newestTs;
223
+ if (stillNewest) {
224
+ await this.options.store.touchByUrlAndType(normalizedUrl, incomingType, newestTs, Date.now());
225
+ }
226
+ } catch (err) {
227
+ logger.warn(`[writer-queue] Timestamp refresh for unchanged ${normalizedUrl} (${incomingType}) failed:`, err);
228
+ }
229
+ }
202
230
  logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
203
231
  return;
204
232
  }
@@ -129,6 +129,19 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
129
129
  });
130
130
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
131
131
  } catch (err) {
132
+ const isTerminal =
133
+ err instanceof ResearchRunCapacityError || (err instanceof Error && err.name === 'AbortError');
134
+ if (isTerminal) {
135
+ // The queue notice above may already have announced this run to the
136
+ // observer (onRunQueued); rethrowing with no terminal event breaks the
137
+ // "exactly one of onComplete/onError" contract both orchestrators keep —
138
+ // an observer-driven consumer saw run_queued and then silence. No
139
+ // orchestrator exists yet, so this cannot double-fire. Observer throws
140
+ // stay isolated, as everywhere else.
141
+ try {
142
+ (observer as { onError?: (e: Error) => void } | undefined)?.onError?.(err as Error);
143
+ } catch { /* observer isolation */ }
144
+ }
132
145
  if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
133
146
  // A cancel while queueing for a slot must stay cancelled. Fail-open here would
134
147
  // start the very run the user just aborted.
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { parseCitations } from '../utils/text-utils.ts';
13
13
  import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
14
- import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
14
+ import { lastCitedLinksHeaderIndex, lastCitedLinksHeader } from '../utils/text-utils.ts';
15
15
  import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
16
16
  import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
17
17
  import { logger } from '../logger.ts';
@@ -321,7 +321,9 @@ export class ResearchSynthesisService implements IService {
321
321
  if (globalId !== undefined) localToGlobal.set(localKey, globalId);
322
322
  else droppedNumbers.add(localKey);
323
323
  });
324
- let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
324
+ // lineStart (not the 'C' offset): slicing at textStart left the tolerated
325
+ // `##`/`**`/`>` marker dangling as a stray line at the end of the body.
326
+ let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
325
327
  if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
326
328
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
327
329
  }
@@ -85,17 +85,6 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
85
85
 
86
86
  logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
87
87
 
88
- // The session sends model.maxTokens as the per-request max_tokens. Large-context
89
- // catalog entries carry ~230k ceilings, and providers that budget-check max_tokens
90
- // up front (OpenRouter 402s it against remaining credits) then reject every
91
- // researcher call on a low balance even though the real response costs cents.
92
- // Clamp the session's copy of the model to RESEARCHER_MAX_TOKENS; Model is plain
93
- // catalog data, so a spread clone is safe and the shared registry entry is untouched.
94
- const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
95
- const sessionModel = modelToUse.maxTokens > outputCap
96
- ? { ...modelToUse, maxTokens: outputCap }
97
- : modelToUse;
98
-
99
88
  try {
100
89
  const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
101
90
 
@@ -149,7 +138,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
149
138
  tools, // Explicit allowlist (BUG-1 fix)
150
139
  sessionManager: SessionManager.inMemory(), // Each researcher gets its own isolated session
151
140
  settingsManager: researcherSettings,
152
- model: sessionModel,
141
+ model: modelToUse,
153
142
  // pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
154
143
  // with the async `modelRuntime`. Pass the runtime backing OUR registry when
155
144
  // this run built one (standalone CLI/SDK): it carries any explicit API key
@@ -196,7 +185,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
196
185
 
197
186
  sessionRef.session = result.session;
198
187
 
199
- return { session: result.session, resolvedModel: sessionModel };
188
+ return { session: result.session, resolvedModel: modelToUse };
200
189
  } catch (tailError) {
201
190
  const session = result?.session;
202
191
  if (session && typeof session.abort === 'function') {
@@ -28,7 +28,7 @@ import { redactSecrets } from '../utils/log-utils.ts';
28
28
  import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
29
29
  import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
30
30
  import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
31
- import { startResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
31
+ import { startResearchSession, endResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
32
32
  import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
33
33
  import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
34
34
  import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
@@ -339,6 +339,13 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
339
339
  registerSessionAbort(piSessionId, sessionResearchId, internalAbort);
340
340
 
341
341
  cleanup = async () => {
342
+ // Free what startResearchSession/registerSessionAbort created — the
343
+ // failures/failureReasons entries and the AbortController live in the
344
+ // module-level piSessions map keyed by the STABLE pi session id, so
345
+ // without this every headless run leaked them for the process
346
+ // lifetime (the TUI branch frees them via createCleanupFunction, the
347
+ // SDK in its own finally; this branch freed only steering).
348
+ endResearchSession(piSessionId, sessionResearchId);
342
349
  clearSteeringMessages(piSessionId);
343
350
  };
344
351
 
@@ -1,4 +1,4 @@
1
- import { parseCitations, lastCitedLinksHeaderIndex, type Citation } from './text-utils.ts';
1
+ import { parseCitations, lastCitedLinksHeader, type Citation } from './text-utils.ts';
2
2
  import { normalizeUrl } from './shared-links.ts';
3
3
 
4
4
  export interface GlobalCitation extends Citation {
@@ -66,7 +66,9 @@ export function normalizeCitations(reports: ReadonlyMap<string, string>): {
66
66
  // line-leading, all-caps header, never an incidental "cited links" mention in
67
67
  // prose (an unanchored split there would drop real findings before the
68
68
  // evaluator ever sees them).
69
- const headerIdx = lastCitedLinksHeaderIndex(report);
69
+ // lineStart (not textStart): slicing at the 'C' left the tolerated `##`/`**`/`>`
70
+ // marker dangling at the end of the kept body.
71
+ const headerIdx = lastCitedLinksHeader(report).lineStart;
70
72
  let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
71
73
 
72
74
  // Renumber local [N] markers to their global ids, and DELETE the ones that could
@@ -253,17 +253,31 @@ function isPlausibleCitationUrl(url: string): boolean {
253
253
  * against the `CITED LINKS\n...` block that formatCitedLinks() emits.
254
254
  */
255
255
  export function lastCitedLinksHeaderIndex(text: string): number {
256
+ return lastCitedLinksHeader(text).textStart;
257
+ }
258
+
259
+ /**
260
+ * Both offsets of the last CITED LINKS header: `textStart` points at the 'C'
261
+ * (what parseCitations slices FROM), `lineStart` points at the start of the
262
+ * header line BEFORE any `#`/`>`/`**` markers (what body-replacement slices TO).
263
+ * Slicing the body at textStart left the tolerated markdown marker dangling at
264
+ * the end of the body — the rebuilt report then carried a stray `##` or `**`
265
+ * line whenever the model wrote `## CITED LINKS` instead of the plain form.
266
+ */
267
+ export function lastCitedLinksHeader(text: string): { lineStart: number; textStart: number } {
256
268
  // Case-insensitive but LINE-LEADING: a mid-prose "...the cited links below..." has
257
269
  // non-whitespace before it on the line, so it cannot match — only a header at the
258
270
  // start of a line (after optional markdown markers) does.
259
271
  const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
260
- let last = -1;
272
+ let lineStart = -1;
273
+ let textStart = -1;
261
274
  let m: RegExpExecArray | null;
262
275
  while ((m = re.exec(text)) !== null) {
276
+ lineStart = m.index + m[1]!.length;
263
277
  // Offset of the 'C' in CITED LINKS = match start + leading newline + markers.
264
- last = m.index + m[1]!.length + m[2]!.length;
278
+ textStart = m.index + m[1]!.length + m[2]!.length;
265
279
  }
266
- return last;
280
+ return { lineStart, textStart };
267
281
  }
268
282
 
269
283
  /**