@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.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1044 -0
  30. package/src/config.ts +1036 -147
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +264 -7
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
package/src/config.ts CHANGED
@@ -1,206 +1,1095 @@
1
1
  /**
2
- * Configuration Module (Refactored for Testability)
2
+ * Configuration Module
3
3
  *
4
- * Uses factory pattern instead of module-level caching.
5
- * Allows for different configurations in tests.
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
- * Configuration values
12
- */
13
- export interface Config {
14
- /** Per-researcher timeout in milliseconds (default: 240000) */
15
- RESEARCHER_TIMEOUT_MS: number;
16
- /** Maximum researchers allowed to run simultaneously (default: 3) */
17
- MAX_CONCURRENT_RESEARCHERS: number;
18
- /** Proxy URL for SearXNG searches (optional) */
19
- PROXY_URL?: string;
20
- /** Brave Search API key — enables the braveapi engine in SearXNG when set (optional) */
21
- BRAVE_SEARCH_API_KEY?: string;
22
- /** Health check timeout in milliseconds (default: 15000) */
23
- HEALTH_CHECK_TIMEOUT_MS?: number;
24
- /** Global TUI refresh debounce in milliseconds (default: 10) */
25
- TUI_REFRESH_DEBOUNCE_MS: number;
26
- /** Console restore delay after research in milliseconds (default: 15000) */
27
- CONSOLE_RESTORE_DELAY_MS: number;
28
- /** BCP 47 language tag sent to SearXNG to filter search results (default: 'en-US') */
29
- SEARCH_LANGUAGE: string;
30
- }
31
-
32
- /**
33
- * Configuration defaults
34
- */
35
- const DEFAULTS: Config = {
36
- RESEARCHER_TIMEOUT_MS: 240000,
37
- MAX_CONCURRENT_RESEARCHERS: 3,
38
- PROXY_URL: undefined,
39
- BRAVE_SEARCH_API_KEY: undefined,
40
- HEALTH_CHECK_TIMEOUT_MS: 15000,
41
- TUI_REFRESH_DEBOUNCE_MS: 10,
42
- CONSOLE_RESTORE_DELAY_MS: 15000,
43
- SEARCH_LANGUAGE: 'en-US',
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
- * Parse environment variable to number with default
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
- function parseEnvNumber(
50
- env: Record<string, string | undefined>,
51
- key: string,
52
- defaultValue: number,
53
- base: number = 10
54
- ): number {
55
- const value = env[key];
56
- if (value === undefined) {
57
- return defaultValue;
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
- const parsed = parseInt(value, base);
60
- if (isNaN(parsed)) {
61
- logger.warn(`[config] Invalid value for ${key}, using default: ${defaultValue}`);
62
- return defaultValue;
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
- * Get optional string environment variable
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 parseEnvString(
71
- env: Record<string, string | undefined>,
72
- key: string
73
- ): string | undefined {
74
- const value = env[key];
75
- return value === undefined ? undefined : value;
76
- }
77
-
78
- /**
79
- * Create configuration from environment variables
80
- * @param env Optional environment object (for testing)
81
- * @returns Configuration object
82
- */
83
- export function createConfig(env: Record<string, string | undefined> = process.env): Config {
84
- return {
85
- RESEARCHER_TIMEOUT_MS: parseEnvNumber(
86
- env,
87
- 'PI_RESEARCH_RESEARCHER_TIMEOUT_MS',
88
- DEFAULTS.RESEARCHER_TIMEOUT_MS
89
- ),
90
- MAX_CONCURRENT_RESEARCHERS: parseEnvNumber(
91
- env,
92
- 'PI_RESEARCH_MAX_CONCURRENT_RESEARCHERS',
93
- DEFAULTS.MAX_CONCURRENT_RESEARCHERS
94
- ),
95
- PROXY_URL: parseEnvString(env, 'PROXY_URL'),
96
- BRAVE_SEARCH_API_KEY: parseEnvString(env, 'BRAVE_SEARCH_API_KEY'),
97
- HEALTH_CHECK_TIMEOUT_MS: parseEnvNumber(
98
- env,
99
- 'PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS',
100
- DEFAULTS.HEALTH_CHECK_TIMEOUT_MS as number,
101
- ),
102
- TUI_REFRESH_DEBOUNCE_MS: parseEnvNumber(
103
- env,
104
- 'PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS',
105
- DEFAULTS.TUI_REFRESH_DEBOUNCE_MS
106
- ),
107
- CONSOLE_RESTORE_DELAY_MS: parseEnvNumber(
108
- env,
109
- 'PI_RESEARCH_CONSOLE_RESTORE_DELAY_MS',
110
- DEFAULTS.CONSOLE_RESTORE_DELAY_MS
111
- ),
112
- SEARCH_LANGUAGE: parseEnvString(env, 'PI_RESEARCH_SEARCH_LANGUAGE') ?? DEFAULTS.SEARCH_LANGUAGE,
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
- * Global configuration instance (for backward compatibility)
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
- let globalConfig: Config | null = null;
443
+ function getLocalEnvFilePath(cwd: string = process.cwd()): string {
444
+ return path.resolve(cwd, '.pi-research.env');
445
+ }
120
446
 
121
447
  /**
122
- * Get global configuration
123
- * Creates from environment if not yet initialized
448
+ * Returns the active database directory.
124
449
  */
125
- export function getConfig(): Config {
126
- if (!globalConfig) {
127
- globalConfig = createConfig();
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
- return globalConfig;
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
- * Set global configuration (for testing or manual override)
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 setConfig(config: Config): void {
136
- globalConfig = config;
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
- * Reset global configuration to environment defaults
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 resetConfig(): void {
143
- globalConfig = null;
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
- * Validate configuration values
148
- * @param config Configuration to validate
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
- export function validateConfig(config: Config = getConfig()): void {
152
- if (config.RESEARCHER_TIMEOUT_MS < 30000 || config.RESEARCHER_TIMEOUT_MS > 600000) {
153
- throw new Error(
154
- `PI_RESEARCH_RESEARCHER_TIMEOUT_MS must be between 30000ms (30s) and 600000ms (10m), got ${config.RESEARCHER_TIMEOUT_MS}ms`
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
- if (config.MAX_CONCURRENT_RESEARCHERS < 1 || config.MAX_CONCURRENT_RESEARCHERS > 10) {
159
- throw new Error(
160
- `PI_RESEARCH_MAX_CONCURRENT_RESEARCHERS must be between 1 and 10, got ${config.MAX_CONCURRENT_RESEARCHERS}`
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
- // Warn if proxy is configured
165
- if (config.PROXY_URL) {
166
- logger.warn('[config] Proxy configured - searches will be routed through:', config.PROXY_URL);
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
- logger.debug('[config] Configuration validated:', {
170
- RESEARCHER_TIMEOUT_MS: config.RESEARCHER_TIMEOUT_MS,
171
- PROXY_URL: config.PROXY_URL,
172
- HEALTH_CHECK_TIMEOUT_MS: config.HEALTH_CHECK_TIMEOUT_MS,
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
- // Backward Compatibility Exports
895
+ // Internal State
178
896
  // ============================================================================
179
897
 
180
898
  /**
181
- * @deprecated Use getConfig().RESEARCHER_TIMEOUT_MS instead
182
- * Returns current config value - reflects environment changes if config is reset
899
+ * Config cache keyed by normalized CWD.
900
+ * Avoids stale cached config when the process CWD changes between sessions.
183
901
  */
184
- export function getResearcherTimeoutMs(): number {
185
- return getConfig().RESEARCHER_TIMEOUT_MS;
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
- * @deprecated Use getConfig().PROXY_URL instead
190
- * Returns current config value - reflects environment changes if config is reset
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 getProxyUrl(): string | undefined {
193
- return getConfig().PROXY_URL;
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
- * @deprecated Use getConfig().BRAVE_SEARCH_API_KEY instead
1014
+ * Manually override configuration.
1015
+ * Uses the cache for the current CWD (for CLI/test compatibility).
198
1016
  */
199
- export function getBraveSearchApiKey(): string | undefined {
200
- return getConfig().BRAVE_SEARCH_API_KEY;
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
- // Legacy variable exports (for backward compatibility, but reflect module load time values)
204
- export const RESEARCHER_TIMEOUT_MS = getResearcherTimeoutMs();
205
- export const PROXY_URL = getProxyUrl();
206
- export const BRAVE_SEARCH_API_KEY = getBraveSearchApiKey();
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
+ }