@lincoln504/pi-research 1.6.8 → 1.6.10
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 +4 -0
- 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 +29 -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 +45 -8
- package/src/tools/research-knowledge-search.ts +13 -4
- package/src/tools/scrape.ts +11 -7
- package/src/tui/tui-controller.ts +17 -0
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,
|
|
@@ -88,12 +89,34 @@ const ENV_KEY_BY_SETTING_ID: Record<string, string> = {
|
|
|
88
89
|
* Main command handler for /research-config
|
|
89
90
|
*/
|
|
90
91
|
export async function handleResearchConfigCommand(
|
|
91
|
-
|
|
92
|
+
args: string,
|
|
92
93
|
ctx: ExtensionContext,
|
|
93
94
|
pi: ExtensionAPI
|
|
94
95
|
): Promise<void> {
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
const verb = args.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
97
|
+
if (verb === 'health') {
|
|
98
|
+
await runHealthCheckAction(ctx, pi);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (verb === 'knowledge-status' || verb === 'knowledge_status') {
|
|
102
|
+
await showKnowledgeStatusAction(ctx, pi);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (ctx.mode !== 'tui' || !ctx.hasUI) {
|
|
106
|
+
// The interactive menu needs pi's real TUI. In RPC/web-hub/print/json/SDK hosts
|
|
107
|
+
// ctx.hasUI is true (RPC) and the menu cannot render — awaiting it silently
|
|
108
|
+
// nudges the host toward a hang and at best does nothing visible. Give a clear
|
|
109
|
+
// non-interactive answer with the headless diagnostics that DO work here.
|
|
110
|
+
pi.sendMessage({
|
|
111
|
+
customType: 'research-config',
|
|
112
|
+
content: 'The interactive `/research-config` menu requires pi\'s terminal UI (TUI).\n\n' +
|
|
113
|
+
'In this host, use the headless diagnostics instead:\n' +
|
|
114
|
+
'- `/research-config health` — system health status\n' +
|
|
115
|
+
'- `/research-config knowledge-status` — knowledge store status\n\n' +
|
|
116
|
+
'Configuration is read from the environment and config files; set `PI_RESEARCH_*` ' +
|
|
117
|
+
'environment variables to change it.',
|
|
118
|
+
display: true,
|
|
119
|
+
});
|
|
97
120
|
return;
|
|
98
121
|
}
|
|
99
122
|
await showInteractiveMenu(ctx, pi);
|
|
@@ -142,7 +165,19 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
142
165
|
{
|
|
143
166
|
id: 'KNOWLEDGE_STORE_MODE',
|
|
144
167
|
label: 'Knowledge Mode [project]',
|
|
145
|
-
description:
|
|
168
|
+
description: (() => {
|
|
169
|
+
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.';
|
|
170
|
+
// The configured mode is a lie when the host cannot run the store at all
|
|
171
|
+
// (optional embedding dep skipped at install, broken lancedb). Surface the
|
|
172
|
+
// package-level OFF right in the setting, so the menu never advertises a
|
|
173
|
+
// scope the store cannot honor and the repair path is named where the
|
|
174
|
+
// user is looking.
|
|
175
|
+
const availability = probeKnowledgeStoreAvailability();
|
|
176
|
+
if (!availability.available) {
|
|
177
|
+
return `${base}\n\n⚠ ${describeKnowledgeStoreUnavailability(availability)}\nThe store stays off — regardless of this setting — until that package is installed.`;
|
|
178
|
+
}
|
|
179
|
+
return base;
|
|
180
|
+
})(),
|
|
146
181
|
currentValue: config.KNOWLEDGE_STORE_MODE,
|
|
147
182
|
values: ['none', 'project', 'global'],
|
|
148
183
|
},
|
|
@@ -485,6 +520,7 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
485
520
|
modelChangeConfirmed = !cancelled && await ctx.ui.confirm(
|
|
486
521
|
'Change Embedding Model',
|
|
487
522
|
`Switch the embedding model to ${config.EMBEDDING_MODEL.split('/').pop()}? This permanently clears the current knowledge store.`,
|
|
523
|
+
{ timeout: 60_000 },
|
|
488
524
|
);
|
|
489
525
|
if (modelChangeConfirmed) {
|
|
490
526
|
logger.info(`[research-config] Embedding model changed from ${initialConfig.EMBEDDING_MODEL} to ${config.EMBEDDING_MODEL}. Clearing knowledge store.`);
|
|
@@ -537,7 +573,7 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
537
573
|
await showKnowledgeStatusAction(ctx, pi);
|
|
538
574
|
break;
|
|
539
575
|
case 'knowledge_clear_global': {
|
|
540
|
-
const confirmed = await ctx.ui.confirm('Clear User Store', 'Permanently delete all user-scoped store data?');
|
|
576
|
+
const confirmed = await ctx.ui.confirm('Clear User Store', 'Permanently delete all user-scoped store data?', { timeout: 60_000 });
|
|
541
577
|
if (confirmed) {
|
|
542
578
|
try {
|
|
543
579
|
const service = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
|
|
@@ -550,7 +586,7 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
550
586
|
break;
|
|
551
587
|
}
|
|
552
588
|
case 'knowledge_clear_local': {
|
|
553
|
-
const confirmed = await ctx.ui.confirm('Clear Project Store', 'Permanently delete all project-scoped store data?');
|
|
589
|
+
const confirmed = await ctx.ui.confirm('Clear Project Store', 'Permanently delete all project-scoped store data?', { timeout: 60_000 });
|
|
554
590
|
if (confirmed) {
|
|
555
591
|
try {
|
|
556
592
|
const service = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
|
|
@@ -564,13 +600,13 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
564
600
|
}
|
|
565
601
|
case 'metrics':
|
|
566
602
|
await showMetricsAction(ctx, pi);
|
|
567
|
-
if (await ctx.ui.confirm('Session Metrics', 'Reset all session counters?')) {
|
|
603
|
+
if (await ctx.ui.confirm('Session Metrics', 'Reset all session counters?', { timeout: 60_000 })) {
|
|
568
604
|
metrics.clearSession();
|
|
569
605
|
ctx.ui.notify('Session metrics reset.', 'info');
|
|
570
606
|
}
|
|
571
607
|
break;
|
|
572
608
|
case 'logs_clear': {
|
|
573
|
-
const confirmed = await ctx.ui.confirm('Clear Logs', 'Delete the main diagnostic log file and all archived rotation files?');
|
|
609
|
+
const confirmed = await ctx.ui.confirm('Clear Logs', 'Delete the main diagnostic log file and all archived rotation files?', { timeout: 60_000 });
|
|
574
610
|
if (confirmed) {
|
|
575
611
|
logger.clear();
|
|
576
612
|
ctx.ui.notify('Diagnostic logs cleared.', 'info');
|
|
@@ -691,6 +727,7 @@ async function uninstallSkillAction(ctx: ExtensionContext, pi: ExtensionAPI): Pr
|
|
|
691
727
|
const confirmed = await ctx.ui.confirm(
|
|
692
728
|
'Uninstall Skill',
|
|
693
729
|
`Remove the pi-research skill from ${names}? (Only symlinks this extension created are removed.)`,
|
|
730
|
+
{ timeout: 60_000 },
|
|
694
731
|
);
|
|
695
732
|
if (!confirmed) return;
|
|
696
733
|
try {
|
|
@@ -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();
|