@lincoln504/pi-research 1.1.0 → 1.1.2
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 +2 -2
- package/dist/cli.mjs +111 -54
- package/dist/thread-worker.mjs +11 -1
- package/docs/CHANGELOG.md +26 -0
- package/docs/CONFIGURATION.md +1 -1
- package/docs/KNOWLEDGE-STORE.md +1 -1
- package/docs/SDK.md +8 -5
- package/package.json +1 -1
- package/scripts/build.cjs +23 -5
- package/skills/pi-research/SKILL.md +3 -3
- package/skills/pi-research/references/configuration.md +1 -1
- package/src/cli.ts +14 -2
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/infrastructure/browser/browser-error-utils.ts +7 -1
- package/src/infrastructure/browser/thread-worker.mjs +11 -1
- package/src/orchestration/research-synthesis-service.ts +1 -81
- package/src/tools/research-knowledge-search.ts +8 -2
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +103 -7
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/retry-utils.ts +13 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ knowledge store on your machine.
|
|
|
32
32
|
|
|
33
33
|
A research run is a loop of agent teams: a coordinator plans the work and runs an
|
|
34
34
|
opening search, parallel researcher agents scrape and read sources through a fixed,
|
|
35
|
-
sandboxed toolkit (no
|
|
35
|
+
sandboxed toolkit (no shell access, and no ability to write or modify files), and an evaluator decides whether
|
|
36
36
|
to run another round or synthesize — returning one cited Markdown report while its
|
|
37
37
|
sources are saved to the knowledge store in the background.
|
|
38
38
|
|
|
@@ -55,7 +55,7 @@ ask in natural language and the tool picks the right one:
|
|
|
55
55
|
- **Free, unlimited search & scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use, never per search or per month.
|
|
56
56
|
- **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers. No borrowed, server-side index you can't keep or query. Findings can also be kept **globally**, scoped to a **single project**, or **not kept at all** — set per directory, changed whenever you like. The optionality is the point: the store is yours to build, partition, or switch off.
|
|
57
57
|
- **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report to the chat instead of dumping raw web content into the conversation — context-efficient and easy to verify.
|
|
58
|
-
- **Sandboxed by design.** Web access runs inside a specialized, limited research agent
|
|
58
|
+
- **Sandboxed by design.** Web access runs inside a specialized, limited research agent that cannot run shell commands or write, edit, or delete anything. It can read and search files in the working directory, so scope that directory as you would for any coding agent.
|
|
59
59
|
- **Search a little or a lot** — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
|
|
60
60
|
|
|
61
61
|
### Requirements / limitations
|
package/dist/cli.mjs
CHANGED
|
@@ -1709,9 +1709,23 @@ var init_metrics = __esm({
|
|
|
1709
1709
|
});
|
|
1710
1710
|
|
|
1711
1711
|
// src/utils/llm-usage.ts
|
|
1712
|
+
function warnIfUnpriced(model, tokens, cost) {
|
|
1713
|
+
if (tokens <= 0 || cost > 0) return;
|
|
1714
|
+
const m = model;
|
|
1715
|
+
const key = `${m.provider ?? "unknown"}/${m.id ?? "unknown"}`;
|
|
1716
|
+
if (unpricedModelsWarned.has(key)) return;
|
|
1717
|
+
const table = m.cost;
|
|
1718
|
+
const allZero = !table || Object.values(table).every((v) => typeof v !== "number" || v === 0);
|
|
1719
|
+
if (!allZero) return;
|
|
1720
|
+
unpricedModelsWarned.add(key);
|
|
1721
|
+
logger.warn(
|
|
1722
|
+
`[LlmUsage] Model "${key}" consumed ${tokens} tokens but reports $0.00 \u2014 its price table is all zeros. Expected for flat-rate plans and local servers. Otherwise the price data is missing: a hand-written "models" entry in ~/.pi/agent/models.json replaces pi's catalog entry (and its pricing) for the same model id \u2014 remove it and let pi's catalog supply the model, or use "modelOverrides", which merges.`
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1712
1725
|
function recordLlmUsage(model, rawUsage, opts) {
|
|
1713
1726
|
if (!rawUsage) return { tokens: 0, cost: 0 };
|
|
1714
1727
|
const { tokens, cost } = extractUsage(model, rawUsage);
|
|
1728
|
+
warnIfUnpriced(model, tokens, cost);
|
|
1715
1729
|
if (tokens > 0 || cost > 0) {
|
|
1716
1730
|
const labels = { component: opts.component };
|
|
1717
1731
|
if (opts.complexity !== void 0) labels["complexity"] = String(opts.complexity);
|
|
@@ -1721,11 +1735,14 @@ function recordLlmUsage(model, rawUsage, opts) {
|
|
|
1721
1735
|
}
|
|
1722
1736
|
return { tokens, cost };
|
|
1723
1737
|
}
|
|
1738
|
+
var unpricedModelsWarned;
|
|
1724
1739
|
var init_llm_usage = __esm({
|
|
1725
1740
|
"src/utils/llm-usage.ts"() {
|
|
1726
1741
|
"use strict";
|
|
1727
1742
|
init_llm();
|
|
1743
|
+
init_logger();
|
|
1728
1744
|
init_metrics();
|
|
1745
|
+
unpricedModelsWarned = /* @__PURE__ */ new Set();
|
|
1729
1746
|
}
|
|
1730
1747
|
});
|
|
1731
1748
|
|
|
@@ -3292,6 +3309,13 @@ function constructMinimalModel(provider, modelId, _apiKey) {
|
|
|
3292
3309
|
// Provider-specific base URLs are handled by pi-ai internal registry
|
|
3293
3310
|
reasoning: false,
|
|
3294
3311
|
input: ["text"],
|
|
3312
|
+
// No catalog is available on this path, so there is no price data and no real
|
|
3313
|
+
// context/output ceiling to read. The zeros below are "unknown", NOT "free":
|
|
3314
|
+
// cost reporting for this model will read $0.00 (see warnIfUnpriced in
|
|
3315
|
+
// utils/llm-usage.ts), and the window figures are conservative guesses, not the
|
|
3316
|
+
// model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
|
|
3317
|
+
// ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
|
|
3318
|
+
// full context window should give pi a credential so it can fetch the real catalog.
|
|
3295
3319
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
3296
3320
|
contextWindow: 128e3,
|
|
3297
3321
|
maxTokens: 32768,
|
|
@@ -3309,7 +3333,7 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
|
|
|
3309
3333
|
if (apiKey && provider) {
|
|
3310
3334
|
return constructMinimalModel(prov, modelId, apiKey);
|
|
3311
3335
|
}
|
|
3312
|
-
throw new Error(
|
|
3336
|
+
throw new Error(buildModelNotFoundMessage(modelSpec));
|
|
3313
3337
|
}
|
|
3314
3338
|
const allModels = safeGetAll(registry);
|
|
3315
3339
|
const sameId = allModels.filter((m) => m.id === modelSpec);
|
|
@@ -3340,9 +3364,23 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
|
|
|
3340
3364
|
}
|
|
3341
3365
|
return constructMinimalModel(provider, modelSpec, apiKey);
|
|
3342
3366
|
}
|
|
3343
|
-
throw new Error(
|
|
3344
|
-
|
|
3345
|
-
|
|
3367
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
3368
|
+
}
|
|
3369
|
+
function credentialGuidance() {
|
|
3370
|
+
const agentDir = getAgentDir();
|
|
3371
|
+
return ` \u2022 Provider pi supports built-in (openrouter, anthropic, openai, \u2026): add a credential \u2014 run \`pi\` and use /login, set the provider's API-key environment variable, or add it to ${path6.join(agentDir, "auth.json")}. pi then fetches that provider's model catalog automatically.
|
|
3372
|
+
\u2022 Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path6.join(agentDir, "models.json")}.
|
|
3373
|
+
Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and
|
|
3374
|
+
context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead \u2014 it merges.`;
|
|
3375
|
+
}
|
|
3376
|
+
function buildModelNotFoundMessage(modelSpec) {
|
|
3377
|
+
return `Model "${modelSpec}" not found in pi's model registry.
|
|
3378
|
+
${credentialGuidance()}`;
|
|
3379
|
+
}
|
|
3380
|
+
function buildNoModelAvailableMessage() {
|
|
3381
|
+
return `No LLM model available.
|
|
3382
|
+
${credentialGuidance()}
|
|
3383
|
+
\u2022 Or pass an explicit apiKey.`;
|
|
3346
3384
|
}
|
|
3347
3385
|
function readModelsJsonProviderOrder() {
|
|
3348
3386
|
const modelsJsonPath = path6.join(getAgentDir(), "models.json");
|
|
@@ -3428,6 +3466,7 @@ function withTimeout2(promise, timeoutMs, label, signal) {
|
|
|
3428
3466
|
logger.debug(`[withTimeout] Starting ${timeoutMs}ms timeout guard (external signal: ${signal?.aborted ?? "no signal"})`);
|
|
3429
3467
|
return new Promise((resolve8, reject) => {
|
|
3430
3468
|
if (combinedSignal.aborted) {
|
|
3469
|
+
void promise.catch(() => void 0);
|
|
3431
3470
|
logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
|
|
3432
3471
|
return reject(new Error(`${label} cancelled or timed out`));
|
|
3433
3472
|
}
|
|
@@ -3533,7 +3572,7 @@ async function retryWithBackoff(fn, options = {}) {
|
|
|
3533
3572
|
metrics.observe("retry_backoff_delay_ms", delay2, { attempt: String(attempt), label: opts.label });
|
|
3534
3573
|
logger.warn(`[Retry] ${opts.label} failed (attempt ${attempt + 1}/${opts.maxRetries + 1}), retrying in ${Math.round(delay2)}ms: ${lastError.message}`);
|
|
3535
3574
|
try {
|
|
3536
|
-
await abortableDelay(delay2, opts.signal);
|
|
3575
|
+
await abortableDelay(delay2, opts.signal, true);
|
|
3537
3576
|
} catch {
|
|
3538
3577
|
metrics.increment("retry_aborted_total", 1, { label: opts.label });
|
|
3539
3578
|
throw lastError;
|
|
@@ -4052,6 +4091,11 @@ var init_circuit_breaker = __esm({
|
|
|
4052
4091
|
metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
|
|
4053
4092
|
if (this.options.isTransientError(error)) {
|
|
4054
4093
|
this.onFailure(error);
|
|
4094
|
+
} else if (admittedAsProbe && this.state === "HALF_OPEN") {
|
|
4095
|
+
logger.warn(
|
|
4096
|
+
`[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
|
|
4097
|
+
);
|
|
4098
|
+
this.transitionTo("OPEN");
|
|
4055
4099
|
}
|
|
4056
4100
|
throw error;
|
|
4057
4101
|
} finally {
|
|
@@ -8039,7 +8083,7 @@ function resolveResearchModel(options) {
|
|
|
8039
8083
|
if (anyModel.length > 0) {
|
|
8040
8084
|
return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder());
|
|
8041
8085
|
}
|
|
8042
|
-
throw new Error(
|
|
8086
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
8043
8087
|
}
|
|
8044
8088
|
var init_research_model_resolver = __esm({
|
|
8045
8089
|
"src/core/llm/research-model-resolver.ts"() {
|
|
@@ -8341,7 +8385,7 @@ async function runBackgroundExtraction(model, auth, conversationHistory, queries
|
|
|
8341
8385
|
const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
8342
8386
|
metrics.increment("research_knowledge_search_synthesis_retries_total", 1);
|
|
8343
8387
|
logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
|
|
8344
|
-
await abortableDelay(delay2, signal);
|
|
8388
|
+
await abortableDelay(delay2, signal, true);
|
|
8345
8389
|
}
|
|
8346
8390
|
}
|
|
8347
8391
|
const extracted = extractJson(responseText, "object");
|
|
@@ -8429,7 +8473,7 @@ ${desc}`;
|
|
|
8429
8473
|
const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
8430
8474
|
metrics.increment("research_knowledge_search_triage_retries_total", 1);
|
|
8431
8475
|
logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
|
|
8432
|
-
await abortableDelay(delay2, signal);
|
|
8476
|
+
await abortableDelay(delay2, signal, true);
|
|
8433
8477
|
}
|
|
8434
8478
|
}
|
|
8435
8479
|
const extracted = extractJson(responseText, "object");
|
|
@@ -9557,11 +9601,11 @@ function normalizeCitations(reports) {
|
|
|
9557
9601
|
});
|
|
9558
9602
|
const headerIdx = lastCitedLinksHeaderIndex(report);
|
|
9559
9603
|
let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
|
|
9560
|
-
content =
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9604
|
+
content = rewriteCitationMarkers(
|
|
9605
|
+
content,
|
|
9606
|
+
localToGlobal,
|
|
9607
|
+
(n) => !localToGlobal.has(n)
|
|
9608
|
+
);
|
|
9565
9609
|
normalizedReports.set(id, content.trim());
|
|
9566
9610
|
}
|
|
9567
9611
|
return { normalizedReports, globalCitations };
|
|
@@ -9576,6 +9620,42 @@ function formatCitedLinks(citations) {
|
|
|
9576
9620
|
return `CITED LINKS
|
|
9577
9621
|
${links.join("\n")}`;
|
|
9578
9622
|
}
|
|
9623
|
+
var MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
9624
|
+
function rewriteCitationMarkers(text, remap, isDangling) {
|
|
9625
|
+
const inFence = fencedCodeMask(text);
|
|
9626
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
|
|
9627
|
+
const n = parseInt(digits, 10);
|
|
9628
|
+
const mapped = remap.get(n);
|
|
9629
|
+
if (mapped !== void 0) return `${pad}[${mapped}]`;
|
|
9630
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
9631
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
9632
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
9633
|
+
return text.slice(lineStart, offset).trim() === "" ? pad : "";
|
|
9634
|
+
});
|
|
9635
|
+
}
|
|
9636
|
+
function fencedCodeMask(text) {
|
|
9637
|
+
const ranges = [];
|
|
9638
|
+
let open4 = -1;
|
|
9639
|
+
let pos = 0;
|
|
9640
|
+
for (const line of text.split("\n")) {
|
|
9641
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
9642
|
+
if (open4 < 0) open4 = pos;
|
|
9643
|
+
else {
|
|
9644
|
+
ranges.push([open4, pos + line.length]);
|
|
9645
|
+
open4 = -1;
|
|
9646
|
+
}
|
|
9647
|
+
}
|
|
9648
|
+
pos += line.length + 1;
|
|
9649
|
+
}
|
|
9650
|
+
if (open4 >= 0) ranges.push([open4, text.length]);
|
|
9651
|
+
return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
9652
|
+
}
|
|
9653
|
+
function inInlineCodeSpan(text, offset) {
|
|
9654
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
9655
|
+
let ticks = 0;
|
|
9656
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
|
|
9657
|
+
return ticks % 2 === 1;
|
|
9658
|
+
}
|
|
9579
9659
|
|
|
9580
9660
|
// src/core/planning-service.ts
|
|
9581
9661
|
init_agentic_repair();
|
|
@@ -10813,7 +10893,12 @@ function isServerDrainingError(error) {
|
|
|
10813
10893
|
function isPoolShutdownError(error) {
|
|
10814
10894
|
if (!error || typeof error !== "object") return false;
|
|
10815
10895
|
const err = error;
|
|
10816
|
-
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
10896
|
+
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
|
|
10897
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
10898
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
10899
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
10900
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
10901
|
+
err.message.includes("Worker pool is being reset"));
|
|
10817
10902
|
}
|
|
10818
10903
|
var sessionCircuitBreakers = /* @__PURE__ */ new Map();
|
|
10819
10904
|
var MAX_SESSION_BREAKERS = 256;
|
|
@@ -22178,42 +22263,6 @@ init_service_registry();
|
|
|
22178
22263
|
init_service_names();
|
|
22179
22264
|
var NO_SOURCES_NOTE = "CITED LINKS\n_No web sources were successfully retrieved for this report \u2014 searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._";
|
|
22180
22265
|
var UNVERIFIED_LINKS_NOTE = "_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented \u2014 check each one before relying on it._";
|
|
22181
|
-
var MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
22182
|
-
function rewriteCitationMarkers(text, remap, isDangling) {
|
|
22183
|
-
const inFence = fencedCodeMask(text);
|
|
22184
|
-
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
|
|
22185
|
-
const n = parseInt(digits, 10);
|
|
22186
|
-
const mapped = remap.get(n);
|
|
22187
|
-
if (mapped !== void 0) return `${pad}[${mapped}]`;
|
|
22188
|
-
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
22189
|
-
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
22190
|
-
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22191
|
-
return text.slice(lineStart, offset).trim() === "" ? pad : "";
|
|
22192
|
-
});
|
|
22193
|
-
}
|
|
22194
|
-
function fencedCodeMask(text) {
|
|
22195
|
-
const ranges = [];
|
|
22196
|
-
let open4 = -1;
|
|
22197
|
-
let pos = 0;
|
|
22198
|
-
for (const line of text.split("\n")) {
|
|
22199
|
-
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
22200
|
-
if (open4 < 0) open4 = pos;
|
|
22201
|
-
else {
|
|
22202
|
-
ranges.push([open4, pos + line.length]);
|
|
22203
|
-
open4 = -1;
|
|
22204
|
-
}
|
|
22205
|
-
}
|
|
22206
|
-
pos += line.length + 1;
|
|
22207
|
-
}
|
|
22208
|
-
if (open4 >= 0) ranges.push([open4, text.length]);
|
|
22209
|
-
return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
22210
|
-
}
|
|
22211
|
-
function inInlineCodeSpan(text, offset) {
|
|
22212
|
-
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22213
|
-
let ticks = 0;
|
|
22214
|
-
for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
|
|
22215
|
-
return ticks % 2 === 1;
|
|
22216
|
-
}
|
|
22217
22266
|
function redactUnverifiedProseUrls(text, trusted) {
|
|
22218
22267
|
if (trusted.size === 0) return text;
|
|
22219
22268
|
let removed = 0;
|
|
@@ -23255,8 +23304,8 @@ function appendExportMessage(result, filepath, totalCost) {
|
|
|
23255
23304
|
---
|
|
23256
23305
|
|
|
23257
23306
|
Research report saved to: ${filepath}`;
|
|
23258
|
-
if (totalCost !== void 0
|
|
23259
|
-
const costStr = totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
|
|
23307
|
+
if (totalCost !== void 0) {
|
|
23308
|
+
const costStr = totalCost === 0 ? "$0.00" : totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
|
|
23260
23309
|
footer += `
|
|
23261
23310
|
Total cost: ${costStr}`;
|
|
23262
23311
|
}
|
|
@@ -23339,10 +23388,16 @@ function makeProgressObserver() {
|
|
|
23339
23388
|
toStderr(` \u2022 evaluator: ${data?.action}
|
|
23340
23389
|
`);
|
|
23341
23390
|
break;
|
|
23342
|
-
case "tokens_consumed":
|
|
23343
|
-
|
|
23391
|
+
case "tokens_consumed": {
|
|
23392
|
+
const consumed = Number(data?.tokens ?? 0);
|
|
23393
|
+
const costVal = typeof data?.cost === "number" ? data.cost : void 0;
|
|
23394
|
+
if (consumed > 0 || costVal !== void 0) {
|
|
23395
|
+
const costPart = costVal === void 0 ? "" : ` ($${costVal.toFixed(4)})`;
|
|
23396
|
+
toStderr(` \u2022 tokens: ${consumed}${costPart}
|
|
23344
23397
|
`);
|
|
23398
|
+
}
|
|
23345
23399
|
break;
|
|
23400
|
+
}
|
|
23346
23401
|
case "complete":
|
|
23347
23402
|
toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
|
|
23348
23403
|
`);
|
|
@@ -23984,7 +24039,9 @@ CONFIGURE
|
|
|
23984
24039
|
EXIT CODES
|
|
23985
24040
|
0 success
|
|
23986
24041
|
64 bad arguments
|
|
24042
|
+
75 at capacity \u2014 every concurrent run slot is busy; nothing is broken, retry later
|
|
23987
24043
|
78 not configured \u2014 message prints the exact locations to fix
|
|
24044
|
+
(on \`knowledge\`, also returned when the store is deliberately disabled)
|
|
23988
24045
|
70 runtime error (network, provider, internal)
|
|
23989
24046
|
`;
|
|
23990
24047
|
}
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -1284,6 +1284,11 @@ var init_circuit_breaker = __esm({
|
|
|
1284
1284
|
metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
|
|
1285
1285
|
if (this.options.isTransientError(error)) {
|
|
1286
1286
|
this.onFailure(error);
|
|
1287
|
+
} else if (admittedAsProbe && this.state === "HALF_OPEN") {
|
|
1288
|
+
logger.warn(
|
|
1289
|
+
`[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
|
|
1290
|
+
);
|
|
1291
|
+
this.transitionTo("OPEN");
|
|
1287
1292
|
}
|
|
1288
1293
|
throw error;
|
|
1289
1294
|
} finally {
|
|
@@ -1379,7 +1384,12 @@ function isServerDrainingError(error) {
|
|
|
1379
1384
|
function isPoolShutdownError(error) {
|
|
1380
1385
|
if (!error || typeof error !== "object") return false;
|
|
1381
1386
|
const err = error;
|
|
1382
|
-
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
1387
|
+
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
|
|
1388
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
1389
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
1390
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
1391
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
1392
|
+
err.message.includes("Worker pool is being reset"));
|
|
1383
1393
|
}
|
|
1384
1394
|
var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
|
|
1385
1395
|
var init_browser_error_utils = __esm({
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.1.2] - 2026-08-04
|
|
9
|
+
|
|
10
|
+
> Supersedes `v1.1.1`, which was tagged but never published: its release run was
|
|
11
|
+
> correctly blocked by a red CI, and the tag is retained only as a marker of that
|
|
12
|
+
> attempt. Everything below is the full set of changes since 1.1.0.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **The multi-process run-cap test counted cumulative slot acquisitions as concurrent ones.** The "thundering herd" test asserts the *concurrent* cap but tallied *cumulative* `acquired` events, and let its winners hold for a finite 4 s. On a slow runner the six contenders start staggered, so a late one can attempt after an early winner's hold has already elapsed, legitimately win the freed slot, and push the cumulative tally past the cap while at most `MAX` ever held at once — a false failure that blocked this release on Windows CI. Confirmed by direct reproduction: with the stagger forced, four acquisitions were recorded against a maximum of two concurrent holders, the late pair's acquire timestamps falling strictly after the early pair's releases on reused slots. Winners now hold until killed, so there is no free slot to win and the count measures genuine simultaneity — a strictly stronger assertion. Test-only; the semaphore itself was never at fault and is unchanged. (The sibling "distinct slot assignments" test was fixed the same way in 1.1.0; that change cited this test's own stated principle but left this test on the racy pattern.)
|
|
16
|
+
- **Citation markers could be silently retargeted at the wrong source.** When merging researcher reports, `normalizeCitations` renumbers each report's local `[N]` markers to global ids — but a marker it could not map was left *verbatim* instead of being removed. Global ids are assigned densely from 1, so a surviving `[1]` no longer meant "this report's first source": it resolved against the renumbered global list and pointed at an unrelated document. Markers go unmapped whenever `parseCitations` legitimately drops an entry, e.g. a URL whose hostname has no dot (`localhost`, a bare internal host, a malformed line). This was not cosmetic — the normalized body is fed straight into the evaluator/synthesis prompt, which is explicitly told the numbering is already global, so the model faithfully reproduced the wrong attribution, and `ensureCitedLinks` then regenerated a `CITED LINKS` list perfectly consistent with it. A correct structure-preserving rewriter already existed on the synthesis path (added in 1.0.11); it is now shared, so dangling markers are removed cleanly while fenced blocks and inline-code spans — a quoted `items[12]` — stay untouched. The prior test could never have caught this: its assertion was structurally guaranteed by the CITED-LINKS slice regardless of marker handling.
|
|
17
|
+
- **A retry could silently abandon the operation instead of retrying it.** `retryWithBackoff` awaited its inter-attempt sleep on an `unref`'d timer. The sleep *is* the operation — the retry has already been decided and the next attempt must run — and `abortableDelay`'s own docstring says exactly that (`web-scraper.ts` passes `keepAlive` with a comment explaining why); the backoff simply omitted it. The failure compounds itself: the transient network errors that trigger a retry are precisely the ones that leave no referenced socket handle, so the backoff is frequently the *only* pending handle, and Node drains the event loop and exits mid-retry. The next attempt never runs and nothing is reported — in the CLI neither branch of the top-level handler fires, so the user gets a bare exit with no report and no error. Affected every `retryWithBackoff` caller (LLM planning, all four security clients, YouTube transcripts) plus both knowledge-search retry loops.
|
|
18
|
+
- **The circuit breaker could pin permanently in `HALF_OPEN`, collapsing parallel search bursts.** `onFailure` is the only path back to `OPEN` and is skipped for errors the breaker does not count, while `transitionTo('HALF_OPEN')` never re-arms `nextAttemptTime` — so a trial probe failing with a non-counted error left the breaker stuck with no time-based escape, fast-failing every caller beyond `halfOpenMaxCalls` (default 1) indefinitely. This was not a corner case for the browser pool: `DEFAULT_BREAKER_CONFIG.isTransientError` deliberately excludes pool-shutdown, draining, Cloudflare and task-timeout errors — exactly what a recovering or blocked pool returns — so the probe most likely to run was also the one most likely to pin it. A non-counted probe failure is now treated as *inconclusive* and re-opens with a fresh reset window.
|
|
19
|
+
- **`npm install <dir>`, `file:`, `npm link` and `pi install <dir>` aborted during install.** The published package ships the built `skills/pi-research/scripts/run.mjs` and deliberately excludes its `run.ts` source, yet also ships `prepare.cjs` + `build.cjs` — and `prepare` runs `build.cjs all`, whose `skill` target hard-requires `run.ts` as its esbuild entry point. Running the shipped build against the shipped tree failed with `Could not resolve run.ts` and exit 1. Registry `.tgz` and `npx` installs were unaffected only because `prepare` does not run there. The target now no-ops when `run.ts` is absent and `run.mjs` is already built, and still errors when neither exists.
|
|
20
|
+
- **A pool-reset error told callers to retry, but nothing recognised it.** `worker-pool-manager` throws `Worker pool is being reset, please retry` while auto-recovery swaps the pool, and no error classifier matched that string — so the first attempt rethrew instead of retrying. Auto-recovery fires only after 3 consecutive worker failures, i.e. exactly when a burst of tasks is in flight and would all fail together: the same user-visible shape as the leader-handover loss fixed in 1.1.0, entering through a different door. Now classified as a transient pool-unavailable condition.
|
|
21
|
+
- **Cancelling a run at the wrong moment could kill the process.** `withTimeout`'s early-abort path returned before attaching to the caller-supplied promise, orphaning that promise's eventual rejection. Node 22+ defaults to `--unhandled-rejections=throw`, and the main process registers no `unhandledRejection` handler, so an Esc timed between two awaits (reachable via the YouTube transcript client, which threads the same signal into both) crashed the host instead of cancelling cleanly. The in-flight rejection is now adopted before bailing out.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Model-resolution errors now lead with the credential path instead of pointing at `models.json`.** For a provider pi ships built in (openrouter, anthropic, openai, …), the fix for an unresolved model is a credential — after which pi fetches that provider's catalog, with real pricing and context limits, on its own. Both error messages instead named `~/.pi/agent/models.json` as the remedy, which is wrong for exactly that case and demonstrably led users to hand-write `models[]` entries. Because such an entry **replaces** pi's catalog entry wholesale rather than merging into it (`modelOverrides` is the merging mechanism), doing so silently zeroed out the model's pricing and capped its context and output windows. The messages now name `/login`, `auth.json` and the provider env var first, scope `models.json` to genuinely custom or self-hosted providers, and warn about the replace-vs-merge distinction.
|
|
25
|
+
- **A cost of exactly `$0.00` is now shown rather than omitted.** Cost is computed locally as tokens × the model's price table, never read off the wire, so a model whose table is all zeros bills tokens and reports zero indefinitely. Every display site gated on `cost > 0`, which made a misconfigured price table indistinguishable from "nothing reported yet" — and on the CLI progress line a falsy cost suppressed the **token count** as well, so the run appeared to consume nothing. Zero is also the honest figure for flat-rate plans and local models. `recordLlmUsage` additionally warns once per model when tokens are billed against an all-zero table, naming the usual cause.
|
|
26
|
+
- **Session Metrics now actually reports cost.** The view has always advertised "API cost estimates", but `SessionStats` carried no cost field to show; it now aggregates and displays one.
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
- **`README.md` claimed the research agent has "no filesystem or shell access" and is "safe to point at anything".** The shell half is accurate; the filesystem half was not — `read` and `grep` are registered unconditionally and the researcher exclusion list (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) never excluded them. `ARCHITECTURE.md` documented this correctly, so the README was the outlier — and it is the sentence a user relies on when deciding whether to run this against a sensitive directory. Corrected to what the code actually enforces: no shell, no writes, but reads and searches within the working directory.
|
|
30
|
+
- **The `knowledge-config` example was an unquoted shell pipe.** `... knowledge-config set none|project|global` sat inside a block `SKILL.md` explicitly instructs the agent to run on the user's behalf; pasted literally, bash executes `set none` and pipes into two "command not found" lines — so a user asking to *enable* the store would have it silently *disabled*. Now a `<none|project|global>` placeholder, fixed in all four copies.
|
|
31
|
+
- **Exit-code documentation gaps.** `75` (at capacity) — 1.1.0's headline exit code — was missing from the CLI's own `--help`. And `78` from `knowledge` covers two very different conditions (store deliberately disabled vs. missing credentials) while `SKILL.md` documented only the first, telling agents to stay silent and continue on what is actually a hard configuration failure. Both surfaces now distinguish them.
|
|
32
|
+
- **`docs/SDK.md` recommended a loader recipe that cannot work.** It suggested `node --experimental-transform-types your-script.ts` for consuming the TypeScript entrypoint; Node refuses to strip *or* transform TypeScript under `node_modules`, failing with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. A loader (`tsx`/`ts-node`) or the pi host is mandatory, and the limitation is now stated.
|
|
33
|
+
|
|
8
34
|
## [1.1.0] - 2026-08-04
|
|
9
35
|
|
|
10
36
|
### Changed
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -218,7 +218,7 @@ set the knowledge-store mode per directory directly:
|
|
|
218
218
|
|
|
219
219
|
```sh
|
|
220
220
|
pi-research knowledge-config # show the mode here and where it comes from
|
|
221
|
-
pi-research knowledge-config set none|project|global
|
|
221
|
+
pi-research knowledge-config set <none|project|global> # pick ONE value
|
|
222
222
|
```
|
|
223
223
|
|
|
224
224
|
Under the agent skill you don't have to run this yourself — you ask the agent (e.g. "disable the
|
package/docs/KNOWLEDGE-STORE.md
CHANGED
|
@@ -44,7 +44,7 @@ a project-scoped setting you can change per directory:
|
|
|
44
44
|
| `none` | The store is disabled — nothing is read or written, the `research_knowledge_search` tool is not advertised to the agent, and `/knowledge-store` is unavailable. Re-enabling needs no restart (see [PI-EXTENSION.md](PI-EXTENSION.md) for the registration mechanics). |
|
|
45
45
|
|
|
46
46
|
Change the mode for the current directory with the `/research-config` TUI (Knowledge Mode)
|
|
47
|
-
in the pi extension, or with `pi-research knowledge-config set none|project|global
|
|
47
|
+
in the pi extension, or with `pi-research knowledge-config set <none|project|global>` (pick one value) on the
|
|
48
48
|
standalone CLI. The setting persists to the per-directory project registry — see
|
|
49
49
|
[CONFIGURATION.md](CONFIGURATION.md) for the full precedence chain. The change applies on
|
|
50
50
|
the next run — no restart.
|
package/docs/SDK.md
CHANGED
|
@@ -33,13 +33,16 @@ global file entirely and run purely from defaults + `process.env` + `options.con
|
|
|
33
33
|
> `enum` and constructor parameter properties, which Node's strip-only mode
|
|
34
34
|
> (`--experimental-strip-types`, the default since Node 23.6) rejects with
|
|
35
35
|
> `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Use one of:
|
|
36
|
-
> - the pi host, which loads it natively;
|
|
37
|
-
> - **`node --experimental-transform-types your-script.ts`** — must be passed
|
|
38
|
-
> explicitly; full transform is not the default on any current Node release;
|
|
36
|
+
> - the pi host, which loads it natively (via `jiti`);
|
|
39
37
|
> - a loader such as `tsx` or `ts-node`.
|
|
40
38
|
>
|
|
41
|
-
>
|
|
42
|
-
>
|
|
39
|
+
> **Bare Node cannot load it at all, with any flag.** Node refuses to strip or
|
|
40
|
+
> transform TypeScript that lives under `node_modules` — as an installed
|
|
41
|
+
> dependency's source does — failing with
|
|
42
|
+
> `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. That applies to
|
|
43
|
+
> `--experimental-transform-types` exactly as it does to
|
|
44
|
+
> `--experimental-strip-types`, so neither flag helps here; a loader (or the pi
|
|
45
|
+
> host) is mandatory. (`engines.node` is `>=22.19.0`.)
|
|
43
46
|
|
|
44
47
|
```typescript
|
|
45
48
|
import {
|
package/package.json
CHANGED
package/scripts/build.cjs
CHANGED
|
@@ -106,13 +106,31 @@ const TARGETS = {
|
|
|
106
106
|
copyWebGpuProbe();
|
|
107
107
|
copyPrompts();
|
|
108
108
|
},
|
|
109
|
-
skill: () =>
|
|
110
|
-
|
|
109
|
+
skill: () => {
|
|
110
|
+
const entry = p('skills', 'pi-research', 'scripts', 'run.ts');
|
|
111
|
+
const outfile = p('skills', 'pi-research', 'scripts', 'run.mjs');
|
|
112
|
+
// The published package ships the BUILT run.mjs and deliberately excludes its
|
|
113
|
+
// run.ts source (package.json "files": "!skills/pi-research/scripts/run.ts"),
|
|
114
|
+
// yet it also ships prepare.cjs + build.cjs — and `prepare` runs `build.cjs all`.
|
|
115
|
+
// On any install that executes prepare against the shipped tree (npm install
|
|
116
|
+
// <dir>, file:, npm link, pi install <dir>) this target had no entry point and
|
|
117
|
+
// aborted the whole install with "Could not resolve run.ts". Registry .tgz and
|
|
118
|
+
// npx installs were unaffected only because prepare does not run there.
|
|
119
|
+
// Nothing to do when the artifact is already present and its source is not.
|
|
120
|
+
if (!fs.existsSync(entry)) {
|
|
121
|
+
if (fs.existsSync(outfile)) {
|
|
122
|
+
console.log('[build] skill: run.ts absent and run.mjs already built (published tree) — skipping');
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`[build] skill: neither ${entry} nor a prebuilt ${outfile} exists`);
|
|
126
|
+
}
|
|
127
|
+
return esbuild.build({
|
|
111
128
|
...COMMON,
|
|
112
|
-
entryPoints: [
|
|
113
|
-
outfile
|
|
129
|
+
entryPoints: [entry],
|
|
130
|
+
outfile,
|
|
114
131
|
banner: { js: SHEBANG },
|
|
115
|
-
})
|
|
132
|
+
});
|
|
133
|
+
},
|
|
116
134
|
};
|
|
117
135
|
|
|
118
136
|
// The cli target's copyThreadWorker step depends on the worker output existing,
|
|
@@ -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.1.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.1.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -63,7 +63,7 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
|
|
|
63
63
|
| Exit | Meaning | Action |
|
|
64
64
|
|------|---------|--------|
|
|
65
65
|
| `0` | Success | Read stdout; cite sources; relay any "saved to: <path>" line. |
|
|
66
|
-
| `78` on `knowledge` |
|
|
66
|
+
| `78` on `knowledge` | Two cases — **read stderr to tell them apart.** | Says the store is **disabled**: silent; proceed to `research`. Mentions **credentials/model/API key**: this is the same hard config failure as `78` on `research` — **stop and relay full stderr**; don't silently continue, `research` will fail the same way. |
|
|
67
67
|
| `78` on `research` | Engine missing or no model/key | **Stop. Relay full stderr** (it shows where to configure). Don't retry until fixed. |
|
|
68
68
|
| `75` | Machine at capacity — other research runs are already using every slot | **Nothing is broken.** Wait for an in-flight run to finish, then retry this query once. Don't reconfigure anything and don't retry in a tight loop. |
|
|
69
69
|
| `70` | Runtime error | Relay stderr; suggest one retry for transient failures (network, rate limit). |
|
|
@@ -81,7 +81,7 @@ the new setting. Check the current one first if useful:
|
|
|
81
81
|
|
|
82
82
|
```
|
|
83
83
|
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + where it's set
|
|
84
|
-
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
|
|
84
|
+
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
The change applies to the current directory on the next run. Never change it unprompted.
|
|
@@ -52,7 +52,7 @@ shared store — the default), `project` (scoped to the current directory), or `
|
|
|
52
52
|
|
|
53
53
|
```sh
|
|
54
54
|
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + its source
|
|
55
|
-
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
|
|
55
|
+
node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
`set` persists to the per-directory project registry (`~/.pi/research/state/
|
package/src/cli.ts
CHANGED
|
@@ -137,9 +137,19 @@ function makeProgressObserver(): HeadlessObserverOptions {
|
|
|
137
137
|
case 'evaluation_decision':
|
|
138
138
|
toStderr(` • evaluator: ${data?.action}\n`);
|
|
139
139
|
break;
|
|
140
|
-
case 'tokens_consumed':
|
|
141
|
-
|
|
140
|
+
case 'tokens_consumed': {
|
|
141
|
+
// A truthiness check on cost used to gate this whole line, so a model with
|
|
142
|
+
// an all-zero price table suppressed the TOKEN count as well — the run
|
|
143
|
+
// looked like it consumed nothing. Tokens stand on their own; cost is
|
|
144
|
+
// appended whenever it is a number, including 0.
|
|
145
|
+
const consumed = Number(data?.tokens ?? 0);
|
|
146
|
+
const costVal = typeof data?.cost === 'number' ? data.cost : undefined;
|
|
147
|
+
if (consumed > 0 || costVal !== undefined) {
|
|
148
|
+
const costPart = costVal === undefined ? '' : ` ($${costVal.toFixed(4)})`;
|
|
149
|
+
toStderr(` • tokens: ${consumed}${costPart}\n`);
|
|
150
|
+
}
|
|
142
151
|
break;
|
|
152
|
+
}
|
|
143
153
|
case 'complete':
|
|
144
154
|
toStderr(` • complete (${data?.result?.length ?? 0} chars)\n`);
|
|
145
155
|
break;
|
|
@@ -1016,7 +1026,9 @@ CONFIGURE
|
|
|
1016
1026
|
EXIT CODES
|
|
1017
1027
|
0 success
|
|
1018
1028
|
64 bad arguments
|
|
1029
|
+
75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
|
|
1019
1030
|
78 not configured — message prints the exact locations to fix
|
|
1031
|
+
(on \`knowledge\`, also returned when the store is deliberately disabled)
|
|
1020
1032
|
70 runtime error (network, provider, internal)
|
|
1021
1033
|
`;
|
|
1022
1034
|
}
|
|
@@ -219,6 +219,13 @@ export function constructMinimalModel(provider: string, modelId: string, _apiKey
|
|
|
219
219
|
baseUrl: '', // Provider-specific base URLs are handled by pi-ai internal registry
|
|
220
220
|
reasoning: false,
|
|
221
221
|
input: ['text'],
|
|
222
|
+
// No catalog is available on this path, so there is no price data and no real
|
|
223
|
+
// context/output ceiling to read. The zeros below are "unknown", NOT "free":
|
|
224
|
+
// cost reporting for this model will read $0.00 (see warnIfUnpriced in
|
|
225
|
+
// utils/llm-usage.ts), and the window figures are conservative guesses, not the
|
|
226
|
+
// model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
|
|
227
|
+
// ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
|
|
228
|
+
// full context window should give pi a credential so it can fetch the real catalog.
|
|
222
229
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
223
230
|
contextWindow: 128_000,
|
|
224
231
|
maxTokens: 32_768,
|
|
@@ -253,7 +260,7 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
|
|
|
253
260
|
return constructMinimalModel(prov, modelId, apiKey);
|
|
254
261
|
}
|
|
255
262
|
|
|
256
|
-
throw new Error(
|
|
263
|
+
throw new Error(buildModelNotFoundMessage(modelSpec));
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
// Validate format: must contain a slash OR be found as a bare model ID.
|
|
@@ -306,11 +313,41 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
|
|
|
306
313
|
return constructMinimalModel(provider, modelSpec, apiKey);
|
|
307
314
|
}
|
|
308
315
|
|
|
309
|
-
throw new Error(
|
|
310
|
-
|
|
316
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Credential-first guidance for model-resolution failures.
|
|
321
|
+
*
|
|
322
|
+
* These messages used to say only "Check ~/.pi/agent/models.json", which is the
|
|
323
|
+
* WRONG remedy for a provider pi ships built in: the fix there is a credential,
|
|
324
|
+
* after which pi fetches the provider's catalog (with real pricing and context
|
|
325
|
+
* limits) on its own. Following the old advice, users hand-wrote `models[]`
|
|
326
|
+
* entries — and because such an entry REPLACES pi's catalog entry wholesale
|
|
327
|
+
* rather than merging into it, that silently zeroed out pricing and capped
|
|
328
|
+
* context/output windows. Lead with the credential path; scope models.json to
|
|
329
|
+
* genuinely custom providers; name `modelOverrides` for single-field tweaks.
|
|
330
|
+
*/
|
|
331
|
+
function credentialGuidance(): string {
|
|
332
|
+
const agentDir = getAgentDir();
|
|
333
|
+
return (
|
|
334
|
+
` • Provider pi supports built-in (openrouter, anthropic, openai, …): add a credential — run \`pi\` and use /login, ` +
|
|
335
|
+
`set the provider's API-key environment variable, or add it to ${path.join(agentDir, 'auth.json')}. ` +
|
|
336
|
+
`pi then fetches that provider's model catalog automatically.\n` +
|
|
337
|
+
` • Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path.join(agentDir, 'models.json')}.\n` +
|
|
338
|
+
` Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and\n` +
|
|
339
|
+
` context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead — it merges.`
|
|
311
340
|
);
|
|
312
341
|
}
|
|
313
342
|
|
|
343
|
+
export function buildModelNotFoundMessage(modelSpec: string): string {
|
|
344
|
+
return `Model "${modelSpec}" not found in pi's model registry.\n${credentialGuidance()}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function buildNoModelAvailableMessage(): string {
|
|
348
|
+
return `No LLM model available.\n${credentialGuidance()}\n • Or pass an explicit apiKey.`;
|
|
349
|
+
}
|
|
350
|
+
|
|
314
351
|
/**
|
|
315
352
|
* Read the user's provider order from ~/.pi/agent/models.json.
|
|
316
353
|
* Returns provider keys in document (insertion) order, or [] when the file is
|
|
@@ -9,7 +9,7 @@ import { type Model } from '@earendil-works/pi-ai';
|
|
|
9
9
|
import { type ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import { getConfig } from '../../config.ts';
|
|
11
11
|
import { logger } from '../../logger.ts';
|
|
12
|
-
import { pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
|
|
12
|
+
import { buildNoModelAvailableMessage, pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Resolve the research model with standardized priority:
|
|
@@ -87,5 +87,5 @@ export function resolveResearchModel(options: {
|
|
|
87
87
|
return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder()) as Model<any>;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
throw new Error(
|
|
90
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
91
91
|
}
|
|
@@ -109,7 +109,13 @@ export function isPoolShutdownError(error: unknown): boolean {
|
|
|
109
109
|
return typeof err.message === 'string' && (
|
|
110
110
|
err.message.includes('Worker pool is shutting down') ||
|
|
111
111
|
err.message.includes('Cannot execute a task on destroying pool') ||
|
|
112
|
-
err.message.includes('destroying pool')
|
|
112
|
+
err.message.includes('destroying pool') ||
|
|
113
|
+
// worker-pool-manager throws this when auto-recovery is still swapping the
|
|
114
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
115
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
116
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
117
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
118
|
+
err.message.includes('Worker pool is being reset')
|
|
113
119
|
);
|
|
114
120
|
}
|
|
115
121
|
|
|
@@ -1284,6 +1284,11 @@ var init_circuit_breaker = __esm({
|
|
|
1284
1284
|
metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
|
|
1285
1285
|
if (this.options.isTransientError(error)) {
|
|
1286
1286
|
this.onFailure(error);
|
|
1287
|
+
} else if (admittedAsProbe && this.state === "HALF_OPEN") {
|
|
1288
|
+
logger.warn(
|
|
1289
|
+
`[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
|
|
1290
|
+
);
|
|
1291
|
+
this.transitionTo("OPEN");
|
|
1287
1292
|
}
|
|
1288
1293
|
throw error;
|
|
1289
1294
|
} finally {
|
|
@@ -1379,7 +1384,12 @@ function isServerDrainingError(error) {
|
|
|
1379
1384
|
function isPoolShutdownError(error) {
|
|
1380
1385
|
if (!error || typeof error !== "object") return false;
|
|
1381
1386
|
const err = error;
|
|
1382
|
-
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
1387
|
+
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
|
|
1388
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
1389
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
1390
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
1391
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
1392
|
+
err.message.includes("Worker pool is being reset"));
|
|
1383
1393
|
}
|
|
1384
1394
|
var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
|
|
1385
1395
|
var init_browser_error_utils = __esm({
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
13
|
-
import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
13
|
+
import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
14
|
import { lastCitedLinksHeaderIndex } 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';
|
|
@@ -33,86 +33,6 @@ const NO_SOURCES_NOTE =
|
|
|
33
33
|
const UNVERIFIED_LINKS_NOTE =
|
|
34
34
|
'_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Largest `[N]` treated as a citation marker. Inline markers index a source list
|
|
38
|
-
* that is realistically well under this; the bound exists so an ordinary bracketed
|
|
39
|
-
* number in prose — a year (`[2023]`), an issue id, an array index — is left alone
|
|
40
|
-
* instead of being silently deleted as a "dangling citation".
|
|
41
|
-
*/
|
|
42
|
-
const MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
|
|
46
|
-
* map, delete the ones known to dangle, leave everything else untouched.
|
|
47
|
-
*
|
|
48
|
-
* Deletion is whitespace-aware rather than followed by a global space collapse.
|
|
49
|
-
* A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
|
|
50
|
-
* silently flattens nested list levels into one and turns 4-space indented code
|
|
51
|
-
* blocks into paragraphs — corrupting the structure of every report that contains
|
|
52
|
-
* them. So the pad preceding a deleted marker is consumed only when the marker is
|
|
53
|
-
* mid-line (i.e. real prose spacing); line-leading indentation is preserved.
|
|
54
|
-
*
|
|
55
|
-
* Code is skipped entirely. A report may quote code containing bracketed integers
|
|
56
|
-
* (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
|
|
57
|
-
* as dangling — so without this guard a quoted array index would be silently deleted
|
|
58
|
-
* from the user's code sample. Fenced blocks are tracked across lines; single-backtick
|
|
59
|
-
* spans are detected by an odd backtick count earlier on the same line.
|
|
60
|
-
*
|
|
61
|
-
* @param remap local marker number → verified global id
|
|
62
|
-
* @param isDangling whether a marker not present in `remap` should be deleted
|
|
63
|
-
*/
|
|
64
|
-
function rewriteCitationMarkers(
|
|
65
|
-
text: string,
|
|
66
|
-
remap: Map<number, number>,
|
|
67
|
-
isDangling: (n: number) => boolean,
|
|
68
|
-
): string {
|
|
69
|
-
const inFence = fencedCodeMask(text);
|
|
70
|
-
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
|
|
71
|
-
const n = parseInt(digits, 10);
|
|
72
|
-
const mapped = remap.get(n);
|
|
73
|
-
if (mapped !== undefined) return `${pad}[${mapped}]`;
|
|
74
|
-
// Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
|
|
75
|
-
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
76
|
-
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
77
|
-
// Preserve indentation when nothing but whitespace precedes on this line;
|
|
78
|
-
// otherwise drop the pad too so no double space is left behind.
|
|
79
|
-
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
80
|
-
return text.slice(lineStart, offset).trim() === '' ? pad : '';
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Build an `offset → is inside a ``` fenced block` predicate for `text`.
|
|
86
|
-
*
|
|
87
|
-
* Computed once per rewrite (a single line scan) rather than per marker, so marker
|
|
88
|
-
* rewriting stays linear in the body length.
|
|
89
|
-
*/
|
|
90
|
-
function fencedCodeMask(text: string): (offset: number) => boolean {
|
|
91
|
-
const ranges: Array<[number, number]> = [];
|
|
92
|
-
let open = -1;
|
|
93
|
-
let pos = 0;
|
|
94
|
-
for (const line of text.split('\n')) {
|
|
95
|
-
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
96
|
-
if (open < 0) open = pos;
|
|
97
|
-
else {
|
|
98
|
-
ranges.push([open, pos + line.length]);
|
|
99
|
-
open = -1;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
pos += line.length + 1; // +1 for the '\n' consumed by split
|
|
103
|
-
}
|
|
104
|
-
// An unterminated fence runs to the end of the body — treat the remainder as code.
|
|
105
|
-
if (open >= 0) ranges.push([open, text.length]);
|
|
106
|
-
return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Whether `offset` sits inside a single-backtick code span on its own line. */
|
|
110
|
-
function inInlineCodeSpan(text: string, offset: number): boolean {
|
|
111
|
-
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
112
|
-
let ticks = 0;
|
|
113
|
-
for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
|
|
114
|
-
return ticks % 2 === 1;
|
|
115
|
-
}
|
|
116
36
|
|
|
117
37
|
/**
|
|
118
38
|
* Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
|
|
@@ -481,7 +481,10 @@ export async function runBackgroundExtraction(
|
|
|
481
481
|
const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
482
482
|
metrics.increment('research_knowledge_search_synthesis_retries_total', 1);
|
|
483
483
|
logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
|
|
484
|
-
|
|
484
|
+
// keepAlive=true: the retry is already committed and the next attempt must
|
|
485
|
+
// run, so this sleep is foreground work. An unref'd timer here can be the
|
|
486
|
+
// only pending handle and let the process exit mid-retry (see abortableDelay).
|
|
487
|
+
await abortableDelay(delay, signal, true);
|
|
485
488
|
}
|
|
486
489
|
}
|
|
487
490
|
|
|
@@ -625,7 +628,10 @@ export async function triageRelevantUrls(
|
|
|
625
628
|
const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
626
629
|
metrics.increment('research_knowledge_search_triage_retries_total', 1);
|
|
627
630
|
logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
|
|
628
|
-
|
|
631
|
+
// keepAlive=true: the retry is already committed and the next attempt must
|
|
632
|
+
// run, so this sleep is foreground work. An unref'd timer here can be the
|
|
633
|
+
// only pending handle and let the process exit mid-retry (see abortableDelay).
|
|
634
|
+
await abortableDelay(delay, signal, true);
|
|
629
635
|
}
|
|
630
636
|
}
|
|
631
637
|
|
|
@@ -78,6 +78,26 @@ export class CircuitBreaker {
|
|
|
78
78
|
metrics.observe('circuit_breaker_call_duration_ms', duration, { breaker: this.options.name, status: 'error' });
|
|
79
79
|
if (this.options.isTransientError(error)) {
|
|
80
80
|
this.onFailure(error);
|
|
81
|
+
} else if (admittedAsProbe && this.state === 'HALF_OPEN') {
|
|
82
|
+
// The trial probe failed for a reason this breaker does not count. That is
|
|
83
|
+
// INCONCLUSIVE, not healthy — so re-open and wait out another reset window
|
|
84
|
+
// rather than staying HALF_OPEN.
|
|
85
|
+
//
|
|
86
|
+
// Without this the breaker pins permanently: onFailure is the only path to
|
|
87
|
+
// OPEN, and it is skipped for non-counted errors, while transitionTo(HALF_OPEN)
|
|
88
|
+
// never re-arms nextAttemptTime. The admission gate above then fast-fails every
|
|
89
|
+
// caller beyond halfOpenMaxCalls (default 1) forever. This is not a corner case
|
|
90
|
+
// for the browser pool: DEFAULT_BREAKER_CONFIG.isTransientError deliberately
|
|
91
|
+
// excludes pool-shutdown, draining, Cloudflare and task-timeout errors — exactly
|
|
92
|
+
// what a recovering or blocked pool returns — so the probe most likely to run is
|
|
93
|
+
// also the one most likely to pin the breaker. Observed effect: 19 of a 20-query
|
|
94
|
+
// parallel burst fast-failing instantly, surfacing to the researcher as
|
|
95
|
+
// "your query may be too narrow".
|
|
96
|
+
logger.warn(
|
|
97
|
+
`[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; ` +
|
|
98
|
+
`treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
|
|
99
|
+
);
|
|
100
|
+
this.transitionTo('OPEN');
|
|
81
101
|
}
|
|
82
102
|
throw error;
|
|
83
103
|
} finally {
|
|
@@ -67,13 +67,28 @@ export function normalizeCitations(reports: Map<string, string>): {
|
|
|
67
67
|
const headerIdx = lastCitedLinksHeaderIndex(report);
|
|
68
68
|
let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
// Renumber local [N] markers to their global ids, and DELETE the ones that could
|
|
71
|
+
// not be mapped.
|
|
72
|
+
//
|
|
73
|
+
// Leaving an unmapped marker in place (the previous `return match`) was a silent
|
|
74
|
+
// misattribution bug, not a harmless no-op: global ids are assigned densely from 1,
|
|
75
|
+
// so a surviving `[1]` no longer means "this report's first source" — it resolves
|
|
76
|
+
// against the renumbered global list and points at a completely different document.
|
|
77
|
+
// Markers go unmapped whenever parseCitations dropped the entry (e.g. a URL whose
|
|
78
|
+
// hostname has no dot: `localhost`, a bare internal host, a malformed line). The
|
|
79
|
+
// result is fed straight into the evaluator/synthesis prompt, which is explicitly
|
|
80
|
+
// told the numbering is already normalized — so the model faithfully reproduces the
|
|
81
|
+
// wrong attribution, and ensureCitedLinks then regenerates a CITED LINKS list that
|
|
82
|
+
// looks perfectly consistent with it.
|
|
83
|
+
//
|
|
84
|
+
// rewriteCitationMarkers is the same routine the synthesis path already uses: it
|
|
85
|
+
// skips fenced blocks and inline code spans (so a quoted `items[12]` survives),
|
|
86
|
+
// bounds what counts as a marker, and preserves line-leading indentation.
|
|
87
|
+
content = rewriteCitationMarkers(
|
|
88
|
+
content,
|
|
89
|
+
localToGlobal,
|
|
90
|
+
(n) => !localToGlobal.has(n),
|
|
91
|
+
);
|
|
77
92
|
|
|
78
93
|
normalizedReports.set(id, content.trim());
|
|
79
94
|
}
|
|
@@ -95,3 +110,84 @@ export function formatCitedLinks(citations: GlobalCitation[]): string {
|
|
|
95
110
|
|
|
96
111
|
return `CITED LINKS\n${links.join('\n')}`;
|
|
97
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Largest `[N]` treated as a citation marker. Inline markers index a source list
|
|
116
|
+
* that is realistically well under this; the bound exists so an ordinary bracketed
|
|
117
|
+
* number in prose — a year (`[2023]`), an issue id, an array index — is left alone
|
|
118
|
+
* instead of being silently deleted as a "dangling citation".
|
|
119
|
+
*/
|
|
120
|
+
export const MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
|
|
124
|
+
* map, delete the ones known to dangle, leave everything else untouched.
|
|
125
|
+
*
|
|
126
|
+
* Deletion is whitespace-aware rather than followed by a global space collapse.
|
|
127
|
+
* A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
|
|
128
|
+
* silently flattens nested list levels into one and turns 4-space indented code
|
|
129
|
+
* blocks into paragraphs — corrupting the structure of every report that contains
|
|
130
|
+
* them. So the pad preceding a deleted marker is consumed only when the marker is
|
|
131
|
+
* mid-line (i.e. real prose spacing); line-leading indentation is preserved.
|
|
132
|
+
*
|
|
133
|
+
* Code is skipped entirely. A report may quote code containing bracketed integers
|
|
134
|
+
* (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
|
|
135
|
+
* as dangling — so without this guard a quoted array index would be silently deleted
|
|
136
|
+
* from the user's code sample. Fenced blocks are tracked across lines; single-backtick
|
|
137
|
+
* spans are detected by an odd backtick count earlier on the same line.
|
|
138
|
+
*
|
|
139
|
+
* @param remap local marker number → verified global id
|
|
140
|
+
* @param isDangling whether a marker not present in `remap` should be deleted
|
|
141
|
+
*/
|
|
142
|
+
export function rewriteCitationMarkers(
|
|
143
|
+
text: string,
|
|
144
|
+
remap: Map<number, number>,
|
|
145
|
+
isDangling: (n: number) => boolean,
|
|
146
|
+
): string {
|
|
147
|
+
const inFence = fencedCodeMask(text);
|
|
148
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
|
|
149
|
+
const n = parseInt(digits, 10);
|
|
150
|
+
const mapped = remap.get(n);
|
|
151
|
+
if (mapped !== undefined) return `${pad}[${mapped}]`;
|
|
152
|
+
// Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
|
|
153
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
154
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
155
|
+
// Preserve indentation when nothing but whitespace precedes on this line;
|
|
156
|
+
// otherwise drop the pad too so no double space is left behind.
|
|
157
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
158
|
+
return text.slice(lineStart, offset).trim() === '' ? pad : '';
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build an `offset → is inside a ``` fenced block` predicate for `text`.
|
|
164
|
+
*
|
|
165
|
+
* Computed once per rewrite (a single line scan) rather than per marker, so marker
|
|
166
|
+
* rewriting stays linear in the body length.
|
|
167
|
+
*/
|
|
168
|
+
function fencedCodeMask(text: string): (offset: number) => boolean {
|
|
169
|
+
const ranges: Array<[number, number]> = [];
|
|
170
|
+
let open = -1;
|
|
171
|
+
let pos = 0;
|
|
172
|
+
for (const line of text.split('\n')) {
|
|
173
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
174
|
+
if (open < 0) open = pos;
|
|
175
|
+
else {
|
|
176
|
+
ranges.push([open, pos + line.length]);
|
|
177
|
+
open = -1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
pos += line.length + 1; // +1 for the '\n' consumed by split
|
|
181
|
+
}
|
|
182
|
+
// An unterminated fence runs to the end of the body — treat the remainder as code.
|
|
183
|
+
if (open >= 0) ranges.push([open, text.length]);
|
|
184
|
+
return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether `offset` sits inside a single-backtick code span on its own line. */
|
|
188
|
+
function inInlineCodeSpan(text: string, offset: number): boolean {
|
|
189
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
190
|
+
let ticks = 0;
|
|
191
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
|
|
192
|
+
return ticks % 2 === 1;
|
|
193
|
+
}
|
package/src/utils/llm-usage.ts
CHANGED
|
@@ -13,8 +13,44 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Model } from '@earendil-works/pi-ai';
|
|
15
15
|
import { extractUsage } from '../types/llm.ts';
|
|
16
|
+
import { logger } from '../logger.ts';
|
|
16
17
|
import { metrics } from './metrics.ts';
|
|
17
18
|
|
|
19
|
+
/** Models already warned about, so the notice fires at most once per model per process. */
|
|
20
|
+
const unpricedModelsWarned = new Set<string>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Warn once when a model bills tokens but carries an all-zero price table.
|
|
24
|
+
*
|
|
25
|
+
* Cost is never read off the wire — pi computes it as tokens × the model's local
|
|
26
|
+
* price table — so an unpriced table yields $0.00 silently and indefinitely. That
|
|
27
|
+
* is legitimate for flat-rate plans and local servers, but it is also exactly what
|
|
28
|
+
* a misconfiguration looks like: a hand-written `models[]` entry in models.json
|
|
29
|
+
* REPLACES pi's catalog entry for the same id, discarding its pricing. This
|
|
30
|
+
* happened in practice and went unnoticed for weeks because every display site
|
|
31
|
+
* suppressed a zero cost. The message deliberately does not assert a fault — it
|
|
32
|
+
* states the condition and names the usual cause.
|
|
33
|
+
*/
|
|
34
|
+
function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
|
|
35
|
+
if (tokens <= 0 || cost > 0) return;
|
|
36
|
+
const m = model as unknown as { provider?: string; id?: string; cost?: Record<string, number> };
|
|
37
|
+
const key = `${m.provider ?? 'unknown'}/${m.id ?? 'unknown'}`;
|
|
38
|
+
if (unpricedModelsWarned.has(key)) return;
|
|
39
|
+
|
|
40
|
+
const table = m.cost;
|
|
41
|
+
const allZero =
|
|
42
|
+
!table || Object.values(table).every((v) => typeof v !== 'number' || v === 0);
|
|
43
|
+
if (!allZero) return;
|
|
44
|
+
|
|
45
|
+
unpricedModelsWarned.add(key);
|
|
46
|
+
logger.warn(
|
|
47
|
+
`[LlmUsage] Model "${key}" consumed ${tokens} tokens but reports $0.00 — its price table is all zeros. ` +
|
|
48
|
+
`Expected for flat-rate plans and local servers. Otherwise the price data is missing: a hand-written ` +
|
|
49
|
+
`"models" entry in ~/.pi/agent/models.json replaces pi's catalog entry (and its pricing) for the same ` +
|
|
50
|
+
`model id — remove it and let pi's catalog supply the model, or use "modelOverrides", which merges.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
/** Structural subset of the run observer — just the token sink. Kept structural (rather
|
|
19
55
|
* than importing ResearchObserver from core/) so this foundation-layer helper does not
|
|
20
56
|
* depend on an upper layer; the full ResearchObserver satisfies it. */
|
|
@@ -43,6 +79,7 @@ export function recordLlmUsage(
|
|
|
43
79
|
): { tokens: number; cost: number } {
|
|
44
80
|
if (!rawUsage) return { tokens: 0, cost: 0 };
|
|
45
81
|
const { tokens, cost } = extractUsage(model, rawUsage);
|
|
82
|
+
warnIfUnpriced(model, tokens, cost);
|
|
46
83
|
if (tokens > 0 || cost > 0) {
|
|
47
84
|
const labels: Record<string, string> = { component: opts.component };
|
|
48
85
|
if (opts.complexity !== undefined) labels['complexity'] = String(opts.complexity);
|
|
@@ -283,8 +283,15 @@ export function buildResearchSummary(stats: ResearchStats): string {
|
|
|
283
283
|
if (stats.tokens > 0) {
|
|
284
284
|
resourceParts.push(`**${formatTokens(stats.tokens)}** tokens`);
|
|
285
285
|
}
|
|
286
|
-
|
|
287
|
-
|
|
286
|
+
// Render the cost whenever tokens were actually billed-through, INCLUDING when it
|
|
287
|
+
// is exactly zero. Gating on `cost > 0` hid a real defect for weeks: a model whose
|
|
288
|
+
// price table is all zeros (an unpriced or catalog-overwritten entry) produced no
|
|
289
|
+
// cost line at all, so a misconfiguration was indistinguishable from a run that
|
|
290
|
+
// simply had not reported yet. "$0.00" beside a large token count is conspicuous;
|
|
291
|
+
// silence is not. Zero is also the honest figure for flat-rate and local providers.
|
|
292
|
+
if (stats.tokens > 0 || stats.cost > 0) {
|
|
293
|
+
const costStr =
|
|
294
|
+
stats.cost === 0 ? '$0.00' : stats.cost < 0.01 ? '<$0.01' : `$${stats.cost.toFixed(2)}`;
|
|
288
295
|
resourceParts.push(`**${costStr}**`);
|
|
289
296
|
}
|
|
290
297
|
if (stats.durationMs > 0) {
|
|
@@ -317,6 +324,9 @@ export interface SessionStats {
|
|
|
317
324
|
totalUrlsDiscovered: number;
|
|
318
325
|
totalSearchQueries: number;
|
|
319
326
|
totalTokens: number;
|
|
327
|
+
/** Summed LLM cost across the session's runs. The Session Metrics UI advertises
|
|
328
|
+
* "API cost estimates"; without this field it had no cost to show. */
|
|
329
|
+
totalCost: number;
|
|
320
330
|
totalToolUsage: {
|
|
321
331
|
searches: number;
|
|
322
332
|
scrapes: number;
|
|
@@ -339,6 +349,7 @@ export function aggregateSessionStats(
|
|
|
339
349
|
let totalUrlsDiscovered = 0;
|
|
340
350
|
let totalSearchQueries = 0;
|
|
341
351
|
let totalTokens = 0;
|
|
352
|
+
let totalCost = 0;
|
|
342
353
|
const totalToolUsage = {
|
|
343
354
|
searches: 0,
|
|
344
355
|
scrapes: 0,
|
|
@@ -359,6 +370,7 @@ export function aggregateSessionStats(
|
|
|
359
370
|
totalUrlsDiscovered += stats.urlsDiscovered;
|
|
360
371
|
totalSearchQueries += stats.searchQueries;
|
|
361
372
|
totalTokens += stats.tokens;
|
|
373
|
+
totalCost += stats.cost;
|
|
362
374
|
totalToolUsage.searches += stats.toolUsage.searches;
|
|
363
375
|
totalToolUsage.scrapes += stats.toolUsage.scrapes;
|
|
364
376
|
totalToolUsage.securitySearches += stats.toolUsage.securitySearches;
|
|
@@ -377,6 +389,7 @@ export function aggregateSessionStats(
|
|
|
377
389
|
totalUrlsDiscovered,
|
|
378
390
|
totalSearchQueries,
|
|
379
391
|
totalTokens,
|
|
392
|
+
totalCost,
|
|
380
393
|
totalToolUsage,
|
|
381
394
|
};
|
|
382
395
|
}
|
|
@@ -403,6 +416,16 @@ export function buildSessionOverview(stats: SessionStats): string {
|
|
|
403
416
|
}
|
|
404
417
|
if (stats.totalTokens > 0) {
|
|
405
418
|
overviewParts.push(`**${formatTokens(stats.totalTokens)}** tokens`);
|
|
419
|
+
// Shown alongside tokens even at exactly $0.00 — the Session Metrics view
|
|
420
|
+
// advertises cost estimates, and a silent omission is what let an unpriced
|
|
421
|
+
// model table go unnoticed. Zero is correct for flat-rate and local providers.
|
|
422
|
+
const costStr =
|
|
423
|
+
stats.totalCost === 0
|
|
424
|
+
? '$0.00'
|
|
425
|
+
: stats.totalCost < 0.01
|
|
426
|
+
? '<$0.01'
|
|
427
|
+
: `$${stats.totalCost.toFixed(2)}`;
|
|
428
|
+
overviewParts.push(`**${costStr}**`);
|
|
406
429
|
}
|
|
407
430
|
if (stats.totalDurationMs > 0) {
|
|
408
431
|
overviewParts.push(`**${formatDuration(stats.totalDurationMs)}** total research time`);
|
|
@@ -207,8 +207,15 @@ export async function exportResearchReport(
|
|
|
207
207
|
*/
|
|
208
208
|
export function appendExportMessage(result: string, filepath: string, totalCost?: number): string {
|
|
209
209
|
let footer = `\n\n---\n\nResearch report saved to: ${filepath}`;
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// Report a defined cost even when it is exactly zero — suppressing it made an
|
|
211
|
+
// unpriced/misconfigured model table look identical to "no cost reported yet".
|
|
212
|
+
if (totalCost !== undefined) {
|
|
213
|
+
const costStr =
|
|
214
|
+
totalCost === 0
|
|
215
|
+
? '$0.00'
|
|
216
|
+
: totalCost < 0.01
|
|
217
|
+
? '<$0.01'
|
|
218
|
+
: `$${totalCost.toFixed(4)}`.replace(/0+$/, '').replace(/\.$/, '');
|
|
212
219
|
footer += `\nTotal cost: ${costStr}`;
|
|
213
220
|
}
|
|
214
221
|
return result + footer;
|
|
@@ -141,6 +141,11 @@ export function withTimeout<T>(
|
|
|
141
141
|
// aborted before we start means an external (user) cancellation, not a timeout —
|
|
142
142
|
// a clean stop, so log at debug, not error.
|
|
143
143
|
if (combinedSignal.aborted) {
|
|
144
|
+
// `promise` is a parameter, so the caller already started it. Returning here
|
|
145
|
+
// skips the promise.then(...) attachment below, leaving its eventual rejection
|
|
146
|
+
// with no handler — and Node 22 defaults to --unhandled-rejections=throw, so
|
|
147
|
+
// that kills the process. Adopt the rejection before bailing out.
|
|
148
|
+
void promise.catch(() => undefined);
|
|
144
149
|
logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
|
|
145
150
|
return reject(new Error(`${label} cancelled or timed out`));
|
|
146
151
|
}
|
|
@@ -319,7 +324,14 @@ export async function retryWithBackoff<T>(
|
|
|
319
324
|
// the full backoff. If the wait is aborted, surface the original transient
|
|
320
325
|
// error (not the synthetic AbortError) so callers see the real cause.
|
|
321
326
|
try {
|
|
322
|
-
|
|
327
|
+
// keepAlive=true: this sleep IS the operation. The retry has already been
|
|
328
|
+
// decided and the next attempt must run, so the timer must hold the event
|
|
329
|
+
// loop open. Without it, a transient network failure — which by its nature
|
|
330
|
+
// leaves no referenced socket handle — can make the backoff the sole pending
|
|
331
|
+
// handle, and Node drains the loop and exits mid-retry. The attempt never
|
|
332
|
+
// runs, and no error surfaces: in the CLI neither branch of the top-level
|
|
333
|
+
// handler fires, so the user gets a bare exit with no report.
|
|
334
|
+
await abortableDelay(delay, opts.signal, true);
|
|
323
335
|
} catch {
|
|
324
336
|
metrics.increment('retry_aborted_total', 1, { label: opts.label });
|
|
325
337
|
throw lastError;
|