@lincoln504/pi-research 1.6.8 → 1.6.9
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/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +113 -27
- package/dist/prompts/researcher.md +2 -2
- package/dist/thread-worker.mjs +3 -1
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +10 -0
- package/docs/CONFIGURATION.md +2 -1
- package/docs/KNOWLEDGE-STORE.md +16 -8
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/config.ts +38 -2
- package/src/constants.ts +24 -2
- package/src/healthcheck/index.ts +32 -13
- package/src/infrastructure/browser/thread-worker.mjs +3 -1
- package/src/infrastructure/knowledge-store-service.ts +13 -2
- package/src/knowledge/availability.ts +118 -0
- package/src/knowledge/index.ts +17 -1
- package/src/orchestration/quick-research-orchestrator.ts +5 -1
- package/src/orchestration/researcher-executor.ts +6 -0
- package/src/prompts/researcher.md +2 -2
- package/src/research-config.ts +14 -1
- package/src/tools/research-knowledge-search.ts +13 -4
- package/src/tools/scrape.ts +11 -7
|
@@ -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.6.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.6.9", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
package/dist/cli.mjs
CHANGED
|
@@ -2387,6 +2387,46 @@ var init_atomic_replace = __esm({
|
|
|
2387
2387
|
}
|
|
2388
2388
|
});
|
|
2389
2389
|
|
|
2390
|
+
// src/knowledge/availability.ts
|
|
2391
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
2392
|
+
function probeKnowledgeStoreAvailability(resolver = defaultResolver) {
|
|
2393
|
+
if (cachedIsValidFor(resolver)) return _cached;
|
|
2394
|
+
const missing = [];
|
|
2395
|
+
for (const pkg of REQUIRED_PACKAGES) {
|
|
2396
|
+
try {
|
|
2397
|
+
resolver(pkg);
|
|
2398
|
+
} catch {
|
|
2399
|
+
missing.push(pkg);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
const result = { available: missing.length === 0, missing };
|
|
2403
|
+
if (resolver === defaultResolver) _cached = result;
|
|
2404
|
+
return result;
|
|
2405
|
+
}
|
|
2406
|
+
function cachedIsValidFor(resolver) {
|
|
2407
|
+
return _cached !== null && resolver === defaultResolver;
|
|
2408
|
+
}
|
|
2409
|
+
function describeKnowledgeStoreUnavailability(availability) {
|
|
2410
|
+
if (availability.available) return "";
|
|
2411
|
+
const parts = availability.missing.map(
|
|
2412
|
+
(pkg) => pkg === "@huggingface/transformers" ? `'${pkg}' (optional embedding dependency \u2014 likely skipped at install time)` : `'${pkg}' (broken install \u2014 native vector store missing)`
|
|
2413
|
+
);
|
|
2414
|
+
return `knowledge store is OFF: required package${parts.length > 1 ? "s" : ""} not resolvable \u2014 ${parts.join("; ")}. Repair with a full install of optional dependencies (e.g. 'npm install' including optional deps, or pi install npm:@lincoln504/pi-research without --omit=optional).`;
|
|
2415
|
+
}
|
|
2416
|
+
var REQUIRED_PACKAGES, _cached, defaultResolver;
|
|
2417
|
+
var init_availability = __esm({
|
|
2418
|
+
"src/knowledge/availability.ts"() {
|
|
2419
|
+
"use strict";
|
|
2420
|
+
REQUIRED_PACKAGES = ["@lancedb/lancedb", "@huggingface/transformers"];
|
|
2421
|
+
_cached = null;
|
|
2422
|
+
defaultResolver = (specifier) => (
|
|
2423
|
+
// import.meta.url survives esbuild's ESM output; createRequire only builds a
|
|
2424
|
+
// resolver — it never evaluates the specifier.
|
|
2425
|
+
createRequire3(import.meta.url).resolve(specifier)
|
|
2426
|
+
);
|
|
2427
|
+
}
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2390
2430
|
// src/config.ts
|
|
2391
2431
|
import * as fs5 from "node:fs";
|
|
2392
2432
|
import * as path5 from "node:path";
|
|
@@ -2593,7 +2633,19 @@ function describeKnowledgeStoreMode(cwd = process.cwd(), iface) {
|
|
|
2593
2633
|
else if (iface && fileHasKey(getInterfaceEnvFilePath(iface))) origin = `${iface}.env overlay`;
|
|
2594
2634
|
else if (fileHasKey(getGlobalEnvFilePath())) origin = "config.env (machine-wide default)";
|
|
2595
2635
|
}
|
|
2596
|
-
return {
|
|
2636
|
+
return {
|
|
2637
|
+
mode: cfg.KNOWLEDGE_STORE_MODE,
|
|
2638
|
+
origin,
|
|
2639
|
+
dbDir: getDbDir(cfg, cwd),
|
|
2640
|
+
...availabilityFields()
|
|
2641
|
+
};
|
|
2642
|
+
}
|
|
2643
|
+
function availabilityFields() {
|
|
2644
|
+
const availability = probeKnowledgeStoreAvailability();
|
|
2645
|
+
return {
|
|
2646
|
+
available: availability.available,
|
|
2647
|
+
unavailableReason: describeKnowledgeStoreUnavailability(availability)
|
|
2648
|
+
};
|
|
2597
2649
|
}
|
|
2598
2650
|
function parseDotEnv(content) {
|
|
2599
2651
|
const out = {};
|
|
@@ -2744,6 +2796,7 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
|
|
|
2744
2796
|
PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: String(config.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING),
|
|
2745
2797
|
PI_RESEARCH_AVG_TOKENS_PER_SCRAPE: String(config.AVG_TOKENS_PER_SCRAPE),
|
|
2746
2798
|
PI_RESEARCH_MAX_CONCURRENT_SCRAPES: String(config.MAX_CONCURRENT_SCRAPES),
|
|
2799
|
+
PI_RESEARCH_MAX_SCRAPE_URLS: String(config.MAX_SCRAPE_URLS),
|
|
2747
2800
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS: String(config.YOUTUBE_TRANSCRIPT_MAX_VIDEOS),
|
|
2748
2801
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS: String(config.YOUTUBE_TRANSCRIPT_TIMEOUT_MS),
|
|
2749
2802
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG: config.YOUTUBE_TRANSCRIPT_LANG,
|
|
@@ -2905,6 +2958,7 @@ function createConfig(env, processEnv) {
|
|
|
2905
2958
|
MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: parseEnvNumber(e, "PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING", DEFAULTS.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING, 0.05, 1),
|
|
2906
2959
|
AVG_TOKENS_PER_SCRAPE: parseEnvNumber(e, "PI_RESEARCH_AVG_TOKENS_PER_SCRAPE", DEFAULTS.AVG_TOKENS_PER_SCRAPE, 500, 1e4),
|
|
2907
2960
|
MAX_CONCURRENT_SCRAPES: parseEnvNumber(e, "PI_RESEARCH_MAX_CONCURRENT_SCRAPES", DEFAULTS.MAX_CONCURRENT_SCRAPES, 1, 20, true),
|
|
2961
|
+
MAX_SCRAPE_URLS: parseEnvNumber(e, "PI_RESEARCH_MAX_SCRAPE_URLS", DEFAULTS.MAX_SCRAPE_URLS, 1, 20, true),
|
|
2908
2962
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: parseEnvNumber(e, "PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS", DEFAULTS.YOUTUBE_TRANSCRIPT_MAX_VIDEOS, 1, 5, true),
|
|
2909
2963
|
YOUTUBE_TRANSCRIPT_TIMEOUT_MS: parseEnvNumber(e, "PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS", DEFAULTS.YOUTUBE_TRANSCRIPT_TIMEOUT_MS, 5e3, 12e4),
|
|
2910
2964
|
YOUTUBE_TRANSCRIPT_LANG: parseEnvString(e, "PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG", DEFAULTS.YOUTUBE_TRANSCRIPT_LANG),
|
|
@@ -3002,6 +3056,7 @@ var init_config = __esm({
|
|
|
3002
3056
|
init_host_config();
|
|
3003
3057
|
init_text_utils();
|
|
3004
3058
|
init_atomic_replace();
|
|
3059
|
+
init_availability();
|
|
3005
3060
|
ConfigSchema = Type.Object({
|
|
3006
3061
|
/** Per-researcher timeout in milliseconds (default: 300000, range: 3-30 min) */
|
|
3007
3062
|
RESEARCHER_TIMEOUT_MS: Type.Number({ minimum: 18e4, maximum: 18e5, default: 3e5 }),
|
|
@@ -3046,8 +3101,10 @@ var init_config = __esm({
|
|
|
3046
3101
|
MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: Type.Number({ minimum: 0.05, maximum: 1, default: 0.15 }),
|
|
3047
3102
|
/** Estimated tokens per scrape result for planning (default: 2500) */
|
|
3048
3103
|
AVG_TOKENS_PER_SCRAPE: Type.Number({ minimum: 500, maximum: 1e4, default: 2500 }),
|
|
3049
|
-
/** Maximum
|
|
3104
|
+
/** Maximum concurrent scrapes (default: 3) */
|
|
3050
3105
|
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
3106
|
+
/** Maximum URLs fetched per scrape batch; extras are listed under "Not Fetched — Over Batch Cap" (default: 8, range: 1-20) */
|
|
3107
|
+
MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 8 }),
|
|
3051
3108
|
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
3052
3109
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
3053
3110
|
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
|
@@ -3184,6 +3241,7 @@ var init_config = __esm({
|
|
|
3184
3241
|
"PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING",
|
|
3185
3242
|
"PI_RESEARCH_AVG_TOKENS_PER_SCRAPE",
|
|
3186
3243
|
"PI_RESEARCH_MAX_CONCURRENT_SCRAPES",
|
|
3244
|
+
"PI_RESEARCH_MAX_SCRAPE_URLS",
|
|
3187
3245
|
"PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS",
|
|
3188
3246
|
"PI_RESEARCH_SEARCH_TIMEOUT_MS",
|
|
3189
3247
|
"PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS",
|
|
@@ -3240,6 +3298,13 @@ function getMaxScrapeBatches(config, cachingActive = false) {
|
|
|
3240
3298
|
return 2;
|
|
3241
3299
|
}
|
|
3242
3300
|
}
|
|
3301
|
+
function getMaxScrapeUrls(config) {
|
|
3302
|
+
try {
|
|
3303
|
+
return (config || getConfig()).MAX_SCRAPE_URLS;
|
|
3304
|
+
} catch {
|
|
3305
|
+
return MAX_SCRAPE_URLS;
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3243
3308
|
function resolveExcludedTools(callerExclusions, configDisabled = []) {
|
|
3244
3309
|
return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
|
|
3245
3310
|
}
|
|
@@ -3250,7 +3315,7 @@ var init_constants = __esm({
|
|
|
3250
3315
|
init_config();
|
|
3251
3316
|
MAX_GATHERING_CALLS = 12;
|
|
3252
3317
|
MAX_GREP_CALLS = 30;
|
|
3253
|
-
MAX_SCRAPE_URLS =
|
|
3318
|
+
MAX_SCRAPE_URLS = 8;
|
|
3254
3319
|
MAX_TEAM_SIZE_LEVEL_1 = 2;
|
|
3255
3320
|
MAX_TEAM_SIZE_LEVEL_2 = 3;
|
|
3256
3321
|
MAX_TEAM_SIZE_LEVEL_3 = 5;
|
|
@@ -9007,11 +9072,15 @@ function getMigrationStrategy(config) {
|
|
|
9007
9072
|
logger.warn(`[knowledge] Invalid migration strategy '${strategy}'. Valid options: drop, re-embed, backup. Falling back to default (backup).`);
|
|
9008
9073
|
return void 0;
|
|
9009
9074
|
}
|
|
9010
|
-
async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory, withLock, config = getConfig(), workspace = process.cwd()) {
|
|
9075
|
+
async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory, withLock, config = getConfig(), workspace = process.cwd(), availability = probeKnowledgeStoreAvailability()) {
|
|
9011
9076
|
if (config.KNOWLEDGE_STORE_MODE === "none") {
|
|
9012
9077
|
logger.debug("[knowledge] Knowledge store is disabled in configuration");
|
|
9013
9078
|
return null;
|
|
9014
9079
|
}
|
|
9080
|
+
if (!availability.available) {
|
|
9081
|
+
logger.warn(`[knowledge] ${describeKnowledgeStoreUnavailability(availability)}`);
|
|
9082
|
+
return null;
|
|
9083
|
+
}
|
|
9015
9084
|
validateConfig(config);
|
|
9016
9085
|
for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) {
|
|
9017
9086
|
let store;
|
|
@@ -9089,6 +9158,7 @@ var init_knowledge = __esm({
|
|
|
9089
9158
|
init_config();
|
|
9090
9159
|
init_logger();
|
|
9091
9160
|
init_model_config();
|
|
9161
|
+
init_availability();
|
|
9092
9162
|
init_model_config();
|
|
9093
9163
|
MAX_INIT_RETRIES = 5;
|
|
9094
9164
|
BASE_RETRY_DELAY_MS = 1e3;
|
|
@@ -9115,6 +9185,7 @@ var init_knowledge_store_service = __esm({
|
|
|
9115
9185
|
init_embedding_factory();
|
|
9116
9186
|
init_config();
|
|
9117
9187
|
init_embedder_utils();
|
|
9188
|
+
init_availability();
|
|
9118
9189
|
KnowledgeStoreService = class {
|
|
9119
9190
|
name = ServiceNames.KNOWLEDGE_STORE;
|
|
9120
9191
|
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
@@ -9255,9 +9326,10 @@ var init_knowledge_store_service = __esm({
|
|
|
9255
9326
|
}
|
|
9256
9327
|
}
|
|
9257
9328
|
if (!components) {
|
|
9258
|
-
|
|
9329
|
+
const depsMissing = !probeKnowledgeStoreAvailability().available;
|
|
9330
|
+
logger.debug(`[KnowledgeStoreService] Knowledge store is disabled (${depsMissing ? "dependencies missing" : "mode = none"}). Setting lifecycle to DISABLED.`);
|
|
9259
9331
|
this.lifecycle = "disabled" /* DISABLED */;
|
|
9260
|
-
this._disabledReason = "mode";
|
|
9332
|
+
this._disabledReason = depsMissing ? "native" : "mode";
|
|
9261
9333
|
this._embedder = null;
|
|
9262
9334
|
this._store = null;
|
|
9263
9335
|
this._writerQueue = null;
|
|
@@ -10104,6 +10176,9 @@ function missResult(reason) {
|
|
|
10104
10176
|
case "store_disabled":
|
|
10105
10177
|
text = "No results found (knowledge store is disabled in settings). Live research can get the info.";
|
|
10106
10178
|
break;
|
|
10179
|
+
case "store_packages_missing":
|
|
10180
|
+
text = "No results found (knowledge store is off \u2014 its required packages are not installed on this host, so it stays off regardless of settings). Live research can get the info.";
|
|
10181
|
+
break;
|
|
10107
10182
|
case "store_not_ready":
|
|
10108
10183
|
text = "No results found (knowledge store is initializing). Live research can get the info.";
|
|
10109
10184
|
break;
|
|
@@ -10166,8 +10241,10 @@ function createResearchKnowledgeSearchTool(iface) {
|
|
|
10166
10241
|
}
|
|
10167
10242
|
const store = await storeService.getStore();
|
|
10168
10243
|
if (!store) {
|
|
10169
|
-
const
|
|
10170
|
-
|
|
10244
|
+
const disabledReason = storeService.getDisabledReason();
|
|
10245
|
+
if (disabledReason === "native") return missResult("store_packages_missing");
|
|
10246
|
+
if (disabledReason === "mode") return missResult("store_disabled");
|
|
10247
|
+
return missResult("store_not_ready");
|
|
10171
10248
|
}
|
|
10172
10249
|
const count = await store.count();
|
|
10173
10250
|
if (count === 0) {
|
|
@@ -12537,7 +12614,7 @@ import * as crypto4 from "node:crypto";
|
|
|
12537
12614
|
import { join as join8 } from "node:path";
|
|
12538
12615
|
import { existsSync as existsSync6, mkdirSync as mkdirSync3 } from "node:fs";
|
|
12539
12616
|
import { platform, homedir as homedir3 } from "node:os";
|
|
12540
|
-
import { createRequire as
|
|
12617
|
+
import { createRequire as createRequire4 } from "node:module";
|
|
12541
12618
|
function getWindowsCamoufoxDir() {
|
|
12542
12619
|
return join8(homedir3(), "AppData", "Local", "camoufox", "camoufox", "Cache");
|
|
12543
12620
|
}
|
|
@@ -12655,7 +12732,7 @@ function probeBrowserNativeDeps(load = defaultNativeDepLoad) {
|
|
|
12655
12732
|
}
|
|
12656
12733
|
}
|
|
12657
12734
|
function defaultNativeDepLoad() {
|
|
12658
|
-
const require2 =
|
|
12735
|
+
const require2 = createRequire4(import.meta.url);
|
|
12659
12736
|
const Database = require2("better-sqlite3");
|
|
12660
12737
|
const db = new Database(":memory:");
|
|
12661
12738
|
db.close();
|
|
@@ -15988,7 +16065,7 @@ import { FixedClusterPool, WorkerChoiceStrategies } from "poolifier";
|
|
|
15988
16065
|
// src/infrastructure/browser/ensure-browser.ts
|
|
15989
16066
|
init_logger();
|
|
15990
16067
|
import { spawn as spawn2 } from "node:child_process";
|
|
15991
|
-
import { createRequire as
|
|
16068
|
+
import { createRequire as createRequire5 } from "node:module";
|
|
15992
16069
|
import { createHash as createHash2 } from "node:crypto";
|
|
15993
16070
|
import { existsSync as existsSync7, readdirSync as readdirSync3, statSync as statSync3, fstatSync, openSync as openSync4, closeSync as closeSync4, rmSync } from "node:fs";
|
|
15994
16071
|
import { join as join11, dirname as dirname8 } from "node:path";
|
|
@@ -16015,7 +16092,7 @@ function isBrowserBinaryPresent() {
|
|
|
16015
16092
|
}
|
|
16016
16093
|
function resolveCamoufoxBin() {
|
|
16017
16094
|
try {
|
|
16018
|
-
const require2 =
|
|
16095
|
+
const require2 = createRequire5(import.meta.url);
|
|
16019
16096
|
const pkgJson = require2.resolve("camoufox-js/package.json");
|
|
16020
16097
|
const bin = join11(dirname8(pkgJson), "..", ".bin", process.platform === "win32" ? "camoufox-js.cmd" : "camoufox-js");
|
|
16021
16098
|
if (existsSync7(bin)) return bin;
|
|
@@ -17939,6 +18016,7 @@ healthRegistry.initialize().catch(() => {
|
|
|
17939
18016
|
|
|
17940
18017
|
// src/healthcheck/index.ts
|
|
17941
18018
|
init_embedder_utils();
|
|
18019
|
+
init_availability();
|
|
17942
18020
|
async function checkBrowserCapability() {
|
|
17943
18021
|
const mockMode = process.env["PI_RESEARCH_MOCK_SEARCH"] === "true" && process.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
|
|
17944
18022
|
if (isBrowserAvailable() || mockMode) {
|
|
@@ -17957,6 +18035,11 @@ async function checkBrowserCapability() {
|
|
|
17957
18035
|
return { healthy: false, error: 'Camoufox (browser) not found. Run "npx camoufox-js fetch" to install the browser.' };
|
|
17958
18036
|
}
|
|
17959
18037
|
}
|
|
18038
|
+
function knowledgeStoreDisabledStatus() {
|
|
18039
|
+
const availability = probeKnowledgeStoreAvailability();
|
|
18040
|
+
const detail = availability.available ? "native embedding/vector stack unavailable on this platform" : `required packages not resolvable \u2014 ${availability.missing.join(", ")}`;
|
|
18041
|
+
return `disabled (${detail})`;
|
|
18042
|
+
}
|
|
17960
18043
|
function registerHealthChecks(registry, container = getServiceContainer()) {
|
|
17961
18044
|
const config = getConfig();
|
|
17962
18045
|
const healthTimeoutMs = config.HEALTH_CHECK_TIMEOUT_MS;
|
|
@@ -17988,7 +18071,7 @@ function registerHealthChecks(registry, container = getServiceContainer()) {
|
|
|
17988
18071
|
if (existing?.lifecycle === "disabled" /* DISABLED */) {
|
|
17989
18072
|
const reason = existing.getDisabledReason();
|
|
17990
18073
|
return { healthy: true, diagnostic: {
|
|
17991
|
-
status: reason === "native" ?
|
|
18074
|
+
status: reason === "native" ? knowledgeStoreDisabledStatus() : "disabled"
|
|
17992
18075
|
} };
|
|
17993
18076
|
}
|
|
17994
18077
|
const status = existing?.lifecycle === "initializing" /* INITIALIZING */ ? "initializing" : existing?.lifecycle === "disposed" /* DISPOSED */ || existing?.lifecycle === "disposing" /* DISPOSING */ ? "disposed (not running)" : "ready (idle)";
|
|
@@ -18007,12 +18090,12 @@ function registerHealthChecks(registry, container = getServiceContainer()) {
|
|
|
18007
18090
|
return { healthy: true, diagnostic: { status: "disabled in config" } };
|
|
18008
18091
|
}
|
|
18009
18092
|
if (isNativeStackUnavailableError(initError)) {
|
|
18010
|
-
return { healthy: true, diagnostic: { status:
|
|
18093
|
+
return { healthy: true, diagnostic: { status: knowledgeStoreDisabledStatus() } };
|
|
18011
18094
|
}
|
|
18012
18095
|
if (service?.lifecycle === "disabled" /* DISABLED */) {
|
|
18013
18096
|
const reason = service.getDisabledReason();
|
|
18014
18097
|
return { healthy: true, diagnostic: {
|
|
18015
|
-
status: reason === "native" ?
|
|
18098
|
+
status: reason === "native" ? knowledgeStoreDisabledStatus() : "disabled"
|
|
18016
18099
|
} };
|
|
18017
18100
|
}
|
|
18018
18101
|
try {
|
|
@@ -18061,7 +18144,7 @@ function registerHealthChecks(registry, container = getServiceContainer()) {
|
|
|
18061
18144
|
};
|
|
18062
18145
|
} catch (e) {
|
|
18063
18146
|
if (isNativeStackUnavailableError(e)) {
|
|
18064
|
-
return { healthy: true, diagnostic: { status:
|
|
18147
|
+
return { healthy: true, diagnostic: { status: knowledgeStoreDisabledStatus() } };
|
|
18065
18148
|
}
|
|
18066
18149
|
return { healthy: false, error: `Knowledge store healthcheck failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
18067
18150
|
}
|
|
@@ -19261,6 +19344,7 @@ init_cancellation();
|
|
|
19261
19344
|
function createScrapeTool(options) {
|
|
19262
19345
|
const config = options.config || getConfig(options.ctx.cwd);
|
|
19263
19346
|
const maxScrapeBatches = options.tracker?.getToolLimit("scrape") ?? getMaxScrapeBatches(config);
|
|
19347
|
+
const maxScrapeUrls = getMaxScrapeUrls(config);
|
|
19264
19348
|
const container = tryGetServiceContainerFromCtx(options.ctx);
|
|
19265
19349
|
const fallbackState = {
|
|
19266
19350
|
version: 1,
|
|
@@ -19279,7 +19363,7 @@ function createScrapeTool(options) {
|
|
|
19279
19363
|
fallbackState.allScrapedLinks = [.../* @__PURE__ */ new Set([...fallbackState.allScrapedLinks, ...links])];
|
|
19280
19364
|
});
|
|
19281
19365
|
const ScrapeParamsSchema = Type5.Object({
|
|
19282
|
-
urls: Type5.Array(Type5.String({ description: `The URLs to scrape (only the first ${
|
|
19366
|
+
urls: Type5.Array(Type5.String({ description: `The URLs to scrape (only the first ${maxScrapeUrls} are fetched per call; extras are reported under "Not Fetched" rather than silently dropped)` }), { minItems: 1, maxItems: 20 }),
|
|
19283
19367
|
maxConcurrency: Type5.Optional(Type5.Number({ default: config.MAX_CONCURRENT_SCRAPES, minimum: 1, maximum: 20 }))
|
|
19284
19368
|
});
|
|
19285
19369
|
const trackerLimit = options.tracker?.getToolLimit("scrape");
|
|
@@ -19289,7 +19373,7 @@ function createScrapeTool(options) {
|
|
|
19289
19373
|
batchProtocolText = `PROTOCOL: Batch 1 \u2192 Batch 2 \u2192 ... (up to ${effectiveLimit} batches)`;
|
|
19290
19374
|
} else {
|
|
19291
19375
|
const batchNumbers = Array.from({ length: effectiveLimit }, (_, i) => `Batch ${i + 1}`).join(" \u2192 ");
|
|
19292
|
-
batchProtocolText = `PROTOCOL: ${batchNumbers} (up to ${
|
|
19376
|
+
batchProtocolText = `PROTOCOL: ${batchNumbers} (up to ${maxScrapeUrls} URLs each).`;
|
|
19293
19377
|
}
|
|
19294
19378
|
return {
|
|
19295
19379
|
name: "scrape",
|
|
@@ -19298,7 +19382,7 @@ function createScrapeTool(options) {
|
|
|
19298
19382
|
promptSnippet: `Read full content of web pages or PDFs (up to ${maxScrapeBatches} batches)`,
|
|
19299
19383
|
promptGuidelines: [
|
|
19300
19384
|
batchProtocolText,
|
|
19301
|
-
`Up to ${
|
|
19385
|
+
`Up to ${maxScrapeUrls} URLs per batch.`,
|
|
19302
19386
|
"Handshake is ELIMINATED. Start scraping immediately.",
|
|
19303
19387
|
"PDFs are auto-detected and extracted with high fidelity."
|
|
19304
19388
|
],
|
|
@@ -19344,8 +19428,8 @@ function createScrapeTool(options) {
|
|
|
19344
19428
|
}
|
|
19345
19429
|
}
|
|
19346
19430
|
const fetchCandidates = [...dedupedUrls, ...evictedDuplicates];
|
|
19347
|
-
const finalUrls = fetchCandidates.slice(0,
|
|
19348
|
-
const skippedUrls = fetchCandidates.slice(
|
|
19431
|
+
const finalUrls = fetchCandidates.slice(0, maxScrapeUrls);
|
|
19432
|
+
const skippedUrls = fetchCandidates.slice(maxScrapeUrls);
|
|
19349
19433
|
if (options.getTokensUsed) {
|
|
19350
19434
|
const ctxWindow = options.contextWindowSize ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
19351
19435
|
const tokensUsed = options.getTokensUsed();
|
|
@@ -19512,7 +19596,7 @@ ${dedupNote}`;
|
|
|
19512
19596
|
markdown += `## Not Fetched \u2014 Over Batch Cap (${skippedUrls.length})
|
|
19513
19597
|
|
|
19514
19598
|
`;
|
|
19515
|
-
markdown += `Only the first ${
|
|
19599
|
+
markdown += `Only the first ${maxScrapeUrls} URLs of a batch are fetched. The following URLs were NOT fetched \u2014 no content for them appears above. Request them in a later scrape batch if still needed:
|
|
19516
19600
|
`;
|
|
19517
19601
|
for (const url of skippedUrls) {
|
|
19518
19602
|
markdown += `- ${url}
|
|
@@ -23685,7 +23769,7 @@ ${effectiveInitialLinks.map((l) => `- ${l}`).join("\n")}`;
|
|
|
23685
23769
|
let lastError;
|
|
23686
23770
|
const researcherExecutionStartMs = Date.now();
|
|
23687
23771
|
const deliveredSteeringIds = /* @__PURE__ */ new Set();
|
|
23688
|
-
const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "").replace("{{digest_section}}", () => RESEARCHER_DIGEST_SECTION).trim();
|
|
23772
|
+
const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "").replaceAll("{{max_scrape_urls}}", String(config.MAX_SCRAPE_URLS)).replace("{{digest_section}}", () => RESEARCHER_DIGEST_SECTION).trim();
|
|
23689
23773
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
23690
23774
|
if (signal?.aborted) throw new Error("Aborted");
|
|
23691
23775
|
if (stopRequested()) {
|
|
@@ -24215,7 +24299,7 @@ For roughly one in ${this.config.YOUTUBE_QUERY_EVERY_N} of your queries, append
|
|
|
24215
24299
|
Your goal is to gather a focused, high-quality pool of initial links.
|
|
24216
24300
|
|
|
24217
24301
|
## Scrape
|
|
24218
|
-
After searching, scrape the best sources using the \`scrape\` tool (up to ${maxScrapeBatchesDisplay} batches, up to
|
|
24302
|
+
After searching, scrape the best sources using the \`scrape\` tool (up to ${maxScrapeBatchesDisplay} batches, up to ${this.config.MAX_SCRAPE_URLS} URLs each).
|
|
24219
24303
|
Prioritize primary sources and authoritative data.`;
|
|
24220
24304
|
consumeQueuedMessages(this.options.sessionId);
|
|
24221
24305
|
const steeringMessages = getSteeringMessages(this.options.sessionId);
|
|
@@ -24228,7 +24312,7 @@ Prioritize primary sources and authoritative data.`;
|
|
|
24228
24312
|
evidenceLines.push("## Initial Links\nInvestigate these provided URLs first:\n" + this.options.initialLinks.map((l) => `- ${l}`).join("\n"));
|
|
24229
24313
|
}
|
|
24230
24314
|
evidenceLines.push(quickEvidenceSection);
|
|
24231
|
-
const prompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "- `search`: Perform broad web searches (Round 1 only).").replace("{{digest_section}}", "").trim();
|
|
24315
|
+
const prompt = injectCurrentDate(researcherPromptTemplate, "researcher").replace("{{extra_tool_guidelines}}", "- `search`: Perform broad web searches (Round 1 only).").replaceAll("{{max_scrape_urls}}", String(this.config.MAX_SCRAPE_URLS)).replace("{{digest_section}}", "").trim();
|
|
24232
24316
|
const userMessage = [
|
|
24233
24317
|
`Goal: ${query}`,
|
|
24234
24318
|
storeSection.trim(),
|
|
@@ -27278,7 +27362,8 @@ async function cmdKnowledgeConfig(kc) {
|
|
|
27278
27362
|
scope: ${kc.mode === "none" ? "disabled here" : kc.mode === "project" ? "this directory only" : "shared across all directories"}
|
|
27279
27363
|
saved: per-directory (${cwd})
|
|
27280
27364
|
db dir: ${info2.dbDir}
|
|
27281
|
-
` + (
|
|
27365
|
+
` + (info2.available ? "" : ` state: ${info2.unavailableReason}
|
|
27366
|
+
`) + (overridden ? `
|
|
27282
27367
|
NOTE: the effective mode here is still '${info2.mode}' because a higher-precedence ${info2.origin} overrides it. Clear that source for this setting to take effect.
|
|
27283
27368
|
` : "")
|
|
27284
27369
|
);
|
|
@@ -27293,7 +27378,8 @@ NOTE: the effective mode here is still '${info2.mode}' because a higher-preceden
|
|
|
27293
27378
|
`knowledge store mode: ${info.mode} (source: ${info.origin})
|
|
27294
27379
|
scope: ${info.mode === "none" ? "disabled here" : info.mode === "project" ? "this directory only" : "shared across all directories"}
|
|
27295
27380
|
db dir: ${info.dbDir}
|
|
27296
|
-
|
|
27381
|
+
` + (info.available ? "" : ` state: ${info.unavailableReason}
|
|
27382
|
+
`) + `
|
|
27297
27383
|
change it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>
|
|
27298
27384
|
change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}
|
|
27299
27385
|
`
|
|
@@ -29,10 +29,10 @@ Do not treat any source as inherently objective. Justify its inclusion through t
|
|
|
29
29
|
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect. Any knowledge-store results in your user message were retrieved automatically for your goal from the **Project Knowledge Store** and/or **User Knowledge Store** — cite them with that store name as their `Source:`.
|
|
30
30
|
|
|
31
31
|
### Step 2: Scrape Round 1
|
|
32
|
-
Identify the
|
|
32
|
+
Identify the most promising URLs and scrape them in one batch. The scrape tool accepts up to {{max_scrape_urls}} URLs per call — {{max_scrape_urls}} is your target per round, so plan each batch at the full budget rather than stopping early. Prioritize primary sources, authoritative references, and dense documentation; all else being equal, lean toward more recent / current sources and topics.
|
|
33
33
|
|
|
34
34
|
### Step 3: Scrape Round 2 (if needed)
|
|
35
|
-
If your first batch did not yield enough material, scrape up to
|
|
35
|
+
If your first batch did not yield enough material, scrape up to {{max_scrape_urls}} additional URLs from **your own source list** that you haven't read yet. Before choosing, review the **Session URL Pool** at the bottom of each scrape response — it shows what topics and domains your sibling researchers are already covering. Use this to steer your remaining scrapes from your own list:
|
|
36
36
|
- **Complement** your siblings: if they're covering area X, pick your own URLs that go deeper on X or fill adjacent gaps.
|
|
37
37
|
- **Diversify**: if you have sources on an angle no one else is touching, prioritize those to maximize session coverage.
|
|
38
38
|
- **Stay on mission**: regardless of what siblings are doing, make sure you have enough material for your assigned goal.
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -2665,8 +2665,10 @@ var ConfigSchema = Type.Object({
|
|
|
2665
2665
|
MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: Type.Number({ minimum: 0.05, maximum: 1, default: 0.15 }),
|
|
2666
2666
|
/** Estimated tokens per scrape result for planning (default: 2500) */
|
|
2667
2667
|
AVG_TOKENS_PER_SCRAPE: Type.Number({ minimum: 500, maximum: 1e4, default: 2500 }),
|
|
2668
|
-
/** Maximum
|
|
2668
|
+
/** Maximum concurrent scrapes (default: 3) */
|
|
2669
2669
|
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
2670
|
+
/** Maximum URLs fetched per scrape batch; extras are listed under "Not Fetched — Over Batch Cap" (default: 8, range: 1-20) */
|
|
2671
|
+
MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 8 }),
|
|
2670
2672
|
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
2671
2673
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
2672
2674
|
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -113,7 +113,7 @@ agent to invoke:
|
|
|
113
113
|
| Tool | Quick | Deep | Backend |
|
|
114
114
|
|------|-------|------|---------|
|
|
115
115
|
| `search` | ✓ | — | DuckDuckGo Lite via the stealth browser |
|
|
116
|
-
| `scrape` | ✓ | ✓ | Batch page fetch → Markdown via the stealth browser (up to
|
|
116
|
+
| `scrape` | ✓ | ✓ | Batch page fetch → Markdown via the stealth browser (up to MAX_SCRAPE_URLS URLs per call, default 8) |
|
|
117
117
|
| `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos by default, configurable 1–5; one call per researcher) |
|
|
118
118
|
| `security_search` | ✓ | ✓ | NVD, CISA KEV, GitHub Advisories, OSV |
|
|
119
119
|
| `stackexchange` | ✓ | ✓ | Stack Exchange network |
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ 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.6.9] - 2026-09-01
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **`MAX_SCRAPE_URLS` is now a real config setting, raised to 8 (was a hardcoded constant capped at 6).** The per-batch page cap joined every other scrape knob in the Config schema (`PI_RESEARCH_MAX_SCRAPE_URLS`, range 1–20, default 8): env-var parsing, the config.env bridge, persistence, and the bundled thread-worker schema mirror all follow, and `src/tools/scrape.ts` resolves one `getMaxScrapeUrls(config)` value that feeds the tool-schema description, the batch-protocol text, the per-call slice, and the "Not Fetched — Over Batch Cap" report, so the enforced cap and every string the model sees are the same number by construction (previously the constant was 6 while the researcher prompt separately steered a lower target). `src/constants.ts` keeps `MAX_SCRAPE_URLS` as a documented schema-default mirror plus the accessor, mirroring `MAX_GATHERING_CALLS`. The researcher prompt no longer steers below the cap: a new {{max_scrape_urls}} placeholder — substituted from config in BOTH call sites (deep researcher-executor and the quick orchestrator, each of which maintains its own replace chain) — states the per-call maximum as the batch target ("{{max_scrape_urls}} is your target per round, so plan each batch at the full budget"), so the prompt can never drift from the enforced cap the way the old hardcoded targets (4, then 6) already had from the constants-derived tool text. The shipped-render tests for both paths pin the substitution. Context-gate note: batches of 8 bill 8 × `AVG_TOKENS_PER_SCRAPE` against the gate fraction, so installs pinning a high per-URL estimate with a small window (the code comment records an 8-URL batch blocked on a 128k model) may see more `context_limit` blocks — tune `PI_RESEARCH_AVG_TOKENS_PER_SCRAPE` if so. `MAX_CONCURRENT_SCRAPES` (fetch parallelism) is untouched at its long-standing default of 3.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **A knowledge store whose packages are not even resolvable is now cleanly OFF — at the settings layer, not buried in runtime degrade.** Since 1.6.7 the store correctly memoized DISABLED when `@huggingface/transformers` (optional dep) was skipped at install or the `@lancedb/lancedb` install was broken — but only AFTER running the full init: `MAX_INIT_RETRIES` (5) attempts of exponential backoff (~31s) per fresh process, spent discovering what `require.resolve` already knows, and every settings surface kept advertising a `KNOWLEDGE_STORE_MODE` that could never run. Now a new execution-free probe (`src/knowledge/availability.ts` — `require.resolve` only, never loads the native modules) is consulted BEFORE the retry loop: `createKnowledgeStoreComponents` returns null immediately (the same clean OFF verdict mode=`none` produces), and `KnowledgeStoreService` records it as DISABLED(`native`) — permanent for the process, never revived by a mode flip, mirroring the existing catch-path semantics. The settings surfaces now tell the truth about it everywhere: `describeKnowledgeStoreMode` gained `available`/`unavailableReason` (flows into `knowledge-config --json` automatically), the `knowledge-config` text output and the `/research-config` TUI's Knowledge Mode item say what is missing and how to repair it, and the healthcheck's 'disabled' verdict names the actual missing packages instead of the generic platform line. One more surface joined them: the `research_knowledge_search` miss message no longer tells a package-gap host the store is "disabled in settings" — a 'native' service disable now reports `store_packages_missing` (the repair is installing the package, not flipping a Knowledge Mode toggle that cannot help), while a true mode disable keeps `store_disabled` and an in-flight/failed init keeps `store_not_ready`. Unit tests pin the probe, the fail-fast (embedder factory never invoked), the native-vs-mode disable distinction, and the miss-reason split.
|
|
17
|
+
|
|
8
18
|
## [1.6.8] - 2026-08-31
|
|
9
19
|
|
|
10
20
|
### Changed
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -71,6 +71,7 @@ Research
|
|
|
71
71
|
| `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). When prompt caching is known to be active for the resolved research model (Anthropic API models, or a provider route explicitly configured for Anthropic-style cache control), the effective limit is this value plus one — the cached prompt prefix makes the extra batch cheap. |
|
|
72
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
|
+
| `PI_RESEARCH_MAX_SCRAPE_URLS` | `8` | 1–20 | Maximum URLs fetched per scrape batch. URLs past the cap are listed under "Not Fetched — Over Batch Cap" and must be requested in a later batch. (Promoted from a hardcoded constant so it is env/config-file tunable like every other scrape knob.) |
|
|
74
75
|
| `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
|
|
75
76
|
| `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
|
|
76
77
|
| `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
|
|
@@ -121,7 +122,7 @@ See the [knowledge store doc](KNOWLEDGE-STORE.md) for what each value does.
|
|
|
121
122
|
|
|
122
123
|
| Variable | Default | Range | Description |
|
|
123
124
|
|----------|---------|-------|-------------|
|
|
124
|
-
| `PI_RESEARCH_KNOWLEDGE_STORE_MODE` (TUI) `[project]` | `global` | none · project · global | Store scope: one shared store across every directory (`global`), scoped to the current directory (`project`), or disabled (`none`). |
|
|
125
|
+
| `PI_RESEARCH_KNOWLEDGE_STORE_MODE` (TUI) `[project]` | `global` | none · project · global | Store scope: one shared store across every directory (`global`), scoped to the current directory (`project`), or disabled (`none`). Independently of this setting, the store is cleanly OFF when its required packages are not installed (optional `@huggingface/transformers` skipped at install, broken `@lancedb/lancedb`): init fails fast instead of retry-storming, and `pi-research knowledge-config`, the `/research-config` menu, and the healthcheck all name the missing package and the repair. |
|
|
125
126
|
| `PI_RESEARCH_EMBEDDING_MODEL` (TUI) | `onnx-community/granite-embedding-small-english-r2-ONNX` | — | Embedding model. Changing it clears the store and starts fresh. |
|
|
126
127
|
| `PI_RESEARCH_EMBEDDING_DEVICE` (TUI) | `auto` | auto · webgpu · cpu | Inference backend. `auto` probes WebGPU viability out-of-process and falls back to CPU; `cpu` forces CPU; `webgpu` forces the GPU path with no probe (advanced — can hard-crash on a software GPU). The TUI exposes only `auto` (as "GPU") and `cpu`. |
|
|
127
128
|
| `PI_RESEARCH_CACHE_TTL_DAYS` (TUI) | `30` | 1–365 | How long cached findings are kept before eviction. |
|
package/docs/KNOWLEDGE-STORE.md
CHANGED
|
@@ -144,17 +144,25 @@ platform/architecture pairs:
|
|
|
144
144
|
| Windows | x64 / arm64 | Supported |
|
|
145
145
|
|
|
146
146
|
Intel Macs (`darwin-x64`) have no prebuilt binary for either component, so the
|
|
147
|
-
knowledge store cannot run there. The
|
|
147
|
+
knowledge store cannot run there. The same clean OFF applies on any platform when a
|
|
148
|
+
required package is not installed at all — optional `@huggingface/transformers` is
|
|
149
|
+
skipped by `npm install --omit=optional` (and by an npm optional-dependency bug), or
|
|
150
|
+
the `@lancedb/lancedb` install is broken. The degradation is automatic and fast:
|
|
148
151
|
|
|
149
152
|
- Research still works. Search, scraping, YouTube transcripts, the security databases,
|
|
150
153
|
Stack Exchange, planning, and synthesis are unaffected — only the store is missing.
|
|
151
|
-
- The
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
- The store fails fast. Missing packages are detected by resolution before any
|
|
155
|
+
initialization is attempted, so there is no retry storm — the store simply starts OFF.
|
|
156
|
+
- The settings surfaces say why. `pi-research knowledge-config`, the `/research-config`
|
|
157
|
+
menu, and the health check name the missing package and the repair (install the
|
|
158
|
+
optional dependencies) instead of advertising a mode the store cannot honor. A
|
|
159
|
+
`research_knowledge_search` miss reports the package gap rather than pointing at a
|
|
160
|
+
settings toggle that cannot help.
|
|
161
|
+
- The health check reports the store as disabled, not unhealthy, so the missing
|
|
162
|
+
component does not drag overall health to "unhealthy" or block a quick (depth-0) run.
|
|
163
|
+
|
|
164
|
+
No configuration is needed: install the package (a full install including optional
|
|
165
|
+
dependencies) and the store comes up; a mode flip alone cannot revive it mid-process.
|
|
158
166
|
|
|
159
167
|
### Retention and eviction
|
|
160
168
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -916,6 +916,7 @@ export async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeCo
|
|
|
916
916
|
` scope: ${kc.mode === 'none' ? 'disabled here' : kc.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
|
|
917
917
|
` saved: per-directory (${cwd})\n` +
|
|
918
918
|
` db dir: ${info.dbDir}\n` +
|
|
919
|
+
(info.available ? '' : ` state: ${info.unavailableReason}\n`) +
|
|
919
920
|
(overridden
|
|
920
921
|
? `\nNOTE: the effective mode here is still '${info.mode}' because a higher-precedence ${info.origin} overrides it. Clear that source for this setting to take effect.\n`
|
|
921
922
|
: ''),
|
|
@@ -933,6 +934,7 @@ export async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeCo
|
|
|
933
934
|
`knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
|
|
934
935
|
` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
|
|
935
936
|
` db dir: ${info.dbDir}\n` +
|
|
937
|
+
(info.available ? '' : ` state: ${info.unavailableReason}\n`) +
|
|
936
938
|
`\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
|
|
937
939
|
`change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
|
|
938
940
|
);
|
package/src/config.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { Type, type Static } from 'typebox';
|
|
|
21
21
|
import { Value } from 'typebox/value';
|
|
22
22
|
import { normalizeWorkspacePath } from './utils/text-utils.ts';
|
|
23
23
|
import { replaceFileSync } from './utils/atomic-replace.ts';
|
|
24
|
+
import { probeKnowledgeStoreAvailability, describeKnowledgeStoreUnavailability } from './knowledge/availability.ts';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Validates configuration schema using TypeBox.
|
|
@@ -70,8 +71,10 @@ export const ConfigSchema = Type.Object({
|
|
|
70
71
|
MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: Type.Number({ minimum: 0.05, maximum: 1.0, default: 0.15 }),
|
|
71
72
|
/** Estimated tokens per scrape result for planning (default: 2500) */
|
|
72
73
|
AVG_TOKENS_PER_SCRAPE: Type.Number({ minimum: 500, maximum: 10000, default: 2500 }),
|
|
73
|
-
/** Maximum
|
|
74
|
+
/** Maximum concurrent scrapes (default: 3) */
|
|
74
75
|
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
76
|
+
/** Maximum URLs fetched per scrape batch; extras are listed under "Not Fetched — Over Batch Cap" (default: 8, range: 1-20) */
|
|
77
|
+
MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 8 }),
|
|
75
78
|
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
76
79
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
77
80
|
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
|
@@ -242,6 +245,7 @@ const USER_MIGRATION_KEYS = [
|
|
|
242
245
|
'PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING',
|
|
243
246
|
'PI_RESEARCH_AVG_TOKENS_PER_SCRAPE',
|
|
244
247
|
'PI_RESEARCH_MAX_CONCURRENT_SCRAPES',
|
|
248
|
+
'PI_RESEARCH_MAX_SCRAPE_URLS',
|
|
245
249
|
'PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS',
|
|
246
250
|
'PI_RESEARCH_SEARCH_TIMEOUT_MS',
|
|
247
251
|
'PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS',
|
|
@@ -594,6 +598,16 @@ export interface KnowledgeStoreModeInfo {
|
|
|
594
598
|
origin: string;
|
|
595
599
|
/** Physical LanceDB directory backing the store (shared across project/global modes). */
|
|
596
600
|
dbDir: string;
|
|
601
|
+
/**
|
|
602
|
+
* Whether the store can actually RUN here — its required packages
|
|
603
|
+
* (@lancedb/lancedb, optional @huggingface/transformers) resolve. A configured
|
|
604
|
+
* mode means nothing when this is false: the store is cleanly OFF regardless.
|
|
605
|
+
* Settings surfaces must show this alongside `mode` so a host that cannot run
|
|
606
|
+
* the store never advertises a mode it cannot honor.
|
|
607
|
+
*/
|
|
608
|
+
available: boolean;
|
|
609
|
+
/** When {@link available} is false: the full human sentence saying what is missing and how to repair it. */
|
|
610
|
+
unavailableReason: string;
|
|
597
611
|
}
|
|
598
612
|
|
|
599
613
|
/**
|
|
@@ -632,7 +646,27 @@ export function describeKnowledgeStoreMode(
|
|
|
632
646
|
else if (fileHasKey(getGlobalEnvFilePath())) origin = 'config.env (machine-wide default)';
|
|
633
647
|
}
|
|
634
648
|
|
|
635
|
-
return {
|
|
649
|
+
return {
|
|
650
|
+
mode: cfg.KNOWLEDGE_STORE_MODE,
|
|
651
|
+
origin,
|
|
652
|
+
dbDir: getDbDir(cfg, cwd),
|
|
653
|
+
...availabilityFields(),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The availability half of {@link KnowledgeStoreModeInfo}, computed by the
|
|
659
|
+
* knowledge module's execution-free package probe. Kept as a helper (not inlined
|
|
660
|
+
* in every return) so the shape stays defined in exactly one place. Never
|
|
661
|
+
* throws: the probe catches resolution errors internally and reports them as
|
|
662
|
+
* `available: false`.
|
|
663
|
+
*/
|
|
664
|
+
function availabilityFields(): { available: boolean; unavailableReason: string } {
|
|
665
|
+
const availability = probeKnowledgeStoreAvailability();
|
|
666
|
+
return {
|
|
667
|
+
available: availability.available,
|
|
668
|
+
unavailableReason: describeKnowledgeStoreUnavailability(availability),
|
|
669
|
+
};
|
|
636
670
|
}
|
|
637
671
|
|
|
638
672
|
/**
|
|
@@ -895,6 +929,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
|
|
|
895
929
|
PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: String(config.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING),
|
|
896
930
|
PI_RESEARCH_AVG_TOKENS_PER_SCRAPE: String(config.AVG_TOKENS_PER_SCRAPE),
|
|
897
931
|
PI_RESEARCH_MAX_CONCURRENT_SCRAPES: String(config.MAX_CONCURRENT_SCRAPES),
|
|
932
|
+
PI_RESEARCH_MAX_SCRAPE_URLS: String(config.MAX_SCRAPE_URLS),
|
|
898
933
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS: String(config.YOUTUBE_TRANSCRIPT_MAX_VIDEOS),
|
|
899
934
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS: String(config.YOUTUBE_TRANSCRIPT_TIMEOUT_MS),
|
|
900
935
|
PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG: config.YOUTUBE_TRANSCRIPT_LANG,
|
|
@@ -1122,6 +1157,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
|
|
|
1122
1157
|
MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: parseEnvNumber(e, 'PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING', DEFAULTS.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING, 0.05, 1.0),
|
|
1123
1158
|
AVG_TOKENS_PER_SCRAPE: parseEnvNumber(e, 'PI_RESEARCH_AVG_TOKENS_PER_SCRAPE', DEFAULTS.AVG_TOKENS_PER_SCRAPE, 500, 10000),
|
|
1124
1159
|
MAX_CONCURRENT_SCRAPES: parseEnvNumber(e, 'PI_RESEARCH_MAX_CONCURRENT_SCRAPES', DEFAULTS.MAX_CONCURRENT_SCRAPES, 1, 20, true),
|
|
1160
|
+
MAX_SCRAPE_URLS: parseEnvNumber(e, 'PI_RESEARCH_MAX_SCRAPE_URLS', DEFAULTS.MAX_SCRAPE_URLS, 1, 20, true),
|
|
1125
1161
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS', DEFAULTS.YOUTUBE_TRANSCRIPT_MAX_VIDEOS, 1, 5, true),
|
|
1126
1162
|
YOUTUBE_TRANSCRIPT_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS', DEFAULTS.YOUTUBE_TRANSCRIPT_TIMEOUT_MS, 5000, 120000),
|
|
1127
1163
|
YOUTUBE_TRANSCRIPT_LANG: parseEnvString(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG', DEFAULTS.YOUTUBE_TRANSCRIPT_LANG)!,
|
package/src/constants.ts
CHANGED
|
@@ -71,8 +71,30 @@ export function getMaxScrapeBatches(config?: Config, cachingActive = false): num
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Maximum URLs fetched per scrape batch — SCHEMA-DEFAULT MIRROR only.
|
|
76
|
+
*
|
|
77
|
+
* The configured value lives in the Config schema (`MAX_SCRAPE_URLS`, env
|
|
78
|
+
* `PI_RESEARCH_MAX_SCRAPE_URLS`); this constant exists so non-config callers
|
|
79
|
+
* (docs text, test fixtures) and the accessor's fallback below have a name for
|
|
80
|
+
* the default. Consumers that enforce the cap (src/tools/scrape.ts) MUST go
|
|
81
|
+
* through {@link getMaxScrapeUrls} with their resolved config so the enforced
|
|
82
|
+
* cap, the tool schema description, and the over-batch-cap report can never
|
|
83
|
+
* drift apart.
|
|
84
|
+
*/
|
|
85
|
+
export const MAX_SCRAPE_URLS = 8;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the maximum URLs per scrape batch from config (env/config-file driven,
|
|
89
|
+
* mirrors getMaxScrapeBatches / getMaxGatheringCalls).
|
|
90
|
+
*/
|
|
91
|
+
export function getMaxScrapeUrls(config?: Config): number {
|
|
92
|
+
try {
|
|
93
|
+
return (config || getConfig()).MAX_SCRAPE_URLS;
|
|
94
|
+
} catch {
|
|
95
|
+
return MAX_SCRAPE_URLS; // Fallback to default
|
|
96
|
+
}
|
|
97
|
+
}
|
|
76
98
|
|
|
77
99
|
/**
|
|
78
100
|
* Get the units per researcher for the progress bar.
|
package/src/healthcheck/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { ServiceNames } from '../core/service-interfaces.ts';
|
|
|
15
15
|
import type { IStateManager, IHealthRegistryService, IKnowledgeStoreService, ISchedulerService } from '../core/service-interfaces.ts';
|
|
16
16
|
import { healthRegistry as globalHealthRegistry } from './registry.ts';
|
|
17
17
|
import { isNativeStackUnavailableError } from '../knowledge/embedder-utils.ts';
|
|
18
|
+
import { probeKnowledgeStoreAvailability } from '../knowledge/availability.ts';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Standalone BrowserCapability check, exported so tests can call it directly
|
|
@@ -44,6 +45,25 @@ export async function checkBrowserCapability(): Promise<{ healthy: boolean; erro
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The diagnostic status for a DISABLED knowledge store whose disable reason is
|
|
50
|
+
* 'native'. When the availability probe can attribute the gap to a missing
|
|
51
|
+
* PACKAGE (optional @huggingface/transformers skipped at install, broken
|
|
52
|
+
* @lancedb/lancedb install), name it — the generic platform line sent users
|
|
53
|
+
* platform-hunting when the actual fix was one npm install. When every package
|
|
54
|
+
* resolves, the gap really is the platform's (no prebuilt native binding, e.g.
|
|
55
|
+
* darwin-x64) and the platform line is the truth. Shared by the idle fast-path
|
|
56
|
+
* and the full-inspection path so one host always gets ONE verdict regardless
|
|
57
|
+
* of whether the check is forced.
|
|
58
|
+
*/
|
|
59
|
+
function knowledgeStoreDisabledStatus(): string {
|
|
60
|
+
const availability = probeKnowledgeStoreAvailability();
|
|
61
|
+
const detail = availability.available
|
|
62
|
+
? 'native embedding/vector stack unavailable on this platform'
|
|
63
|
+
: `required packages not resolvable — ${availability.missing.join(', ')}`;
|
|
64
|
+
return `disabled (${detail})`;
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
/**
|
|
48
68
|
* Register all health checks with a registry
|
|
49
69
|
*/
|
|
@@ -116,9 +136,7 @@ export function registerHealthChecks(registry: IHealthRegistryService, container
|
|
|
116
136
|
if (existing?.lifecycle === ServiceLifecycle.DISABLED) {
|
|
117
137
|
const reason = existing.getDisabledReason();
|
|
118
138
|
return { healthy: true, diagnostic: {
|
|
119
|
-
status: reason === 'native'
|
|
120
|
-
? 'disabled (native embedding/vector stack unavailable on this platform)'
|
|
121
|
-
: 'disabled',
|
|
139
|
+
status: reason === 'native' ? knowledgeStoreDisabledStatus() : 'disabled',
|
|
122
140
|
} };
|
|
123
141
|
}
|
|
124
142
|
// A DISPOSED/DISPOSING service is not "ready" — report it accurately without
|
|
@@ -150,13 +168,16 @@ export function registerHealthChecks(registry: IHealthRegistryService, container
|
|
|
150
168
|
return { healthy: true, diagnostic: { status: 'disabled in config' } };
|
|
151
169
|
}
|
|
152
170
|
// The native ML/vector stack ships no prebuilt for some platforms (Intel macOS
|
|
153
|
-
// / darwin-x64 has no onnxruntime-node binary nor @lancedb native binding)
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
171
|
+
// / darwin-x64 has no onnxruntime-node binary nor @lancedb native binding), and
|
|
172
|
+
// the optional @huggingface/transformers dep can be skipped at install time
|
|
173
|
+
// outright. Both are permanent capability gaps, not faults — report the store
|
|
174
|
+
// as DISABLED (healthy) so research still runs without the optional cache.
|
|
175
|
+
// Otherwise this critical component drags overall health to 'unhealthy' and
|
|
176
|
+
// quick (depth-0) research aborts with "Research cannot start" for any fresh
|
|
177
|
+
// user on such a host. When the gap is a missing PACKAGE (probe-resolvable),
|
|
178
|
+
// name it — see knowledgeStoreDisabledStatus().
|
|
158
179
|
if (isNativeStackUnavailableError(initError)) {
|
|
159
|
-
return { healthy: true, diagnostic: { status:
|
|
180
|
+
return { healthy: true, diagnostic: { status: knowledgeStoreDisabledStatus() } };
|
|
160
181
|
}
|
|
161
182
|
// getService can SUCCEED on a native-unavailable platform: the service memoizes
|
|
162
183
|
// the failure as DISABLED instead of throwing, so initError stays null and the
|
|
@@ -166,9 +187,7 @@ export function registerHealthChecks(registry: IHealthRegistryService, container
|
|
|
166
187
|
if (service?.lifecycle === ServiceLifecycle.DISABLED) {
|
|
167
188
|
const reason = service.getDisabledReason();
|
|
168
189
|
return { healthy: true, diagnostic: {
|
|
169
|
-
status: reason === 'native'
|
|
170
|
-
? 'disabled (native embedding/vector stack unavailable on this platform)'
|
|
171
|
-
: 'disabled',
|
|
190
|
+
status: reason === 'native' ? knowledgeStoreDisabledStatus() : 'disabled',
|
|
172
191
|
} };
|
|
173
192
|
}
|
|
174
193
|
try {
|
|
@@ -232,7 +251,7 @@ export function registerHealthChecks(registry: IHealthRegistryService, container
|
|
|
232
251
|
// Same platform-capability gap as above, but surfacing only when the store
|
|
233
252
|
// is touched lazily (getStore/getEmbedder load the native binding here).
|
|
234
253
|
if (isNativeStackUnavailableError(e)) {
|
|
235
|
-
return { healthy: true, diagnostic: { status:
|
|
254
|
+
return { healthy: true, diagnostic: { status: knowledgeStoreDisabledStatus() } };
|
|
236
255
|
}
|
|
237
256
|
return { healthy: false, error: `Knowledge store healthcheck failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
238
257
|
}
|
|
@@ -2665,8 +2665,10 @@ var ConfigSchema = Type.Object({
|
|
|
2665
2665
|
MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: Type.Number({ minimum: 0.05, maximum: 1, default: 0.15 }),
|
|
2666
2666
|
/** Estimated tokens per scrape result for planning (default: 2500) */
|
|
2667
2667
|
AVG_TOKENS_PER_SCRAPE: Type.Number({ minimum: 500, maximum: 1e4, default: 2500 }),
|
|
2668
|
-
/** Maximum
|
|
2668
|
+
/** Maximum concurrent scrapes (default: 3) */
|
|
2669
2669
|
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
2670
|
+
/** Maximum URLs fetched per scrape batch; extras are listed under "Not Fetched — Over Batch Cap" (default: 8, range: 1-20) */
|
|
2671
|
+
MAX_SCRAPE_URLS: Type.Number({ minimum: 1, maximum: 20, default: 8 }),
|
|
2670
2672
|
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
2671
2673
|
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
2672
2674
|
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { getEmbedder, clearEmbeddingInstance } from './embedding/embedding-factory.ts';
|
|
26
26
|
import { getConfig } from '../config.ts';
|
|
27
27
|
import { isNativeStackUnavailableError } from '../knowledge/embedder-utils.ts';
|
|
28
|
+
import { probeKnowledgeStoreAvailability } from '../knowledge/availability.ts';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Knowledge Store Service Implementation
|
|
@@ -250,9 +251,19 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
|
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
if (!components) {
|
|
253
|
-
|
|
254
|
+
// `null` now has TWO causes with different revival semantics:
|
|
255
|
+
// - KNOWLEDGE_STORE_MODE='none' → 'mode': a user choice, revivable by a
|
|
256
|
+
// later /research-config change without a restart.
|
|
257
|
+
// - availability probe says the store's packages are not resolvable →
|
|
258
|
+
// 'native': a host capability gap on this install, permanent for this
|
|
259
|
+
// process — memoized like the catch-path below so later getStore()
|
|
260
|
+
// calls don't re-run init, and so the healthcheck/config surfaces
|
|
261
|
+
// report the package-level reason rather than a user choice the user
|
|
262
|
+
// never made.
|
|
263
|
+
const depsMissing = !probeKnowledgeStoreAvailability().available;
|
|
264
|
+
logger.debug(`[KnowledgeStoreService] Knowledge store is disabled (${depsMissing ? 'dependencies missing' : 'mode = none'}). Setting lifecycle to DISABLED.`);
|
|
254
265
|
this.lifecycle = ServiceLifecycle.DISABLED;
|
|
255
|
-
this._disabledReason = 'mode';
|
|
266
|
+
this._disabledReason = depsMissing ? 'native' : 'mode';
|
|
256
267
|
this._embedder = null;
|
|
257
268
|
this._store = null;
|
|
258
269
|
this._writerQueue = null;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Store availability probe
|
|
3
|
+
*
|
|
4
|
+
* Cheap, execution-free answer to "can the knowledge store possibly run on this
|
|
5
|
+
* host?" — BEFORE anything tries to build it.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the store's ML/vector stack lives in two packages that are
|
|
8
|
+
* not guaranteed to be present at runtime —
|
|
9
|
+
* - `@huggingface/transformers` is an OPTIONAL dependency (skipped wholesale by
|
|
10
|
+
* `npm install --omit=optional` and by the npm optional-dependency install
|
|
11
|
+
* bug, npm/cli#4828 — see transformers-loader.ts and issue #10), and
|
|
12
|
+
* - `@lancedb/lancedb` is a regular dependency whose native binding is absent
|
|
13
|
+
* on platforms upstream ships no prebuilt for (e.g. darwin-x64).
|
|
14
|
+
*
|
|
15
|
+
* Until now a missing package was only discovered by RUNNING the full
|
|
16
|
+
* embedder+store initialization — which burned MAX_INIT_RETRIES (5) attempts of
|
|
17
|
+
* exponential backoff (~31s) per init before KnowledgeStoreService memoized the
|
|
18
|
+
* failure as DISABLED('native'). Correct outcome, terrible path: every fresh
|
|
19
|
+
* process paid the storm once, and the settings surfaces (research-config TUI,
|
|
20
|
+
* `knowledge-config` CLI, healthcheck) kept advertising a KNOWLEDGE_STORE_MODE
|
|
21
|
+
* that could never actually run.
|
|
22
|
+
*
|
|
23
|
+
* The probe resolves both modules through `require.resolve` — module RESOLUTION
|
|
24
|
+
* only; the module body (and its native bindings) is never executed — so it is
|
|
25
|
+
* safe to call from settings/CLI paths that must not pay the transformers /
|
|
26
|
+
* lancedb load cost. Resolution catches the dominant real-world failure (the
|
|
27
|
+
* package directory absent because an optional dep was skipped or the install
|
|
28
|
+
* is broken); a package that resolves but fails to LOAD its native binding is
|
|
29
|
+
* the residual case and remains covered by the runtime
|
|
30
|
+
* isNativeStackUnavailableError → DISABLED('native') path in
|
|
31
|
+
* knowledge-store-service.ts.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately imports NOTHING from the rest of the project beyond
|
|
34
|
+
* node: builtins — config.ts re-exports its verdict through
|
|
35
|
+
* describeKnowledgeStoreMode, and config.ts must not gain a dependency on the
|
|
36
|
+
* knowledge module graph (knowledge/index.ts imports config.ts).
|
|
37
|
+
*/
|
|
38
|
+
import { createRequire } from 'node:module';
|
|
39
|
+
|
|
40
|
+
/** Result of the availability probe. */
|
|
41
|
+
export interface KnowledgeStoreAvailability {
|
|
42
|
+
/** True when every required package resolves. */
|
|
43
|
+
available: boolean;
|
|
44
|
+
/** Package specifiers whose absence makes the store unusable, in check order. */
|
|
45
|
+
missing: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The packages the store cannot run without. `@lancedb/lancedb` is the vector
|
|
50
|
+
* store itself; `@huggingface/transformers` is the only embedding backend
|
|
51
|
+
* (see transformers-loader.ts — there is no remote-embedding fallback).
|
|
52
|
+
*/
|
|
53
|
+
const REQUIRED_PACKAGES: readonly string[] = ['@lancedb/lancedb', '@huggingface/transformers'];
|
|
54
|
+
|
|
55
|
+
// Memoized per-process: resolution touches the filesystem on every miss, the
|
|
56
|
+
// answer cannot change within a live process without a reinstall mid-flight,
|
|
57
|
+
// and the probe sits on settings/health paths that run often. Tests reset it
|
|
58
|
+
// via clearAvailabilityCache.
|
|
59
|
+
let _cached: KnowledgeStoreAvailability | null = null;
|
|
60
|
+
|
|
61
|
+
/** Resolver signature — injectable so tests never need the real filesystem. */
|
|
62
|
+
export type PackageResolver = (specifier: string) => string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Default resolver: root at THIS module's install tree, so resolution follows
|
|
66
|
+
* pi-research's own node_modules (where the optional deps would be) rather
|
|
67
|
+
* than whatever directory the caller happens to run from.
|
|
68
|
+
*/
|
|
69
|
+
const defaultResolver: PackageResolver = (specifier) =>
|
|
70
|
+
// import.meta.url survives esbuild's ESM output; createRequire only builds a
|
|
71
|
+
// resolver — it never evaluates the specifier.
|
|
72
|
+
createRequire(import.meta.url).resolve(specifier);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Probe whether the knowledge store's required packages are resolvable.
|
|
76
|
+
* Memoized after the first call; {@link clearAvailabilityCache} resets.
|
|
77
|
+
*/
|
|
78
|
+
export function probeKnowledgeStoreAvailability(resolver: PackageResolver = defaultResolver): KnowledgeStoreAvailability {
|
|
79
|
+
if (cachedIsValidFor(resolver)) return _cached!;
|
|
80
|
+
const missing: string[] = [];
|
|
81
|
+
for (const pkg of REQUIRED_PACKAGES) {
|
|
82
|
+
try {
|
|
83
|
+
resolver(pkg);
|
|
84
|
+
} catch {
|
|
85
|
+
// require.resolve throws ERR_MODULE_NOT_FOUND / ERR_PACKAGE_PATH_NOT_EXPORTED
|
|
86
|
+
// / MODULE_NOT_FOUND for every "not installable here" shape we care about.
|
|
87
|
+
missing.push(pkg);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const result: KnowledgeStoreAvailability = { available: missing.length === 0, missing };
|
|
91
|
+
if (resolver === defaultResolver) _cached = result;
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cachedIsValidFor(resolver: PackageResolver): boolean {
|
|
96
|
+
return _cached !== null && resolver === defaultResolver;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Reset the memoized probe result (test isolation). */
|
|
100
|
+
export function clearAvailabilityCache(): void {
|
|
101
|
+
_cached = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Human sentence for why the store is unavailable — empty when available.
|
|
106
|
+
* Used by the settings surfaces (describeKnowledgeStoreMode, the
|
|
107
|
+
* /research-config TUI, the healthcheck) so every surface words it the same.
|
|
108
|
+
*/
|
|
109
|
+
export function describeKnowledgeStoreUnavailability(availability: KnowledgeStoreAvailability): string {
|
|
110
|
+
if (availability.available) return '';
|
|
111
|
+
const parts = availability.missing.map((pkg) =>
|
|
112
|
+
pkg === '@huggingface/transformers'
|
|
113
|
+
? `'${pkg}' (optional embedding dependency — likely skipped at install time)`
|
|
114
|
+
: `'${pkg}' (broken install — native vector store missing)`,
|
|
115
|
+
);
|
|
116
|
+
return `knowledge store is OFF: required package${parts.length > 1 ? 's' : ''} not resolvable — ${parts.join('; ')}. ` +
|
|
117
|
+
"Repair with a full install of optional dependencies (e.g. 'npm install' including optional deps, or pi install npm:@lincoln504/pi-research without --omit=optional).";
|
|
118
|
+
}
|
package/src/knowledge/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Chunker } from './chunker.ts';
|
|
|
6
6
|
import { getConfig, validateConfig, getDbDir, type Config } from '../config.ts';
|
|
7
7
|
import { logger } from '../logger.ts';
|
|
8
8
|
import { getModelChunkConfig } from './model-config.ts';
|
|
9
|
+
import { probeKnowledgeStoreAvailability, describeKnowledgeStoreUnavailability, type KnowledgeStoreAvailability } from './availability.ts';
|
|
9
10
|
import type { MigrationStrategy } from './migration.ts';
|
|
10
11
|
import type {
|
|
11
12
|
IEmbedder,
|
|
@@ -49,12 +50,27 @@ export async function createKnowledgeStoreComponents(
|
|
|
49
50
|
reconnectFactory?: () => Promise<IEmbedder>,
|
|
50
51
|
withLock?: <T>(fn: () => Promise<T>) => Promise<T>,
|
|
51
52
|
config: Config = getConfig(),
|
|
52
|
-
workspace: string = process.cwd()
|
|
53
|
+
workspace: string = process.cwd(),
|
|
54
|
+
availability: KnowledgeStoreAvailability = probeKnowledgeStoreAvailability()
|
|
53
55
|
): Promise<KnowledgeStoreComponents | null> {
|
|
54
56
|
if (config.KNOWLEDGE_STORE_MODE === 'none') {
|
|
55
57
|
logger.debug('[knowledge] Knowledge store is disabled in configuration');
|
|
56
58
|
return null;
|
|
57
59
|
}
|
|
60
|
+
|
|
61
|
+
// Clean settings-level OFF when the store's packages are not even resolvable
|
|
62
|
+
// (optional transformers skipped at install, broken lancedb install). Before
|
|
63
|
+
// this pre-flight, a permanently missing package was discovered by RUNNING
|
|
64
|
+
// init — MAX_INIT_RETRIES (5) attempts of exponential backoff, ~31s, every
|
|
65
|
+
// fresh process — before the service degraded to DISABLED. The verdict here
|
|
66
|
+
// is the same OFF the mode='none' switch produces, just caused by the host
|
|
67
|
+
// instead of the user: return null (never throw), so every existing
|
|
68
|
+
// null-tolerant consumer treats it exactly like a disabled store.
|
|
69
|
+
if (!availability.available) {
|
|
70
|
+
logger.warn(`[knowledge] ${describeKnowledgeStoreUnavailability(availability)}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
validateConfig(config);
|
|
59
75
|
|
|
60
76
|
for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) {
|
|
@@ -162,7 +162,7 @@ export class QuickResearchOrchestrator {
|
|
|
162
162
|
`For roughly one in ${this.config.YOUTUBE_QUERY_EVERY_N} of your queries, append the word 'youtube' (e.g. "<topic> explained youtube") — DuckDuckGo rarely surfaces YouTube otherwise, and YouTube links let you read video transcripts.\n` +
|
|
163
163
|
'Your goal is to gather a focused, high-quality pool of initial links.\n\n' +
|
|
164
164
|
'## Scrape\n' +
|
|
165
|
-
`After searching, scrape the best sources using the \`scrape\` tool (up to ${maxScrapeBatchesDisplay} batches, up to
|
|
165
|
+
`After searching, scrape the best sources using the \`scrape\` tool (up to ${maxScrapeBatchesDisplay} batches, up to ${this.config.MAX_SCRAPE_URLS} URLs each).\n` +
|
|
166
166
|
'Prioritize primary sources and authoritative data.';
|
|
167
167
|
|
|
168
168
|
// Consume queued steering messages before starting
|
|
@@ -187,6 +187,10 @@ export class QuickResearchOrchestrator {
|
|
|
187
187
|
// turn within this session) reuse it via prompt-cache reads.
|
|
188
188
|
const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
|
|
189
189
|
.replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
|
|
190
|
+
// Config-driven, mirroring researcher-executor: the prompt's per-batch
|
|
191
|
+
// target can never drift from the cap the scrape tool enforces.
|
|
192
|
+
// replaceAll — the template states the budget more than once.
|
|
193
|
+
.replaceAll('{{max_scrape_urls}}', String(this.config.MAX_SCRAPE_URLS))
|
|
190
194
|
// Quick research has no research lead and no next round — see
|
|
191
195
|
// RESEARCHER_DIGEST_SECTION. Its report IS the deliverable.
|
|
192
196
|
.replace('{{digest_section}}', '')
|
|
@@ -203,6 +203,12 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
203
203
|
// of the retry loop, because nothing in it varies per attempt.
|
|
204
204
|
const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
|
|
205
205
|
.replace('{{extra_tool_guidelines}}', '')
|
|
206
|
+
// Config-driven, so the prompt's per-batch target can never drift from the
|
|
207
|
+
// cap the scrape tool actually enforces (getMaxScrapeUrls in scrape.ts).
|
|
208
|
+
// replaceAll: the template states the budget more than once (Step 2 target
|
|
209
|
+
// and Step 3's additional-URLs budget) and String.replace would only swap
|
|
210
|
+
// the first.
|
|
211
|
+
.replaceAll('{{max_scrape_urls}}', String(config.MAX_SCRAPE_URLS))
|
|
206
212
|
// Function replacer so `$`-bearing content is inserted literally, not as a
|
|
207
213
|
// String.replace substitution pattern.
|
|
208
214
|
.replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
|
|
@@ -29,10 +29,10 @@ Do not treat any source as inherently objective. Justify its inclusion through t
|
|
|
29
29
|
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect. Any knowledge-store results in your user message were retrieved automatically for your goal from the **Project Knowledge Store** and/or **User Knowledge Store** — cite them with that store name as their `Source:`.
|
|
30
30
|
|
|
31
31
|
### Step 2: Scrape Round 1
|
|
32
|
-
Identify the
|
|
32
|
+
Identify the most promising URLs and scrape them in one batch. The scrape tool accepts up to {{max_scrape_urls}} URLs per call — {{max_scrape_urls}} is your target per round, so plan each batch at the full budget rather than stopping early. Prioritize primary sources, authoritative references, and dense documentation; all else being equal, lean toward more recent / current sources and topics.
|
|
33
33
|
|
|
34
34
|
### Step 3: Scrape Round 2 (if needed)
|
|
35
|
-
If your first batch did not yield enough material, scrape up to
|
|
35
|
+
If your first batch did not yield enough material, scrape up to {{max_scrape_urls}} additional URLs from **your own source list** that you haven't read yet. Before choosing, review the **Session URL Pool** at the bottom of each scrape response — it shows what topics and domains your sibling researchers are already covering. Use this to steer your remaining scrapes from your own list:
|
|
36
36
|
- **Complement** your siblings: if they're covering area X, pick your own URLs that go deeper on X or fill adjacent gaps.
|
|
37
37
|
- **Diversify**: if you have sources on an angle no one else is touching, prioritize those to maximize session coverage.
|
|
38
38
|
- **Stay on mission**: regardless of what siblings are doing, make sure you have enough material for your assigned goal.
|
package/src/research-config.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type { Theme } from './types/research-panel-types.ts';
|
|
|
35
35
|
// Import the constant directly from its source module rather than the knowledge
|
|
36
36
|
// barrel (./knowledge/index.ts), which would transitively load lancedb.
|
|
37
37
|
import { SUPPORTED_MODELS } from './knowledge/model-config.ts';
|
|
38
|
+
import { probeKnowledgeStoreAvailability, describeKnowledgeStoreUnavailability } from './knowledge/availability.ts';
|
|
38
39
|
import { metrics } from './utils/metrics.ts';
|
|
39
40
|
import {
|
|
40
41
|
extractRunStats,
|
|
@@ -142,7 +143,19 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
142
143
|
{
|
|
143
144
|
id: 'KNOWLEDGE_STORE_MODE',
|
|
144
145
|
label: 'Knowledge Mode [project]',
|
|
145
|
-
description:
|
|
146
|
+
description: (() => {
|
|
147
|
+
const base = 'Knowledge store scope — none (disabled), project (scope knowledge per this directory), or global (scope knowledge globally, shared across all projects).\n[project] means configured independently per directory.';
|
|
148
|
+
// The configured mode is a lie when the host cannot run the store at all
|
|
149
|
+
// (optional embedding dep skipped at install, broken lancedb). Surface the
|
|
150
|
+
// package-level OFF right in the setting, so the menu never advertises a
|
|
151
|
+
// scope the store cannot honor and the repair path is named where the
|
|
152
|
+
// user is looking.
|
|
153
|
+
const availability = probeKnowledgeStoreAvailability();
|
|
154
|
+
if (!availability.available) {
|
|
155
|
+
return `${base}\n\n⚠ ${describeKnowledgeStoreUnavailability(availability)}\nThe store stays off — regardless of this setting — until that package is installed.`;
|
|
156
|
+
}
|
|
157
|
+
return base;
|
|
158
|
+
})(),
|
|
146
159
|
currentValue: config.KNOWLEDGE_STORE_MODE,
|
|
147
160
|
values: ['none', 'project', 'global'],
|
|
148
161
|
},
|
|
@@ -840,6 +840,9 @@ function missResult(reason: string): AgentToolResult<unknown> {
|
|
|
840
840
|
case 'store_disabled':
|
|
841
841
|
text = 'No results found (knowledge store is disabled in settings). Live research can get the info.';
|
|
842
842
|
break;
|
|
843
|
+
case 'store_packages_missing':
|
|
844
|
+
text = 'No results found (knowledge store is off — its required packages are not installed on this host, so it stays off regardless of settings). Live research can get the info.';
|
|
845
|
+
break;
|
|
843
846
|
case 'store_not_ready':
|
|
844
847
|
text = 'No results found (knowledge store is initializing). Live research can get the info.';
|
|
845
848
|
break;
|
|
@@ -951,10 +954,16 @@ export function createResearchKnowledgeSearchTool(iface?: ConfigInterface): Tool
|
|
|
951
954
|
// or null when the store is genuinely disabled/failed.
|
|
952
955
|
const store = await storeService.getStore();
|
|
953
956
|
if (!store) {
|
|
954
|
-
// null = disabled (
|
|
955
|
-
//
|
|
956
|
-
|
|
957
|
-
|
|
957
|
+
// null = disabled (a settings choice OR a host whose store packages are
|
|
958
|
+
// missing) or a genuine init failure — distinguish all three in the
|
|
959
|
+
// user-facing message. A 'native' disable must not read as "disabled in
|
|
960
|
+
// settings": the repair is installing the package, not a Knowledge Mode
|
|
961
|
+
// toggle that cannot help (same truth-telling as knowledge-config, the
|
|
962
|
+
// /research-config menu, and the healthcheck).
|
|
963
|
+
const disabledReason = storeService.getDisabledReason();
|
|
964
|
+
if (disabledReason === 'native') return missResult('store_packages_missing');
|
|
965
|
+
if (disabledReason === 'mode') return missResult('store_disabled');
|
|
966
|
+
return missResult('store_not_ready');
|
|
958
967
|
}
|
|
959
968
|
|
|
960
969
|
const count = await store.count();
|
package/src/tools/scrape.ts
CHANGED
|
@@ -17,9 +17,9 @@ import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
|
|
|
17
17
|
import type { SystemResearchState } from '../orchestration/deep-research-types.ts';
|
|
18
18
|
import { deduplicateUrls, normalizeUrl, getCachedScrapedContent, registerResearcherScrapes, buildSessionPoolFooter } from '../utils/shared-links.ts';
|
|
19
19
|
import {
|
|
20
|
-
MAX_SCRAPE_URLS,
|
|
21
20
|
BATCH_2_DEFAULT_CONCURRENCY,
|
|
22
21
|
getMaxScrapeBatches,
|
|
22
|
+
getMaxScrapeUrls,
|
|
23
23
|
DEFAULT_MODEL_CONTEXT_WINDOW,
|
|
24
24
|
MAX_SCRAPE_CONTENT_CHARS_PER_DOC,
|
|
25
25
|
} from '../constants.ts';
|
|
@@ -57,6 +57,10 @@ export function createScrapeTool(options: {
|
|
|
57
57
|
// enforced — a real, if harmless, missed-opportunity: the model would stop one
|
|
58
58
|
// batch short of what it was actually allowed to use.
|
|
59
59
|
const maxScrapeBatches = options.tracker?.getToolLimit('scrape') ?? getMaxScrapeBatches(config);
|
|
60
|
+
// One resolved value feeds the tool schema text, the batch-protocol text, the
|
|
61
|
+
// per-call slice, and the over-cap report — the enforced cap and every string
|
|
62
|
+
// the model sees are the same number by construction.
|
|
63
|
+
const maxScrapeUrls = getMaxScrapeUrls(config);
|
|
60
64
|
const container = tryGetServiceContainerFromCtx(options.ctx);
|
|
61
65
|
|
|
62
66
|
// Fallback global state when no orchestration context is provided
|
|
@@ -78,7 +82,7 @@ export function createScrapeTool(options: {
|
|
|
78
82
|
});
|
|
79
83
|
|
|
80
84
|
const ScrapeParamsSchema = Type.Object({
|
|
81
|
-
urls: Type.Array(Type.String({ description: `The URLs to scrape (only the first ${
|
|
85
|
+
urls: Type.Array(Type.String({ description: `The URLs to scrape (only the first ${maxScrapeUrls} are fetched per call; extras are reported under "Not Fetched" rather than silently dropped)` }), { minItems: 1, maxItems: 20 }),
|
|
82
86
|
maxConcurrency: Type.Optional(Type.Number({ default: config.MAX_CONCURRENT_SCRAPES, minimum: 1, maximum: 20 })),
|
|
83
87
|
});
|
|
84
88
|
|
|
@@ -92,7 +96,7 @@ export function createScrapeTool(options: {
|
|
|
92
96
|
batchProtocolText = `PROTOCOL: Batch 1 → Batch 2 → ... (up to ${effectiveLimit} batches)`;
|
|
93
97
|
} else {
|
|
94
98
|
const batchNumbers = Array.from({ length: effectiveLimit }, (_, i) => `Batch ${i + 1}`).join(' → ');
|
|
95
|
-
batchProtocolText = `PROTOCOL: ${batchNumbers} (up to ${
|
|
99
|
+
batchProtocolText = `PROTOCOL: ${batchNumbers} (up to ${maxScrapeUrls} URLs each).`;
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
return {
|
|
@@ -102,7 +106,7 @@ export function createScrapeTool(options: {
|
|
|
102
106
|
promptSnippet: `Read full content of web pages or PDFs (up to ${maxScrapeBatches} batches)`,
|
|
103
107
|
promptGuidelines: [
|
|
104
108
|
batchProtocolText,
|
|
105
|
-
`Up to ${
|
|
109
|
+
`Up to ${maxScrapeUrls} URLs per batch.`,
|
|
106
110
|
'Handshake is ELIMINATED. Start scraping immediately.',
|
|
107
111
|
'PDFs are auto-detected and extracted with high fidelity.',
|
|
108
112
|
],
|
|
@@ -175,8 +179,8 @@ export function createScrapeTool(options: {
|
|
|
175
179
|
// in results nor in Failed-to-Scrape, and the session-pool footer excludes the
|
|
176
180
|
// current batch).
|
|
177
181
|
const fetchCandidates = [...dedupedUrls, ...evictedDuplicates];
|
|
178
|
-
const finalUrls = fetchCandidates.slice(0,
|
|
179
|
-
const skippedUrls = fetchCandidates.slice(
|
|
182
|
+
const finalUrls = fetchCandidates.slice(0, maxScrapeUrls);
|
|
183
|
+
const skippedUrls = fetchCandidates.slice(maxScrapeUrls);
|
|
180
184
|
|
|
181
185
|
// Context gate: block if projected token usage would exceed threshold
|
|
182
186
|
if (options.getTokensUsed) {
|
|
@@ -364,7 +368,7 @@ export function createScrapeTool(options: {
|
|
|
364
368
|
|
|
365
369
|
if (skippedUrls.length > 0) {
|
|
366
370
|
markdown += `## Not Fetched — Over Batch Cap (${skippedUrls.length})\n\n`;
|
|
367
|
-
markdown += `Only the first ${
|
|
371
|
+
markdown += `Only the first ${maxScrapeUrls} URLs of a batch are fetched. The following URLs were NOT fetched — no content for them appears above. Request them in a later scrape batch if still needed:\n`;
|
|
368
372
|
for (const url of skippedUrls) {
|
|
369
373
|
markdown += `- ${url}\n`;
|
|
370
374
|
}
|