@lincoln504/pi-research 0.1.12 → 1.0.0
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/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
package/src/config.ts
CHANGED
|
@@ -1,206 +1,1095 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Configuration Module
|
|
2
|
+
* Configuration Module
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Source of truth: .env file in the extension directory.
|
|
5
|
+
* The /research-config TUI is a friendly editor for that file.
|
|
6
|
+
* process.env values override the file (useful for CI / one-off overrides).
|
|
6
7
|
*/
|
|
7
8
|
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as os from 'node:os';
|
|
8
12
|
import { logger } from './logger.ts';
|
|
13
|
+
import { getConfigDirName } from './utils/host-config.ts';
|
|
14
|
+
import { Type, type Static } from 'typebox';
|
|
15
|
+
import { Value } from 'typebox/value';
|
|
16
|
+
import { normalizeWorkspacePath } from './utils/text-utils.ts';
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
19
|
+
* Validates configuration schema using TypeBox.
|
|
20
|
+
* This provides runtime validation and TypeScript types from the same source.
|
|
21
|
+
*/
|
|
22
|
+
export const ConfigSchema = Type.Object({
|
|
23
|
+
/** Per-researcher timeout in milliseconds (default: 300000, range: 3-30 min) */
|
|
24
|
+
RESEARCHER_TIMEOUT_MS: Type.Number({ minimum: 180000, maximum: 1800000, default: 300000 }),
|
|
25
|
+
/** Maximum number of concurrent researcher processes (default: 3, range: 1-5) */
|
|
26
|
+
MAX_CONCURRENT_RESEARCHERS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
27
|
+
/** Maximum number of retries for a failed researcher (default: 2, range: 0-5) */
|
|
28
|
+
RESEARCHER_MAX_RETRIES: Type.Number({ minimum: 0, maximum: 5, default: 2 }),
|
|
29
|
+
/** Base delay between retries in milliseconds (default: 2000, range: 100-10000) */
|
|
30
|
+
RESEARCHER_MAX_RETRY_DELAY_MS: Type.Number({ minimum: 100, maximum: 10000, default: 2000 }),
|
|
31
|
+
/** Target depth for recursive research (default: 1, range: 1-3) */
|
|
32
|
+
DEFAULT_RESEARCH_DEPTH: Type.Number({ minimum: 1, maximum: 3, default: 1 }),
|
|
33
|
+
/** Number of batches to allow for a single scrape tool call (default: 2, 0=unlimited) */
|
|
34
|
+
MAX_SCRAPE_BATCHES: Type.Number({ minimum: 0, maximum: 99, default: 2 }),
|
|
35
|
+
/** Max shared web-gathering calls per researcher (search + security_search + stackexchange + youtube_transcript). Default 12. */
|
|
36
|
+
MAX_GATHERING_CALLS: Type.Number({ minimum: 1, maximum: 100, default: 12 }),
|
|
37
|
+
/** Number of parallel browser pool workers (default: 4, range: 1-10) */
|
|
38
|
+
WORKER_THREADS: Type.Number({ minimum: 1, maximum: 10, default: 4 }),
|
|
39
|
+
/** Number of concurrent tasks per pool worker process (default: 2, range: 1-10) */
|
|
40
|
+
WORKER_CONCURRENCY: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
|
|
41
|
+
/** Knowledge store isolation mode (default: 'global' — one shared store across every directory) */
|
|
42
|
+
KNOWLEDGE_STORE_MODE: Type.Union([Type.Literal('none'), Type.Literal('project'), Type.Literal('global')], { default: 'global' }),
|
|
43
|
+
/** Embedding model to use for the knowledge store */
|
|
44
|
+
EMBEDDING_MODEL: Type.String({ default: 'onnx-community/granite-embedding-small-english-r2-ONNX' }),
|
|
45
|
+
/** Hardware backend for embeddings: 'auto' (probe WebGPU viability out-of-process, fall back to CPU), 'webgpu' (force), or 'cpu' (force).
|
|
46
|
+
* Default 'auto': many real targets (VMs, containers, CI, headless/software-Vulkan hosts) have no GPU that
|
|
47
|
+
* onnxruntime-node's bundled Dawn can run compute on — and that failure is a native SIGSEGV, not a catchable
|
|
48
|
+
* JS error. 'auto' detects this via a disposable child process (a crash there can't kill us) and uses CPU. */
|
|
49
|
+
EMBEDDING_DEVICE: Type.Union([Type.Literal('auto'), Type.Literal('webgpu'), Type.Literal('cpu')], { default: 'auto' }),
|
|
50
|
+
/** Timeout for scraping operations in milliseconds (default: 15000, range: 5-120 seconds) */
|
|
51
|
+
SCRAPE_TIMEOUT_MS: Type.Number({ minimum: 5000, maximum: 120000, default: 15000 }),
|
|
52
|
+
/** How long to keep documents in the knowledge store before eviction (default: 30 days) */
|
|
53
|
+
KNOWLEDGE_STORE_CACHE_TTL_DAYS: Type.Number({ minimum: 1, maximum: 365, default: 30 }),
|
|
54
|
+
/** Max age (days) a cached document may be served at read time; 0 (default) disables the
|
|
55
|
+
* read-time gate — eviction (KNOWLEDGE_STORE_CACHE_TTL_DAYS) still bounds the worst case.
|
|
56
|
+
* Set >0 for time-sensitive research to force a fresh scrape of anything older than this. */
|
|
57
|
+
KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS: Type.Number({ minimum: 0, maximum: 3650, default: 0 }),
|
|
58
|
+
/** Timeout for embedding model initialization (default: 300000ms) */
|
|
59
|
+
EMBEDDING_MODEL_INIT_TIMEOUT_MS: Type.Number({ minimum: 10000, maximum: 600000, default: 300000 }),
|
|
60
|
+
/** Max fraction of context window to use for initial scrape context (default: 0.15) */
|
|
61
|
+
MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: Type.Number({ minimum: 0.05, maximum: 1.0, default: 0.15 }),
|
|
62
|
+
/** Estimated tokens per scrape result for planning (default: 2500) */
|
|
63
|
+
AVG_TOKENS_PER_SCRAPE: Type.Number({ minimum: 500, maximum: 10000, default: 2500 }),
|
|
64
|
+
/** Maximum number of concurrent scrapes (default: 3) */
|
|
65
|
+
MAX_CONCURRENT_SCRAPES: Type.Number({ minimum: 1, maximum: 20, default: 3 }),
|
|
66
|
+
/** Max YouTube videos transcribed per youtube_transcript call (default: 3, range 1-5) */
|
|
67
|
+
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: Type.Number({ minimum: 1, maximum: 5, default: 3 }),
|
|
68
|
+
/** Per-video timeout for the youtube_transcript tool in ms (default: 20000, range 5-120s) */
|
|
69
|
+
YOUTUBE_TRANSCRIPT_TIMEOUT_MS: Type.Number({ minimum: 5000, maximum: 120000, default: 20000 }),
|
|
70
|
+
/** Preferred caption language (BCP-47 prefix) for transcripts (default: 'en') */
|
|
71
|
+
YOUTUBE_TRANSCRIPT_LANG: Type.String({ default: 'en' }),
|
|
72
|
+
/** Append 'youtube' to roughly one-in-N search queries for video discovery
|
|
73
|
+
* (default: 5; 1 = every query). Configured via env/config file; not surfaced
|
|
74
|
+
* in the config TUI. */
|
|
75
|
+
YOUTUBE_QUERY_EVERY_N: Type.Number({ minimum: 1, maximum: 100, default: 5 }),
|
|
76
|
+
/** Health check timeout in milliseconds (default: 10000ms) */
|
|
77
|
+
HEALTH_CHECK_TIMEOUT_MS: Type.Number({ minimum: 2000, maximum: 120000, default: 10000 }),
|
|
78
|
+
/** Default timeout for browser page operations like search (default: 45000ms) */
|
|
79
|
+
SEARCH_TIMEOUT_MS: Type.Number({ minimum: 5000, maximum: 120000, default: 45000 }),
|
|
80
|
+
/** TUI refresh debounce in milliseconds (default: 100ms) */
|
|
81
|
+
TUI_REFRESH_DEBOUNCE_MS: Type.Number({ minimum: 0, maximum: 1000, default: 100 }),
|
|
82
|
+
/** Queue-wait / overhead margin (ms) added on top of each browser operation's own nav
|
|
83
|
+
* timeout to form its scheduler task-timeout ceiling: a search task times out after
|
|
84
|
+
* SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this. Sized so a task is
|
|
85
|
+
* never killed before it has used its full nav budget plus time waiting in the queue.
|
|
86
|
+
* (default: 10000) */
|
|
87
|
+
BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2000, maximum: 120000, default: 10000 }),
|
|
88
|
+
/** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-600s).
|
|
89
|
+
* Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var. */
|
|
90
|
+
LLM_TIMEOUT_MS: Type.Number({ minimum: 60000, maximum: 600000, default: 300000 }),
|
|
91
|
+
/** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
|
|
92
|
+
* evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
|
|
93
|
+
* sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
|
|
94
|
+
* open-ended reasoning, so thinking only burns the output-token budget (often
|
|
95
|
+
* truncating before the JSON/report block) and adds latency for at most a marginal
|
|
96
|
+
* quality gain that does not justify the cost on these structured calls.
|
|
97
|
+
* Deliberately NOT exposed in the /research-config TUI — an advanced knob set only
|
|
98
|
+
* via the PI_RESEARCH_LLM_THINKING_LEVEL env var. 'off' maps to the provider's
|
|
99
|
+
* thinking-disabled request (e.g. z.ai thinking:{type:'disabled'}). */
|
|
100
|
+
LLM_THINKING_LEVEL: Type.Union([
|
|
101
|
+
Type.Literal('off'),
|
|
102
|
+
Type.Literal('minimal'),
|
|
103
|
+
Type.Literal('low'),
|
|
104
|
+
Type.Literal('medium'),
|
|
105
|
+
Type.Literal('high'),
|
|
106
|
+
], { default: 'off' }),
|
|
107
|
+
/** Max output tokens for the coordinator plan + mid-round evaluator decision (and,
|
|
108
|
+
* when the evaluator synthesizes mid-round, the report). Default 16384. The blanket
|
|
109
|
+
* 4096 cap previously throttled every call and could be exhausted by a thinking block
|
|
110
|
+
* before any text was emitted. Capped at the model's real ceiling at call time.
|
|
111
|
+
* Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
|
|
112
|
+
PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
|
|
113
|
+
/** Max output tokens for the final synthesized research report (the forced-synthesis
|
|
114
|
+
* evaluator call). Default 32768 so a full cited report is never truncated. Capped at
|
|
115
|
+
* the model's real maxTokens at call time.
|
|
116
|
+
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
117
|
+
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
118
|
+
/** LLM Model override for researcher sub-agents and knowledge synthesis.
|
|
119
|
+
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
120
|
+
* When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
|
|
121
|
+
* and the knowledge synthesis background LLM. The coordinator and evaluator always
|
|
122
|
+
* use the caller's model (ctx.model).
|
|
123
|
+
*/
|
|
124
|
+
RESEARCH_MODEL: Type.Optional(Type.String()),
|
|
125
|
+
/** Explicit directory for the knowledge store database (overrides default) */
|
|
126
|
+
KNOWLEDGE_STORE_DIR: Type.Optional(Type.String()),
|
|
127
|
+
/** Directory for transient browser profile data. Defaults to a disk-backed
|
|
128
|
+
* dir (~/.cache/pi-research/profiles) rather than the system temp dir, so a
|
|
129
|
+
* RAM-backed /tmp (tmpfs) is not consumed by per-worker browser profiles.
|
|
130
|
+
* Set to a path under the system temp dir to opt back into tmpfs/RAM. */
|
|
131
|
+
TMP_DIR: Type.Optional(Type.String()),
|
|
132
|
+
/** Whether to automatically export a markdown research report to disk at the end (default: false) */
|
|
133
|
+
RESEARCH_REPORT_EXPORT_ENABLED: Type.Boolean({ default: false }),
|
|
134
|
+
/**
|
|
135
|
+
* Explicit directory for exported reports. When set, reports are written here
|
|
136
|
+
* verbatim (created if needed), bypassing the cwd-relative "smart" resolution.
|
|
137
|
+
* Lets the skill pin a stable location instead of writing into
|
|
138
|
+
* whatever repo the host agent happens to be in. Empty = smart cwd resolution.
|
|
139
|
+
*/
|
|
140
|
+
RESEARCH_REPORT_EXPORT_DIR: Type.Optional(Type.String()),
|
|
141
|
+
/** Strategy for database schema/model migrations: 'drop', 're-embed', or 'backup' (default: 'backup') */
|
|
142
|
+
MIGRATION_STRATEGY: Type.Union([
|
|
143
|
+
Type.Literal('drop'),
|
|
144
|
+
Type.Literal('re-embed'),
|
|
145
|
+
Type.Literal('backup'),
|
|
146
|
+
], { default: 'backup' }),
|
|
147
|
+
/** Whether to mirror logs to the console (stdout/stderr). (default: false) */
|
|
148
|
+
CONSOLE_LOG: Type.Boolean({ default: false }),
|
|
149
|
+
/** Enable debug/verbose logging (writes INFO+DEBUG to log file). (default: false) */
|
|
150
|
+
DEBUG: Type.Boolean({ default: false }),
|
|
151
|
+
/**
|
|
152
|
+
* Comma-separated list of research tools to DISABLE for every researcher in a run
|
|
153
|
+
* (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
|
|
154
|
+
* search, scrape, security_search, stackexchange, youtube_transcript, grep.
|
|
155
|
+
* Disable-only: the list is merged into the per-run excludeTools stream, so disabled
|
|
156
|
+
* tools are both removed from each researcher's toolset AND named in the
|
|
157
|
+
* coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
|
|
158
|
+
* (PI_RESEARCH_DISABLED_TOOLS); intentionally NOT exposed in the config TUI.
|
|
159
|
+
* Empty/absent = all tools enabled.
|
|
160
|
+
*/
|
|
161
|
+
DISABLED_TOOLS: Type.Optional(Type.String()),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export type Config = Static<typeof ConfigSchema>;
|
|
165
|
+
|
|
166
|
+
/** Default configuration values extracted from schema */
|
|
167
|
+
export const DEFAULTS: Config = Value.Create(ConfigSchema);
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// Scope Definitions
|
|
171
|
+
// ============================================================================
|
|
45
172
|
|
|
46
173
|
/**
|
|
47
|
-
*
|
|
174
|
+
* Env var keys that are project-scoped (saved per-directory in the registry).
|
|
175
|
+
* All other keys are user-scoped (saved to ~/.pi/research/config.env).
|
|
176
|
+
*
|
|
177
|
+
* This is the SINGLE SOURCE OF TRUTH for scope assignments. The TUI scope
|
|
178
|
+
* assignments, migration key lists, and saveConfig filtering all derive from
|
|
179
|
+
* this set. If you change a key's scope in the TUI, update this set too.
|
|
48
180
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
181
|
+
const LOCAL_SCOPE_KEYS = new Set([
|
|
182
|
+
'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH',
|
|
183
|
+
'PI_RESEARCH_KNOWLEDGE_STORE_MODE',
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* True for a per-directory (project-scoped) env-var key. Exported so the CLI's config.env→env
|
|
188
|
+
* bridge can EXCLUDE these: promoting a per-directory key into process.env would make config.env
|
|
189
|
+
* out-rank the per-directory registry (process.env is top precedence), silently defeating a
|
|
190
|
+
* per-directory override. Kept as a file layer instead, the registry can override it per-cwd —
|
|
191
|
+
* matching how the pi extension / SDK resolve config.
|
|
192
|
+
*/
|
|
193
|
+
export function isProjectScopedKey(key: string): boolean {
|
|
194
|
+
return LOCAL_SCOPE_KEYS.has(key);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* User-scoped keys for one-time migration from registry → config.env.
|
|
199
|
+
* Derived from ALL schema keys minus LOCAL_SCOPE_KEYS, plus a few extra keys
|
|
200
|
+
* not in the schema but present in the env.
|
|
201
|
+
*/
|
|
202
|
+
const USER_MIGRATION_KEYS = [
|
|
203
|
+
// Core user-scoped from schema
|
|
204
|
+
'PI_RESEARCH_TIMEOUT_MS',
|
|
205
|
+
'PI_RESEARCH_MAX_RESEARCHERS',
|
|
206
|
+
'PI_RESEARCH_MAX_RETRIES',
|
|
207
|
+
'PI_RESEARCH_RETRY_DELAY_MS',
|
|
208
|
+
'PI_RESEARCH_MAX_SCRAPE_BATCHES',
|
|
209
|
+
'PI_RESEARCH_MAX_GATHERING_CALLS',
|
|
210
|
+
'PI_RESEARCH_WORKER_THREADS',
|
|
211
|
+
'PI_RESEARCH_WORKER_CONCURRENCY',
|
|
212
|
+
'PI_RESEARCH_EMBEDDING_MODEL',
|
|
213
|
+
'PI_RESEARCH_EMBEDDING_DEVICE',
|
|
214
|
+
'PI_RESEARCH_EMBEDDING_MODEL_INIT_TIMEOUT_MS',
|
|
215
|
+
'PI_RESEARCH_SCRAPE_TIMEOUT_MS',
|
|
216
|
+
'PI_RESEARCH_CACHE_TTL_DAYS',
|
|
217
|
+
'PI_RESEARCH_KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS',
|
|
218
|
+
'PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING',
|
|
219
|
+
'PI_RESEARCH_AVG_TOKENS_PER_SCRAPE',
|
|
220
|
+
'PI_RESEARCH_MAX_CONCURRENT_SCRAPES',
|
|
221
|
+
'PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS',
|
|
222
|
+
'PI_RESEARCH_SEARCH_TIMEOUT_MS',
|
|
223
|
+
'PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS',
|
|
224
|
+
'PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS',
|
|
225
|
+
'PI_RESEARCH_LLM_TIMEOUT_MS',
|
|
226
|
+
'PI_RESEARCH_LLM_THINKING_LEVEL',
|
|
227
|
+
'PI_RESEARCH_PLANNING_MAX_TOKENS',
|
|
228
|
+
'PI_RESEARCH_SYNTHESIS_MAX_TOKENS',
|
|
229
|
+
'PI_RESEARCH_MIGRATION_STRATEGY',
|
|
230
|
+
'PI_RESEARCH_CONSOLE_LOG',
|
|
231
|
+
'PI_RESEARCH_MODEL',
|
|
232
|
+
'PI_RESEARCH_KNOWLEDGE_DIR',
|
|
233
|
+
'PI_RESEARCH_TMP_DIR',
|
|
234
|
+
'PI_RESEARCH_REPORT_EXPORT_ENABLED',
|
|
235
|
+
'PI_RESEARCH_REPORT_EXPORT_DIR',
|
|
236
|
+
'PI_RESEARCH_DEBUG',
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// Centralized Project Settings Storage
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Returns the path to the centralized project settings file.
|
|
245
|
+
*/
|
|
246
|
+
export function getProjectSettingsRegistryPath(): string {
|
|
247
|
+
// State lives under pi-research's OWN namespace (~/.pi/research/state), beside
|
|
248
|
+
// config.env and knowledge_db — not in the host pi config dir root (~/.pi/state),
|
|
249
|
+
// which would pollute and risk colliding with the host's own state.
|
|
250
|
+
const stateDir = process.env['PI_RESEARCH_STATE_DIR']
|
|
251
|
+
?? path.join(getGlobalConfigDir(), 'state');
|
|
252
|
+
return path.join(stateDir, 'project-settings.json');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Load all project settings from the centralized registry.
|
|
257
|
+
*/
|
|
258
|
+
function loadProjectSettingsRegistry(): Record<string, Record<string, string>> {
|
|
259
|
+
const registryPath = getProjectSettingsRegistryPath();
|
|
260
|
+
try {
|
|
261
|
+
if (fs.existsSync(registryPath)) {
|
|
262
|
+
const content = fs.readFileSync(registryPath, 'utf-8');
|
|
263
|
+
return JSON.parse(content);
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
logger.warn('[config] Failed to read project settings registry:', err);
|
|
58
267
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Internal helper for synchronous sleep.
|
|
273
|
+
* Uses Atomics.wait (Node.js main thread) with a non-busy-wait fallback
|
|
274
|
+
* that blocks on a file descriptor (pipe) so the thread actually yields.
|
|
275
|
+
*/
|
|
276
|
+
function sleepSync(ms: number): void {
|
|
277
|
+
if (ms <= 0) return;
|
|
278
|
+
try {
|
|
279
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
280
|
+
} catch (_err) {
|
|
281
|
+
// SharedArrayBuffer is unavailable (extremely unlikely in Node.js, but possible
|
|
282
|
+
// in strict security contexts). Fall back to a spin-wait as a last resort.
|
|
283
|
+
// This code path is essentially dead in normal Node.js environments.
|
|
284
|
+
const end = Date.now() + ms;
|
|
285
|
+
while (Date.now() < end) { /* spin */ }
|
|
63
286
|
}
|
|
64
|
-
return parsed;
|
|
65
287
|
}
|
|
66
288
|
|
|
67
289
|
/**
|
|
68
|
-
*
|
|
290
|
+
* Atomically read-modify-write the centralized project settings registry under a file lock.
|
|
291
|
+
*
|
|
292
|
+
* The lock, the READ, and the WRITE all happen inside one critical section. A previous design
|
|
293
|
+
* read the registry OUTSIDE the lock (in saveConfig) and only locked the write, so two concurrent
|
|
294
|
+
* local writes — e.g. `knowledge-config set` invoked in parallel across several directories —
|
|
295
|
+
* could both read the same pre-change snapshot and clobber each other's entry (a lost update).
|
|
296
|
+
* `mutate` receives the latest on-disk registry and edits it in place.
|
|
69
297
|
*/
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
298
|
+
function updateProjectSettingsRegistry(mutate: (registry: Record<string, Record<string, string>>) => void): void {
|
|
299
|
+
const registryPath = getProjectSettingsRegistryPath();
|
|
300
|
+
const dir = path.dirname(registryPath);
|
|
301
|
+
if (!fs.existsSync(dir)) {
|
|
302
|
+
// 0o700: same owner-only posture as the state-manager/backup writers that share this dir.
|
|
303
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const lockPath = `${registryPath}.lock`;
|
|
307
|
+
let lockFd: number | null = null;
|
|
308
|
+
const maxRetries = 100;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
312
|
+
try {
|
|
313
|
+
// 0o600: owner-only, matching the directory's 0o700 posture (this lock sits
|
|
314
|
+
// in the same state dir as config.env / backups).
|
|
315
|
+
lockFd = fs.openSync(lockPath, 'wx', 0o600);
|
|
316
|
+
break;
|
|
317
|
+
} catch (err: any) {
|
|
318
|
+
if (err.code === 'EEXIST') {
|
|
319
|
+
// Stale lock check
|
|
320
|
+
try {
|
|
321
|
+
const stats = fs.statSync(lockPath);
|
|
322
|
+
if (Date.now() - stats.mtimeMs > 30000) {
|
|
323
|
+
fs.unlinkSync(lockPath);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
} catch { /* ignore */ }
|
|
327
|
+
|
|
328
|
+
sleepSync(50);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (lockFd === null) {
|
|
336
|
+
throw new Error(`Failed to acquire lock for project settings registry after ${maxRetries} retries. Aborting to prevent data corruption.`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Read the LATEST registry inside the lock so a concurrent writer's change is never lost.
|
|
340
|
+
// Start fresh ONLY when the file genuinely does not exist (first write / ENOENT). Any other
|
|
341
|
+
// read or parse failure — EPERM, EMFILE, corrupt JSON — must ABORT the update: proceeding
|
|
342
|
+
// with {} would persist an empty registry and wipe every workspace's saved settings over a
|
|
343
|
+
// transient error. Mirrors the write path's fail-loud posture (the outer catch rethrows).
|
|
344
|
+
let registry: Record<string, Record<string, string>> = {};
|
|
345
|
+
if (fs.existsSync(registryPath)) {
|
|
346
|
+
try {
|
|
347
|
+
registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
|
|
348
|
+
} catch (readErr) {
|
|
349
|
+
if ((readErr as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
350
|
+
// Deleted between the existsSync check and the read — genuinely fresh.
|
|
351
|
+
registry = {};
|
|
352
|
+
} else {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Failed to read project settings registry at ${registryPath} — aborting the update so a transient read error cannot wipe other workspaces' settings: ${readErr instanceof Error ? readErr.message : String(readErr)}`,
|
|
355
|
+
{ cause: readErr },
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
mutate(registry);
|
|
362
|
+
|
|
363
|
+
// Atomic write: a bare writeFileSync could be truncated by a crash/ENOSPC mid-write,
|
|
364
|
+
// leaving invalid JSON that loadProjectSettingsRegistry silently resets to {} — wiping
|
|
365
|
+
// every workspace's saved settings. Write a temp then rename (atomic on the same FS).
|
|
366
|
+
const tmpPath = `${registryPath}.tmp.${process.pid}.${Date.now()}`;
|
|
367
|
+
try {
|
|
368
|
+
// 0o600 at creation + a post-rename chmod, mirroring the user-scope config.env
|
|
369
|
+
// writer: the registry sits in the same owner-only state dir, and a chmod tightens
|
|
370
|
+
// any pre-existing 0644 registry left by an older release.
|
|
371
|
+
fs.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
372
|
+
fs.renameSync(tmpPath, registryPath);
|
|
373
|
+
try { fs.chmodSync(registryPath, 0o600); } catch { /* FS without chmod (Windows) — best effort */ }
|
|
374
|
+
} catch (writeErr) {
|
|
375
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
376
|
+
throw writeErr;
|
|
377
|
+
}
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// Rethrow so the caller (saveConfig → /research-config) surfaces the failure instead of
|
|
380
|
+
// reporting a save that silently did not persist. Mirrors the user-scope config.env writer.
|
|
381
|
+
logger.error('[config] Failed to save project settings registry:', err);
|
|
382
|
+
throw err;
|
|
383
|
+
} finally {
|
|
384
|
+
if (lockFd !== null) {
|
|
385
|
+
try { fs.closeSync(lockFd); } catch { /* ignore */ }
|
|
386
|
+
try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ============================================================================
|
|
392
|
+
// Env-file persistence
|
|
393
|
+
// ============================================================================
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Returns the global configuration directory (~/.pi/research).
|
|
397
|
+
*/
|
|
398
|
+
export function getGlobalConfigDir(): string {
|
|
399
|
+
return path.join(os.homedir(), getConfigDirName(), 'research');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Returns the global environment file path (~/.pi/research/config.env).
|
|
404
|
+
*/
|
|
405
|
+
export function getGlobalEnvFilePath(): string {
|
|
406
|
+
return path.join(getGlobalConfigDir(), 'config.env');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The interface (consumer) through which pi-research is being used.
|
|
411
|
+
*
|
|
412
|
+
* Each interface can have an optional per-interface config overlay file at
|
|
413
|
+
* ~/.pi/research/{interface}.env that layers over the base config.env.
|
|
414
|
+
* Keys in the overlay win over the base file but lose to process.env and
|
|
415
|
+
* the centralized project registry.
|
|
416
|
+
*
|
|
417
|
+
* - 'pi' → src/index.ts (the pi extension: /research, /research-config, /knowledge-store)
|
|
418
|
+
* - 'cli' → src/cli.ts (the standalone `pi-research` CLI / agent skill —
|
|
419
|
+
* the surface every skills-aware host, including OpenClaw, runs)
|
|
420
|
+
*
|
|
421
|
+
* Each front-end reads ONLY its own overlay, so the standalone CLI / agent skill
|
|
422
|
+
* (cli.env) can be configured independently of the pi extension (pi.env).
|
|
423
|
+
*
|
|
424
|
+
* NOTE: the programmatic SDK (src/sdk.ts) is deliberately NOT an interface here —
|
|
425
|
+
* it is a library configured from code via ResearchSDKOptions, not from a global
|
|
426
|
+
* overlay file. See `ignoreGlobalConfig` in the SDK options for hermetic usage.
|
|
427
|
+
*/
|
|
428
|
+
export type ConfigInterface = 'pi' | 'cli';
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Returns the per-interface overlay file path (~/.pi/research/{iface}.env).
|
|
432
|
+
* The file is optional — only read if it exists.
|
|
433
|
+
*/
|
|
434
|
+
export function getInterfaceEnvFilePath(iface: ConfigInterface): string {
|
|
435
|
+
return path.join(getGlobalConfigDir(), `${iface}.env`);
|
|
114
436
|
}
|
|
115
437
|
|
|
116
438
|
/**
|
|
117
|
-
*
|
|
439
|
+
* Returns the local environment file path for a given directory.
|
|
440
|
+
* @deprecated Legacy .pi-research.env files are auto-migrated to the centralized registry.
|
|
441
|
+
* @internal
|
|
118
442
|
*/
|
|
119
|
-
|
|
443
|
+
function getLocalEnvFilePath(cwd: string = process.cwd()): string {
|
|
444
|
+
return path.resolve(cwd, '.pi-research.env');
|
|
445
|
+
}
|
|
120
446
|
|
|
121
447
|
/**
|
|
122
|
-
*
|
|
123
|
-
* Creates from environment if not yet initialized
|
|
448
|
+
* Returns the active database directory.
|
|
124
449
|
*/
|
|
125
|
-
export function
|
|
126
|
-
|
|
127
|
-
|
|
450
|
+
export function getDbDir(config?: Config, cwd: string = process.cwd()): string {
|
|
451
|
+
const cfg = config || getConfig(cwd);
|
|
452
|
+
if (cfg.KNOWLEDGE_STORE_DIR) {
|
|
453
|
+
return path.isAbsolute(cfg.KNOWLEDGE_STORE_DIR)
|
|
454
|
+
? cfg.KNOWLEDGE_STORE_DIR
|
|
455
|
+
: path.resolve(cwd, cfg.KNOWLEDGE_STORE_DIR);
|
|
128
456
|
}
|
|
129
|
-
|
|
457
|
+
// Default unified database in the global config directory
|
|
458
|
+
return path.join(getGlobalConfigDir(), 'knowledge_db');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export interface KnowledgeStoreModeInfo {
|
|
462
|
+
mode: 'none' | 'project' | 'global';
|
|
463
|
+
/** Human-readable source of the EFFECTIVE value (highest-precedence source that set it). */
|
|
464
|
+
origin: string;
|
|
465
|
+
/** Physical LanceDB directory backing the store (shared across project/global modes). */
|
|
466
|
+
dbDir: string;
|
|
130
467
|
}
|
|
131
468
|
|
|
132
469
|
/**
|
|
133
|
-
*
|
|
470
|
+
* Resolve the effective KNOWLEDGE_STORE_MODE for a directory AND explain WHERE the value came
|
|
471
|
+
* from. Backs the `knowledge-config` CLI and any agent/user asking "why is the store X here".
|
|
472
|
+
* The origin walks the same precedence createConfig applies, highest first:
|
|
473
|
+
* env var > per-directory project registry > {iface}.env overlay > config.env > default.
|
|
474
|
+
* (Legacy .pi-research.env local keys are auto-migrated into the registry on load, so a value
|
|
475
|
+
* there surfaces as the registry origin.)
|
|
134
476
|
*/
|
|
135
|
-
export function
|
|
136
|
-
|
|
477
|
+
export function describeKnowledgeStoreMode(
|
|
478
|
+
cwd: string = process.cwd(),
|
|
479
|
+
iface?: ConfigInterface,
|
|
480
|
+
): KnowledgeStoreModeInfo {
|
|
481
|
+
const cfg = getConfig(cwd, iface);
|
|
482
|
+
const KEY = 'PI_RESEARCH_KNOWLEDGE_STORE_MODE';
|
|
483
|
+
const fileHasKey = (p: string): boolean => {
|
|
484
|
+
try {
|
|
485
|
+
return fs.existsSync(p) && parseDotEnv(fs.readFileSync(p, 'utf-8'))[KEY] !== undefined;
|
|
486
|
+
} catch {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
let origin = 'built-in default';
|
|
492
|
+
if (process.env[KEY] !== undefined) {
|
|
493
|
+
origin = 'environment variable';
|
|
494
|
+
} else {
|
|
495
|
+
let registryHasKey = false;
|
|
496
|
+
try {
|
|
497
|
+
const registry = loadProjectSettingsRegistry();
|
|
498
|
+
registryHasKey = registry[normalizeWorkspacePath(cwd)]?.[KEY] !== undefined;
|
|
499
|
+
} catch { /* unreadable registry → not the origin */ }
|
|
500
|
+
if (registryHasKey) origin = 'this directory (project settings)';
|
|
501
|
+
else if (iface && fileHasKey(getInterfaceEnvFilePath(iface))) origin = `${iface}.env overlay`;
|
|
502
|
+
else if (fileHasKey(getGlobalEnvFilePath())) origin = 'config.env (machine-wide default)';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { mode: cfg.KNOWLEDGE_STORE_MODE, origin, dbDir: getDbDir(cfg, cwd) };
|
|
137
506
|
}
|
|
138
507
|
|
|
139
508
|
/**
|
|
140
|
-
*
|
|
509
|
+
* Parse a minimal KEY=VALUE dotenv file. The single canonical parser — the CLI's
|
|
510
|
+
* env bridge (bridgeConfigEnv) imports this too, so both paths agree on edge cases
|
|
511
|
+
* like surrounding-quote stripping. Previously the CLI kept a divergent copy that
|
|
512
|
+
* stripped quotes while this one did not; a quoted value on a project-scoped key
|
|
513
|
+
* (which only this parser ever sees, since the CLI bridge skips those) then retained
|
|
514
|
+
* its literal quote characters.
|
|
141
515
|
*/
|
|
142
|
-
export function
|
|
143
|
-
|
|
516
|
+
export function parseDotEnv(content: string): Record<string, string> {
|
|
517
|
+
const out: Record<string, string> = {};
|
|
518
|
+
for (const raw of content.split('\n')) {
|
|
519
|
+
const line = raw.trim();
|
|
520
|
+
if (!line || line.startsWith('#')) continue;
|
|
521
|
+
const eq = line.indexOf('=');
|
|
522
|
+
if (eq < 1) continue;
|
|
523
|
+
const key = line.slice(0, eq).trim();
|
|
524
|
+
// Trim BEFORE the quote-strip so `KEY = value` yields "value", not " value" — an
|
|
525
|
+
// untrimmed leading space silently broke booleans (parseEnvBool(' true') → false)
|
|
526
|
+
// and defeated quote detection for `KEY = "value"`. Intentional whitespace can
|
|
527
|
+
// still be preserved by quoting it: KEY=" value " → " value ".
|
|
528
|
+
let val = line.slice(eq + 1).replace(/\r$/, '').trim();
|
|
529
|
+
// Strip a matched pair of surrounding quotes so KEY="value" yields value.
|
|
530
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
531
|
+
val = val.slice(1, -1);
|
|
532
|
+
}
|
|
533
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
534
|
+
if (key) out[key] = val;
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
144
537
|
}
|
|
145
538
|
|
|
146
539
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* @throws {Error} if configuration is invalid
|
|
540
|
+
* Load environment variables from global and local files.
|
|
541
|
+
* Order: Global File < Interface overlay < Legacy .env < Centralized Registry (WINS)
|
|
150
542
|
*/
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
543
|
+
function loadEnvFiles(cwd: string, iface?: ConfigInterface): Record<string, string> {
|
|
544
|
+
const merged: Record<string, string> = {};
|
|
545
|
+
const globalPath = getGlobalEnvFilePath();
|
|
546
|
+
const registry = loadProjectSettingsRegistry();
|
|
547
|
+
const normalizedCwd = normalizeWorkspacePath(cwd);
|
|
548
|
+
const homeRegistry = registry[normalizeWorkspacePath(os.homedir())];
|
|
549
|
+
|
|
550
|
+
// 1. ONE-TIME MIGRATION: Populate config.env from HOME registry if missing
|
|
551
|
+
try {
|
|
552
|
+
if (!fs.existsSync(globalPath) && homeRegistry && Object.keys(homeRegistry).length > 0) {
|
|
553
|
+
logger.info('[config] config.env missing. Initializing from user settings in central registry...');
|
|
554
|
+
const migrated: Record<string, string> = {};
|
|
555
|
+
for (const key of USER_MIGRATION_KEYS) {
|
|
556
|
+
if (homeRegistry[key] !== undefined) migrated[key] = homeRegistry[key];
|
|
557
|
+
}
|
|
558
|
+
if (Object.keys(migrated).length > 0) {
|
|
559
|
+
// We use a dummy config to trigger saveConfig('user'). Pass the migrated key names as
|
|
560
|
+
// changedKeys so ONLY the settings the user had actually saved are written — createConfig
|
|
561
|
+
// fills every other key with its current default, and snapshotting those into config.env
|
|
562
|
+
// would freeze today's defaults forever (they'd shadow future default changes).
|
|
563
|
+
const dummyConfig = createConfig(migrated, {});
|
|
564
|
+
saveConfig(dummyConfig, 'user', cwd, Object.keys(migrated));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
logger.warn('[config] Failed one-time config.env migration:', err);
|
|
156
569
|
}
|
|
157
570
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
571
|
+
// 2. Load Global user config
|
|
572
|
+
try {
|
|
573
|
+
if (fs.existsSync(globalPath)) {
|
|
574
|
+
Object.assign(merged, parseDotEnv(fs.readFileSync(globalPath, 'utf-8')));
|
|
575
|
+
}
|
|
576
|
+
} catch (err) {
|
|
577
|
+
logger.warn('[config] Failed to read global env file:', err);
|
|
162
578
|
}
|
|
163
579
|
|
|
164
|
-
//
|
|
165
|
-
if (
|
|
166
|
-
|
|
580
|
+
// 2b. Per-interface overlay (~/.pi/research/{iface}.env) — optional, wins over base.
|
|
581
|
+
if (iface) {
|
|
582
|
+
const ifacePath = getInterfaceEnvFilePath(iface);
|
|
583
|
+
try {
|
|
584
|
+
if (fs.existsSync(ifacePath)) {
|
|
585
|
+
Object.assign(merged, parseDotEnv(fs.readFileSync(ifacePath, 'utf-8')));
|
|
586
|
+
}
|
|
587
|
+
} catch (err) {
|
|
588
|
+
logger.warn(`[config] Failed to read interface config ${ifacePath}:`, err);
|
|
589
|
+
}
|
|
167
590
|
}
|
|
168
591
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
592
|
+
// 3. Load Legacy .pi-research.env in CWD
|
|
593
|
+
const legacyPath = getLocalEnvFilePath(cwd);
|
|
594
|
+
let legacyEnv: Record<string, string> = {};
|
|
595
|
+
if (fs.existsSync(legacyPath)) {
|
|
596
|
+
try {
|
|
597
|
+
logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
|
|
598
|
+
legacyEnv = parseDotEnv(fs.readFileSync(legacyPath, 'utf-8'));
|
|
599
|
+
Object.assign(merged, legacyEnv);
|
|
600
|
+
|
|
601
|
+
// Auto-migrate legacy settings to the central registry — but only LOCAL_SCOPE_KEYS. The
|
|
602
|
+
// registry is project-scoped storage by design (saveConfig writes only these keys to it);
|
|
603
|
+
// persisting user-scoped legacy keys would freeze them per-workspace and silently override
|
|
604
|
+
// the user's global config.env forever. Non-local legacy keys still apply to THIS resolution
|
|
605
|
+
// via the Object.assign(merged, legacyEnv) above — they just aren't persisted.
|
|
606
|
+
const legacyLocal = Object.fromEntries(
|
|
607
|
+
Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
608
|
+
);
|
|
609
|
+
const existingLocal = Object.fromEntries(
|
|
610
|
+
Object.entries(registry[normalizedCwd] ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
611
|
+
);
|
|
612
|
+
if (Object.keys(legacyLocal).length > 0 && JSON.stringify(existingLocal) !== JSON.stringify(legacyLocal)) {
|
|
613
|
+
logger.info(`[config] Migrating legacy .pi-research.env settings from ${cwd} to central registry...`);
|
|
614
|
+
// Persist under the registry lock, reading the latest on-disk state so a concurrent
|
|
615
|
+
// write isn't lost; also reflect the merge in the in-memory `registry` used just below.
|
|
616
|
+
registry[normalizedCwd] = { ...registry[normalizedCwd], ...legacyLocal };
|
|
617
|
+
updateProjectSettingsRegistry((disk) => {
|
|
618
|
+
disk[normalizedCwd] = { ...disk[normalizedCwd], ...legacyLocal };
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
} catch (err) {
|
|
622
|
+
logger.warn(`[config] Failed to load legacy settings for ${cwd}:`, err);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 4. Apply centralized project settings. The registry is project-scoped storage: ONLY
|
|
627
|
+
// LOCAL_SCOPE_KEYS are applied (and may override), never user-scoped keys — those belong to
|
|
628
|
+
// config.env. Any non-local key present here is stale pollution (e.g. a pre-fix legacy
|
|
629
|
+
// migration) and is ignored rather than allowed to override the user's global settings.
|
|
630
|
+
if (registry[normalizedCwd]) {
|
|
631
|
+
for (const [key, val] of Object.entries(registry[normalizedCwd])) {
|
|
632
|
+
if (LOCAL_SCOPE_KEYS.has(key)) {
|
|
633
|
+
// Don't interpolate values in warnings — a config key could carry a secret and
|
|
634
|
+
// redactSecrets won't catch short/non-standard tokens. Key + winner is enough.
|
|
635
|
+
if (legacyEnv[key] !== undefined && legacyEnv[key] !== val) {
|
|
636
|
+
logger.warn(`[config] Config divergence for ${key} in ${cwd}: registry value differs from legacy .pi-research.env. Registry wins.`);
|
|
637
|
+
}
|
|
638
|
+
merged[key] = val;
|
|
639
|
+
} else if (merged[key] !== undefined && merged[key] !== val) {
|
|
640
|
+
logger.warn(`[config] Ignoring stale user-scoped ${key} in the project registry (config.env wins). Re-save your user settings to drop it.`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} else if (Object.keys(merged).length === 0 && !fs.existsSync(legacyPath) && !fs.existsSync(globalPath)) {
|
|
644
|
+
// 5. Warning for missing config
|
|
645
|
+
logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
warnLegacyEnvVarsOnce(merged);
|
|
649
|
+
|
|
650
|
+
return merged;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Env vars from the 0.1.x generation (Docker/SearXNG/Chromium era) that 1.0.0 no longer
|
|
654
|
+
// reads. Without this, an upgrader's old setting silently stops applying — worst case
|
|
655
|
+
// PROXY_URL, where the user believes searches are proxied but they now connect directly.
|
|
656
|
+
const LEGACY_ENV_VARS: Record<string, string> = {
|
|
657
|
+
PI_RESEARCH_MAX_CONCURRENT_RESEARCHERS: 'renamed to PI_RESEARCH_MAX_RESEARCHERS',
|
|
658
|
+
PI_RESEARCH_RESEARCHER_TIMEOUT_MS: 'renamed to PI_RESEARCH_TIMEOUT_MS',
|
|
659
|
+
PI_RESEARCH_VERBOSE: 'replaced by PI_RESEARCH_DEBUG=true',
|
|
660
|
+
PI_RESEARCH_SEARCH_LANGUAGE: 'removed with the SearXNG backend in v1.0.0',
|
|
661
|
+
PI_RESEARCH_CONSOLE_RESTORE_DELAY_MS: 'removed in v1.0.0',
|
|
662
|
+
SEARXNG_URL: 'removed with the SearXNG backend in v1.0.0',
|
|
663
|
+
BRAVE_SEARCH_API_KEY: 'removed with the SearXNG backend in v1.0.0',
|
|
664
|
+
PROXY_URL:
|
|
665
|
+
'no longer read — the SearXNG proxy path was removed in v1.0.0 and searches/scrapes connect directly',
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
let warnedLegacyEnvVars = false;
|
|
669
|
+
|
|
670
|
+
/** Warn once per process about set-but-dead 0.1.x env vars (upgrade aid; never throws). */
|
|
671
|
+
function warnLegacyEnvVarsOnce(merged: Record<string, string>): void {
|
|
672
|
+
if (warnedLegacyEnvVars) return;
|
|
673
|
+
warnedLegacyEnvVars = true;
|
|
674
|
+
for (const [name, note] of Object.entries(LEGACY_ENV_VARS)) {
|
|
675
|
+
if (process.env[name] !== undefined || merged[name] !== undefined) {
|
|
676
|
+
logger.warn(`[config] ${name} is a pi-research <=0.1.13 setting and has no effect in v1.0.0: ${note}.`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Write config back to env file.
|
|
683
|
+
*/
|
|
684
|
+
export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cwd: string = process.cwd(), changedKeys?: string[]): void {
|
|
685
|
+
// ALL keys — canonical env var name → value
|
|
686
|
+
// Note: PI_RESEARCH_CACHE_TTL_DAYS is the canonical name (matches .env.example).
|
|
687
|
+
// PI_RESEARCH_KNOWLEDGE_STORE_CACHE_TTL_DAYS is accepted at read-time for backward compat.
|
|
688
|
+
const allValues: Record<string, string> = {
|
|
689
|
+
PI_RESEARCH_TIMEOUT_MS: String(config.RESEARCHER_TIMEOUT_MS),
|
|
690
|
+
PI_RESEARCH_MAX_RESEARCHERS: String(config.MAX_CONCURRENT_RESEARCHERS),
|
|
691
|
+
PI_RESEARCH_MAX_RETRIES: String(config.RESEARCHER_MAX_RETRIES),
|
|
692
|
+
PI_RESEARCH_RETRY_DELAY_MS: String(config.RESEARCHER_MAX_RETRY_DELAY_MS),
|
|
693
|
+
PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS: String(config.HEALTH_CHECK_TIMEOUT_MS ?? DEFAULTS.HEALTH_CHECK_TIMEOUT_MS),
|
|
694
|
+
PI_RESEARCH_SEARCH_TIMEOUT_MS: String(config.SEARCH_TIMEOUT_MS),
|
|
695
|
+
PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS: String(config.TUI_REFRESH_DEBOUNCE_MS),
|
|
696
|
+
PI_RESEARCH_DEFAULT_RESEARCH_DEPTH: String(config.DEFAULT_RESEARCH_DEPTH),
|
|
697
|
+
PI_RESEARCH_MAX_SCRAPE_BATCHES: String(config.MAX_SCRAPE_BATCHES),
|
|
698
|
+
PI_RESEARCH_MAX_GATHERING_CALLS: String(config.MAX_GATHERING_CALLS),
|
|
699
|
+
PI_RESEARCH_WORKER_THREADS: String(config.WORKER_THREADS),
|
|
700
|
+
PI_RESEARCH_WORKER_CONCURRENCY: String(config.WORKER_CONCURRENCY),
|
|
701
|
+
PI_RESEARCH_KNOWLEDGE_STORE_MODE: config.KNOWLEDGE_STORE_MODE,
|
|
702
|
+
PI_RESEARCH_EMBEDDING_MODEL: config.EMBEDDING_MODEL,
|
|
703
|
+
PI_RESEARCH_EMBEDDING_DEVICE: config.EMBEDDING_DEVICE,
|
|
704
|
+
PI_RESEARCH_SCRAPE_TIMEOUT_MS: String(config.SCRAPE_TIMEOUT_MS),
|
|
705
|
+
PI_RESEARCH_CACHE_TTL_DAYS: String(config.KNOWLEDGE_STORE_CACHE_TTL_DAYS),
|
|
706
|
+
PI_RESEARCH_KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS: String(config.KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS),
|
|
707
|
+
PI_RESEARCH_EMBEDDING_MODEL_INIT_TIMEOUT_MS: String(config.EMBEDDING_MODEL_INIT_TIMEOUT_MS),
|
|
708
|
+
PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING: String(config.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING),
|
|
709
|
+
PI_RESEARCH_AVG_TOKENS_PER_SCRAPE: String(config.AVG_TOKENS_PER_SCRAPE),
|
|
710
|
+
PI_RESEARCH_MAX_CONCURRENT_SCRAPES: String(config.MAX_CONCURRENT_SCRAPES),
|
|
711
|
+
PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS: String(config.YOUTUBE_TRANSCRIPT_MAX_VIDEOS),
|
|
712
|
+
PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS: String(config.YOUTUBE_TRANSCRIPT_TIMEOUT_MS),
|
|
713
|
+
PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG: config.YOUTUBE_TRANSCRIPT_LANG,
|
|
714
|
+
PI_RESEARCH_YOUTUBE_QUERY_EVERY_N: String(config.YOUTUBE_QUERY_EVERY_N),
|
|
715
|
+
PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS: String(config.BROWSER_TASK_TIMEOUT_MS),
|
|
716
|
+
PI_RESEARCH_LLM_TIMEOUT_MS: String(config.LLM_TIMEOUT_MS),
|
|
717
|
+
PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
|
|
718
|
+
PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
|
|
719
|
+
PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
|
|
720
|
+
PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
|
|
721
|
+
PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
|
|
722
|
+
...(config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {}),
|
|
723
|
+
...(config.KNOWLEDGE_STORE_DIR ? { PI_RESEARCH_KNOWLEDGE_DIR: config.KNOWLEDGE_STORE_DIR } : {}),
|
|
724
|
+
...(config.TMP_DIR ? { PI_RESEARCH_TMP_DIR: config.TMP_DIR } : {}),
|
|
725
|
+
PI_RESEARCH_REPORT_EXPORT_ENABLED: String(config.RESEARCH_REPORT_EXPORT_ENABLED),
|
|
726
|
+
...(config.RESEARCH_REPORT_EXPORT_DIR ? { PI_RESEARCH_REPORT_EXPORT_DIR: config.RESEARCH_REPORT_EXPORT_DIR } : {}),
|
|
727
|
+
PI_RESEARCH_DEBUG: String(config.DEBUG),
|
|
728
|
+
...(config.DISABLED_TOOLS ? { PI_RESEARCH_DISABLED_TOOLS: config.DISABLED_TOOLS } : {}),
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// Scope-filter: only write the keys that belong to the target scope.
|
|
732
|
+
// This prevents cross-contamination (project-scoped keys leaking into config.env
|
|
733
|
+
// and user-scoped keys leaking into the project registry).
|
|
734
|
+
const scopedValues = scope === 'local'
|
|
735
|
+
? Object.fromEntries(Object.entries(allValues).filter(([k]) => LOCAL_SCOPE_KEYS.has(k)))
|
|
736
|
+
: Object.fromEntries(Object.entries(allValues).filter(([k]) => !LOCAL_SCOPE_KEYS.has(k)));
|
|
737
|
+
|
|
738
|
+
// changedKeys narrows the write to just the setting(s) the caller actually changed —
|
|
739
|
+
// for BOTH scopes. Without it a user-scope save snapshots the ENTIRE resolved config
|
|
740
|
+
// (env overrides and current defaults included) into config.env, freezing values the
|
|
741
|
+
// user never chose; a local-scope save would freeze the sibling local key per-directory.
|
|
742
|
+
// The comment-preserving merge below leaves every other existing line untouched.
|
|
743
|
+
const newValues = changedKeys
|
|
744
|
+
? Object.fromEntries(Object.entries(scopedValues).filter(([k]) => changedKeys.includes(k)))
|
|
745
|
+
: scopedValues;
|
|
746
|
+
|
|
747
|
+
if (scope === 'local') {
|
|
748
|
+
// CENTRALIZED PROJECT STORAGE
|
|
749
|
+
const normalizedCwd = normalizeWorkspacePath(cwd);
|
|
750
|
+
// The per-directory registry must hold ONLY genuine per-directory overrides. Persist just the
|
|
751
|
+
// key(s) the caller actually changed, merged onto any existing entry — never the full local-key
|
|
752
|
+
// set. Writing every LOCAL_SCOPE_KEY here (all resolved to their current values) would freeze
|
|
753
|
+
// the OTHER local key for this directory (e.g. changing KNOWLEDGE_STORE_MODE would pin
|
|
754
|
+
// DEFAULT_RESEARCH_DEPTH at whatever it resolved to now), silently decoupling it from later
|
|
755
|
+
// machine-wide default changes. Callers pass `changedKeys` to scope the write.
|
|
756
|
+
// Read-merge-write under one lock so a concurrent local write to another directory
|
|
757
|
+
// isn't lost (the merge onto the existing entry happens against the latest on-disk state).
|
|
758
|
+
updateProjectSettingsRegistry((registry) => {
|
|
759
|
+
registry[normalizedCwd] = { ...(registry[normalizedCwd] ?? {}), ...newValues };
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
logger.debug(`[config] Saved project settings for ${normalizedCwd} to central registry.`);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// USER STORAGE
|
|
767
|
+
const p = getGlobalEnvFilePath();
|
|
768
|
+
const lockPath = `${p}.lock`;
|
|
769
|
+
const dir = path.dirname(p);
|
|
770
|
+
|
|
771
|
+
if (!fs.existsSync(dir)) {
|
|
772
|
+
// 0o700: ~/.pi/research holds config.env, which is the documented home for API keys.
|
|
773
|
+
// Same owner-only posture as the state/lock/log writers.
|
|
774
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Simple synchronous file lock
|
|
778
|
+
let lockFd: number | null = null;
|
|
779
|
+
const lockRetryDelay = 50;
|
|
780
|
+
const lockMaxRetries = 100; // 5 seconds total
|
|
781
|
+
|
|
782
|
+
for (let i = 0; i < lockMaxRetries; i++) {
|
|
783
|
+
try {
|
|
784
|
+
// 0o600: owner-only, matching the 0o700 directory posture.
|
|
785
|
+
lockFd = fs.openSync(lockPath, 'wx', 0o600);
|
|
786
|
+
break;
|
|
787
|
+
} catch (err: any) {
|
|
788
|
+
if (err.code === 'EEXIST') {
|
|
789
|
+
// Check if lock is stale (older than 30s)
|
|
790
|
+
try {
|
|
791
|
+
const stats = fs.statSync(lockPath);
|
|
792
|
+
if (Date.now() - stats.mtimeMs > 30000) {
|
|
793
|
+
fs.unlinkSync(lockPath);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
} catch { /* ignore */ }
|
|
797
|
+
|
|
798
|
+
const jitter = Math.floor(Math.random() * 20);
|
|
799
|
+
sleepSync(lockRetryDelay + jitter);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (lockFd === null) {
|
|
807
|
+
throw new Error(`Failed to acquire lock for ${p} after ${lockMaxRetries} retries`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
let lines: string[] = [];
|
|
812
|
+
if (fs.existsSync(p)) {
|
|
813
|
+
lines = fs.readFileSync(p, 'utf-8').split('\n');
|
|
814
|
+
} else {
|
|
815
|
+
lines = [
|
|
816
|
+
`# pi-research global configuration`,
|
|
817
|
+
'',
|
|
818
|
+
];
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const updatedKeys = new Set<string>();
|
|
822
|
+
const outLines: string[] = [];
|
|
823
|
+
|
|
824
|
+
for (const line of lines) {
|
|
825
|
+
const trimmed = line.trim();
|
|
826
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
827
|
+
outLines.push(line);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const eq = line.indexOf('=');
|
|
832
|
+
if (eq < 1) {
|
|
833
|
+
outLines.push(line);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const key = line.slice(0, eq).trim();
|
|
838
|
+
if (newValues[key] !== undefined) {
|
|
839
|
+
outLines.push(`${key}=${newValues[key]}`);
|
|
840
|
+
updatedKeys.add(key);
|
|
841
|
+
} else {
|
|
842
|
+
outLines.push(line);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
for (const [key, val] of Object.entries(newValues)) {
|
|
847
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
848
|
+
if (!updatedKeys.has(key) && val !== '') {
|
|
849
|
+
if (outLines.length > 0 && outLines[outLines.length - 1]?.trim() !== '') {
|
|
850
|
+
outLines.push('');
|
|
851
|
+
}
|
|
852
|
+
outLines.push(`${key}=${val}`);
|
|
853
|
+
updatedKeys.add(key);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Atomic write. config.env can hold API keys, so it must never be world-readable:
|
|
858
|
+
// the tmp file is created 0o600 ({ mode } applies at creation; the pid+timestamp
|
|
859
|
+
// name makes reuse of an existing tmp file effectively impossible), and after the
|
|
860
|
+
// rename the final file is chmod'd 0o600 so a pre-existing 0644 config.env from an
|
|
861
|
+
// older release gets tightened too (rename alone would only carry the tmp's mode
|
|
862
|
+
// for the fresh-file case, and the merge path rewrites existing files).
|
|
863
|
+
const tmpPath = `${p}.tmp.${process.pid}.${Date.now()}`;
|
|
864
|
+
fs.writeFileSync(tmpPath, outLines.join('\n'), { encoding: 'utf-8', mode: 0o600 });
|
|
865
|
+
try {
|
|
866
|
+
fs.renameSync(tmpPath, p);
|
|
867
|
+
} catch (renameErr) {
|
|
868
|
+
if (process.platform === 'win32') {
|
|
869
|
+
fs.copyFileSync(tmpPath, p);
|
|
870
|
+
try { fs.unlinkSync(tmpPath); } catch { /* best effort */ }
|
|
871
|
+
} else {
|
|
872
|
+
try { fs.unlinkSync(tmpPath); } catch { /* best effort */ }
|
|
873
|
+
throw renameErr;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// Tighten regardless of how the file came to exist (fresh rename, Windows copy over
|
|
877
|
+
// an existing file, or a pre-hardening 0644 file that the rename replaced-in-place).
|
|
878
|
+
// Best-effort: on Windows, chmodSync only toggles the read-only attribute and is
|
|
879
|
+
// effectively a no-op for the write-bit; a throw here (e.g. on an exotic FS) must
|
|
880
|
+
// not fail a save whose data is already durably written.
|
|
881
|
+
try { fs.chmodSync(p, 0o600); } catch { /* best effort — data already saved */ }
|
|
882
|
+
} catch (err) {
|
|
883
|
+
logger.error(`[config] Failed to write config to ${p}:`, err);
|
|
884
|
+
throw err;
|
|
885
|
+
} finally {
|
|
886
|
+
// lockFd is guaranteed non-null here (thrown before try if null)
|
|
887
|
+
fs.closeSync(lockFd!);
|
|
888
|
+
try {
|
|
889
|
+
fs.unlinkSync(lockPath);
|
|
890
|
+
} catch { /* ignore */ }
|
|
891
|
+
}
|
|
174
892
|
}
|
|
175
893
|
|
|
176
894
|
// ============================================================================
|
|
177
|
-
//
|
|
895
|
+
// Internal State
|
|
178
896
|
// ============================================================================
|
|
179
897
|
|
|
180
898
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
899
|
+
* Config cache keyed by normalized CWD.
|
|
900
|
+
* Avoids stale cached config when the process CWD changes between sessions.
|
|
183
901
|
*/
|
|
184
|
-
|
|
185
|
-
|
|
902
|
+
const configCache = new Map<string, Config>();
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Internal factory for creating a configuration object from env.
|
|
906
|
+
*/
|
|
907
|
+
export function createConfig(env: Record<string, string | undefined>, processEnv: Record<string, string | undefined>): Config {
|
|
908
|
+
const e = { ...env, ...processEnv };
|
|
909
|
+
|
|
910
|
+
const raw = {
|
|
911
|
+
RESEARCHER_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_TIMEOUT_MS', DEFAULTS.RESEARCHER_TIMEOUT_MS, 180000, 1800000),
|
|
912
|
+
MAX_CONCURRENT_RESEARCHERS: parseEnvNumber(e, 'PI_RESEARCH_MAX_RESEARCHERS', DEFAULTS.MAX_CONCURRENT_RESEARCHERS, 1, 5, true),
|
|
913
|
+
RESEARCHER_MAX_RETRIES: parseEnvNumber(e, 'PI_RESEARCH_MAX_RETRIES', DEFAULTS.RESEARCHER_MAX_RETRIES, 0, 5, true),
|
|
914
|
+
RESEARCHER_MAX_RETRY_DELAY_MS: parseEnvNumber(e, 'PI_RESEARCH_RETRY_DELAY_MS', DEFAULTS.RESEARCHER_MAX_RETRY_DELAY_MS, 100, 10000),
|
|
915
|
+
DEFAULT_RESEARCH_DEPTH: parseEnvNumber(e, 'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH', DEFAULTS.DEFAULT_RESEARCH_DEPTH, 1, 3, true),
|
|
916
|
+
MAX_SCRAPE_BATCHES: parseEnvNumber(e, 'PI_RESEARCH_MAX_SCRAPE_BATCHES', DEFAULTS.MAX_SCRAPE_BATCHES, 0, 99, true),
|
|
917
|
+
MAX_GATHERING_CALLS: parseEnvNumber(e, 'PI_RESEARCH_MAX_GATHERING_CALLS', DEFAULTS.MAX_GATHERING_CALLS, 1, 100, true),
|
|
918
|
+
WORKER_THREADS: parseEnvNumber(e, 'PI_RESEARCH_WORKER_THREADS', DEFAULTS.WORKER_THREADS, 1, 10, true),
|
|
919
|
+
WORKER_CONCURRENCY: parseEnvNumber(e, 'PI_RESEARCH_WORKER_CONCURRENCY', DEFAULTS.WORKER_CONCURRENCY, 1, 10, true),
|
|
920
|
+
KNOWLEDGE_STORE_MODE: parseEnvEnum(e, 'PI_RESEARCH_KNOWLEDGE_STORE_MODE', ['none', 'project', 'global'] as const, 'global'),
|
|
921
|
+
EMBEDDING_MODEL: parseEnvString(e, 'PI_RESEARCH_EMBEDDING_MODEL', DEFAULTS.EMBEDDING_MODEL)!,
|
|
922
|
+
EMBEDDING_DEVICE: parseEnvEnum(e, 'PI_RESEARCH_EMBEDDING_DEVICE', ['auto', 'webgpu', 'cpu'] as const, DEFAULTS.EMBEDDING_DEVICE),
|
|
923
|
+
SCRAPE_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_SCRAPE_TIMEOUT_MS', DEFAULTS.SCRAPE_TIMEOUT_MS, 5000, 120000),
|
|
924
|
+
// Accept both canonical name and legacy name for backward compatibility.
|
|
925
|
+
// saveConfig writes the canonical name (PI_RESEARCH_CACHE_TTL_DAYS) but
|
|
926
|
+
// older sessions may have written PI_RESEARCH_KNOWLEDGE_STORE_CACHE_TTL_DAYS.
|
|
927
|
+
KNOWLEDGE_STORE_CACHE_TTL_DAYS:
|
|
928
|
+
e['PI_RESEARCH_CACHE_TTL_DAYS'] !== undefined
|
|
929
|
+
? parseEnvNumber(e, 'PI_RESEARCH_CACHE_TTL_DAYS', DEFAULTS.KNOWLEDGE_STORE_CACHE_TTL_DAYS, 1, 365)
|
|
930
|
+
: parseEnvNumber(e, 'PI_RESEARCH_KNOWLEDGE_STORE_CACHE_TTL_DAYS', DEFAULTS.KNOWLEDGE_STORE_CACHE_TTL_DAYS, 1, 365),
|
|
931
|
+
KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS: parseEnvNumber(e, 'PI_RESEARCH_KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS', DEFAULTS.KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS, 0, 3650),
|
|
932
|
+
EMBEDDING_MODEL_INIT_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_EMBEDDING_MODEL_INIT_TIMEOUT_MS', DEFAULTS.EMBEDDING_MODEL_INIT_TIMEOUT_MS, 10000, 600000),
|
|
933
|
+
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),
|
|
934
|
+
AVG_TOKENS_PER_SCRAPE: parseEnvNumber(e, 'PI_RESEARCH_AVG_TOKENS_PER_SCRAPE', DEFAULTS.AVG_TOKENS_PER_SCRAPE, 500, 10000),
|
|
935
|
+
MAX_CONCURRENT_SCRAPES: parseEnvNumber(e, 'PI_RESEARCH_MAX_CONCURRENT_SCRAPES', DEFAULTS.MAX_CONCURRENT_SCRAPES, 1, 20, true),
|
|
936
|
+
YOUTUBE_TRANSCRIPT_MAX_VIDEOS: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS', DEFAULTS.YOUTUBE_TRANSCRIPT_MAX_VIDEOS, 1, 5, true),
|
|
937
|
+
YOUTUBE_TRANSCRIPT_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS', DEFAULTS.YOUTUBE_TRANSCRIPT_TIMEOUT_MS, 5000, 120000),
|
|
938
|
+
YOUTUBE_TRANSCRIPT_LANG: parseEnvString(e, 'PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG', DEFAULTS.YOUTUBE_TRANSCRIPT_LANG)!,
|
|
939
|
+
YOUTUBE_QUERY_EVERY_N: parseEnvNumber(e, 'PI_RESEARCH_YOUTUBE_QUERY_EVERY_N', DEFAULTS.YOUTUBE_QUERY_EVERY_N, 1, 100, true),
|
|
940
|
+
HEALTH_CHECK_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS', DEFAULTS.HEALTH_CHECK_TIMEOUT_MS, 2000, 120000),
|
|
941
|
+
SEARCH_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_SEARCH_TIMEOUT_MS', DEFAULTS.SEARCH_TIMEOUT_MS, 5000, 120000),
|
|
942
|
+
TUI_REFRESH_DEBOUNCE_MS: parseEnvNumber(e, 'PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS', DEFAULTS.TUI_REFRESH_DEBOUNCE_MS, 0, 1000),
|
|
943
|
+
BROWSER_TASK_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS', DEFAULTS.BROWSER_TASK_TIMEOUT_MS, 2000, 120000),
|
|
944
|
+
LLM_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_LLM_TIMEOUT_MS', DEFAULTS.LLM_TIMEOUT_MS, 60000, 600000),
|
|
945
|
+
LLM_THINKING_LEVEL: parseEnvEnum(e, 'PI_RESEARCH_LLM_THINKING_LEVEL', ['off', 'minimal', 'low', 'medium', 'high'] as const, DEFAULTS.LLM_THINKING_LEVEL),
|
|
946
|
+
PLANNING_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_PLANNING_MAX_TOKENS', DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
|
|
947
|
+
SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_SYNTHESIS_MAX_TOKENS', DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
|
|
948
|
+
MIGRATION_STRATEGY: parseEnvEnum(e, 'PI_RESEARCH_MIGRATION_STRATEGY', ['drop', 're-embed', 'backup'] as const, DEFAULTS.MIGRATION_STRATEGY),
|
|
949
|
+
CONSOLE_LOG: parseEnvBool(e, 'PI_RESEARCH_CONSOLE_LOG', DEFAULTS.CONSOLE_LOG),
|
|
950
|
+
RESEARCH_MODEL: parseEnvString(e, 'PI_RESEARCH_MODEL', DEFAULTS.RESEARCH_MODEL),
|
|
951
|
+
KNOWLEDGE_STORE_DIR: parseEnvString(e, 'PI_RESEARCH_KNOWLEDGE_DIR', DEFAULTS.KNOWLEDGE_STORE_DIR),
|
|
952
|
+
TMP_DIR: parseEnvString(e, 'PI_RESEARCH_TMP_DIR', DEFAULTS.TMP_DIR),
|
|
953
|
+
RESEARCH_REPORT_EXPORT_ENABLED: parseEnvBool(e, 'PI_RESEARCH_REPORT_EXPORT_ENABLED', DEFAULTS.RESEARCH_REPORT_EXPORT_ENABLED),
|
|
954
|
+
RESEARCH_REPORT_EXPORT_DIR: parseEnvString(e, 'PI_RESEARCH_REPORT_EXPORT_DIR', DEFAULTS.RESEARCH_REPORT_EXPORT_DIR),
|
|
955
|
+
DEBUG: parseEnvBool(e, 'PI_RESEARCH_DEBUG', DEFAULTS.DEBUG),
|
|
956
|
+
DISABLED_TOOLS: parseEnvString(e, 'PI_RESEARCH_DISABLED_TOOLS', DEFAULTS.DISABLED_TOOLS),
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const config = { ...DEFAULTS };
|
|
960
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
961
|
+
if (value !== undefined) {
|
|
962
|
+
(config as any)[key] = value;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Bridge a config/TUI-set DEBUG into the PI_RESEARCH_DEBUG env var so isVerboseFromEnv()
|
|
967
|
+
// (which reads the env var — it cannot import config without a circular dependency) picks it
|
|
968
|
+
// up. Only ever opt INTO verbose ('true'), never write 'false': createConfig runs with the
|
|
969
|
+
// shared process.env, and getConfig merges process.env LAST, so pinning 'false' here from the
|
|
970
|
+
// first-resolved interface would override a later interface whose own config sets DEBUG=true
|
|
971
|
+
// (silently disabling its debug logging). Enabling-only is a safe, monotonic bridge.
|
|
972
|
+
if (config.DEBUG && processEnv['PI_RESEARCH_DEBUG'] === undefined) {
|
|
973
|
+
processEnv['PI_RESEARCH_DEBUG'] = 'true';
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Same enabling-only bridge for CONSOLE_LOG: the logger reads only the env var
|
|
977
|
+
// (same circular-dependency constraint as DEBUG). Without this, a config.env
|
|
978
|
+
// PI_RESEARCH_CONSOLE_LOG=true silently did nothing under the pi extension —
|
|
979
|
+
// the CLI worked only because bridgeConfigEnv promotes file keys into the env.
|
|
980
|
+
if (config.CONSOLE_LOG && processEnv['PI_RESEARCH_CONSOLE_LOG'] === undefined) {
|
|
981
|
+
processEnv['PI_RESEARCH_CONSOLE_LOG'] = 'true';
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return config;
|
|
186
985
|
}
|
|
187
986
|
|
|
188
987
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
988
|
+
* Robustly load configuration for a specific directory and optional interface.
|
|
989
|
+
*
|
|
990
|
+
* Resolution order (later wins):
|
|
991
|
+
* Defaults < config.env < {iface}.env (optional) < legacy .pi-research.env < project registry < process.env
|
|
992
|
+
*
|
|
993
|
+
* The optional `iface` parameter selects a per-interface overlay file
|
|
994
|
+
* (~/.pi/research/{iface}.env) that layers over the base config.env.
|
|
995
|
+
* This lets the pi and cli front-ends carry independent
|
|
996
|
+
* model/config settings while all falling back to the shared base when
|
|
997
|
+
* not set. (The SDK is code-configured and has no overlay file.)
|
|
998
|
+
*
|
|
999
|
+
* Results are cached by (cwd, iface) pair.
|
|
191
1000
|
*/
|
|
192
|
-
export function
|
|
193
|
-
|
|
1001
|
+
export function getConfig(cwd: string = process.cwd(), iface?: ConfigInterface): Config {
|
|
1002
|
+
const cacheKey = `${normalizeWorkspacePath(cwd)}:${iface ?? ''}`;
|
|
1003
|
+
const cached = configCache.get(cacheKey);
|
|
1004
|
+
if (cached) return cached;
|
|
1005
|
+
|
|
1006
|
+
const e = loadEnvFiles(cwd, iface);
|
|
1007
|
+
const config = createConfig(e, process.env);
|
|
1008
|
+
|
|
1009
|
+
configCache.set(cacheKey, config);
|
|
1010
|
+
return config;
|
|
194
1011
|
}
|
|
195
1012
|
|
|
196
1013
|
/**
|
|
197
|
-
*
|
|
1014
|
+
* Manually override configuration.
|
|
1015
|
+
* Uses the cache for the current CWD (for CLI/test compatibility).
|
|
198
1016
|
*/
|
|
199
|
-
export function
|
|
200
|
-
|
|
1017
|
+
export function setConfig(config: Partial<Config>, iface?: ConfigInterface): void {
|
|
1018
|
+
const current = getConfig(process.cwd(), iface);
|
|
1019
|
+
const cacheKey = `${normalizeWorkspacePath(process.cwd())}:${iface ?? ''}`;
|
|
1020
|
+
configCache.set(cacheKey, { ...current, ...config });
|
|
201
1021
|
}
|
|
202
1022
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
1023
|
+
export function resetConfig(): void {
|
|
1024
|
+
configCache.clear();
|
|
1025
|
+
warnedLegacyEnvVars = false;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Validate configuration object against schema
|
|
1030
|
+
*/
|
|
1031
|
+
export function validateConfig(config: Config): void {
|
|
1032
|
+
const errors = [...Value.Errors(ConfigSchema, config)];
|
|
1033
|
+
if (errors.length > 0) {
|
|
1034
|
+
throw new Error(`Invalid configuration: ${errors.map(e => `${(e as any).path || ''} ${e.message}`).join(', ')}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Helpers
|
|
1039
|
+
function parseEnvNumber(env: Record<string, string | undefined>, key: string, def: number, min?: number, max?: number, integer = false): number {
|
|
1040
|
+
const v = env[key];
|
|
1041
|
+
// Trim before the empty check so a whitespace-only value falls back to the default
|
|
1042
|
+
// (Number('') is 0, not NaN, so without this a " " value would silently resolve to 0
|
|
1043
|
+
// and bypass the invalid-number warning below).
|
|
1044
|
+
if (v === undefined || v.trim() === '') return def;
|
|
1045
|
+
// Number() (not parseFloat) so trailing garbage is REJECTED rather than silently
|
|
1046
|
+
// truncated — parseFloat('10abc') === 10, which let a typo'd knob pass as a wrong value.
|
|
1047
|
+
let n = Number(v.trim());
|
|
1048
|
+
if (isNaN(n)) {
|
|
1049
|
+
logger.warn(`[config] Environment variable ${key}="${v}" is not a valid number, using default: ${def}`);
|
|
1050
|
+
return def;
|
|
1051
|
+
}
|
|
1052
|
+
// Count-type knobs (thread/researcher/scrape counts) must be whole numbers — a
|
|
1053
|
+
// fractional value would break poolifier / loop bounds that assume an integer.
|
|
1054
|
+
if (integer) n = Math.round(n);
|
|
1055
|
+
if (min !== undefined && n < min) {
|
|
1056
|
+
logger.warn(`[config] ${key}=${n} is below minimum ${min}, clamping`);
|
|
1057
|
+
return min;
|
|
1058
|
+
}
|
|
1059
|
+
if (max !== undefined && n > max) {
|
|
1060
|
+
logger.warn(`[config] ${key}=${n} is above maximum ${max}, clamping`);
|
|
1061
|
+
return max;
|
|
1062
|
+
}
|
|
1063
|
+
return n;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function parseEnvBool(env: Record<string, string | undefined>, key: string, def: boolean): boolean {
|
|
1067
|
+
const v = env[key];
|
|
1068
|
+
if (v === undefined || v === '') return def;
|
|
1069
|
+
// Strict by design: only the literal 'true' (any case) is truthy; every other value is false.
|
|
1070
|
+
// This is intentional and asserted in config.test.ts ('1'/'yes' → false) — do not loosen it.
|
|
1071
|
+
return v.toLowerCase() === 'true';
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function parseEnvString(env: Record<string, string | undefined>, key: string, def?: string): string | undefined {
|
|
1075
|
+
return env[key] || def;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Parse an env var constrained to a fixed set of allowed values. An invalid value
|
|
1080
|
+
* falls back to the default with a WARN rather than being cast straight through —
|
|
1081
|
+
* otherwise a typo like PI_RESEARCH_KNOWLEDGE_STORE_MODE=projetc silently produces
|
|
1082
|
+
* a value no downstream switch handles, degrading behavior with no signal.
|
|
1083
|
+
*/
|
|
1084
|
+
function parseEnvEnum<T extends string>(
|
|
1085
|
+
env: Record<string, string | undefined>,
|
|
1086
|
+
key: string,
|
|
1087
|
+
allowed: readonly T[],
|
|
1088
|
+
def: T,
|
|
1089
|
+
): T {
|
|
1090
|
+
const raw = env[key];
|
|
1091
|
+
if (raw === undefined || raw === '') return def;
|
|
1092
|
+
if ((allowed as readonly string[]).includes(raw)) return raw as T;
|
|
1093
|
+
logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(', ')}). Using default "${def}".`);
|
|
1094
|
+
return def;
|
|
1095
|
+
}
|