@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/cli.ts ADDED
@@ -0,0 +1,1044 @@
1
+ /**
2
+ * pi-research — standalone CLI
3
+ *
4
+ * A thin, dependency-free command line over the pi-research programmatic SDK
5
+ * (src/sdk.ts). It lets any tool — Claude, Cursor, a shell script, CI — run
6
+ * multi-agent web research without the pi CLI or TUI.
7
+ *
8
+ * pi-research research "<query>" [--depth N] [--model provider/id]
9
+ * pi-research knowledge "<q>" ["<q2>" ...]
10
+ * pi-research knowledge-config [set <none|project|global>]
11
+ * pi-research status [--json]
12
+ * pi-research help
13
+ *
14
+ * Design goals: simple, robust, zero unconfigured-runtime surprises. It fails
15
+ * FAST with an actionable message (and the exact config locations) when the
16
+ * package or a model/API key is missing, instead of spinning up the browser pool
17
+ * and embedding model only to die on the first LLM call.
18
+ *
19
+ * Built to a single plain-JS bundle (dist/cli.mjs) via esbuild --packages=external,
20
+ * so it runs under plain `node` with no TypeScript loader. Exposed as the
21
+ * `pi-research` bin in package.json.
22
+ */
23
+
24
+ import * as fs from 'node:fs';
25
+ import * as path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ // pi-research internals (bundled; native/npm deps stay external).
29
+ import {
30
+ initResearchSDK,
31
+ runDeepResearch,
32
+ searchKnowledge,
33
+ shutdownResearchSDK,
34
+ type KnowledgeSearchResult,
35
+ } from './sdk.ts';
36
+ import type { HeadlessObserverOptions } from './orchestration/headless-observer.ts';
37
+ import { validateAndSanitizeQuery } from './utils/input-validation.ts';
38
+ import { validateInitialLink, MAX_INITIAL_LINKS } from './utils/url-utils.ts';
39
+ import { exportResearchReport, appendExportMessage } from './utils/research-export.ts';
40
+ import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFilePath, saveConfig, resetConfig, describeKnowledgeStoreMode, isProjectScopedKey, parseDotEnv } from './config.ts';
41
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Constants
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Read the package version from package.json at runtime so `--version`/`status`
49
+ * never drift from package.json. `../package.json` relative to import.meta.url
50
+ * resolves correctly both in dev (src/cli.ts → repo root) and when bundled
51
+ * (dist/cli.mjs → installed package root).
52
+ */
53
+ function readPkgVersion(): string {
54
+ try {
55
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
56
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string };
57
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
58
+ } catch {
59
+ return '0.0.0';
60
+ }
61
+ }
62
+
63
+ const PKG_VERSION = readPkgVersion();
64
+ const BINARY_NAME = 'pi-research';
65
+
66
+ /** sysexits-style exit codes (kept distinct so callers/agents can branch). */
67
+ export const EXIT = {
68
+ OK: 0,
69
+ /** Misuse / bad arguments. */
70
+ USAGE: 64,
71
+ /** Missing setup: package, model, or API key not configured. */
72
+ CONFIG: 78,
73
+ /** Something went wrong at runtime (network, provider, internal). */
74
+ SOFTWARE: 70,
75
+ } as const;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Output helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function toStderr(s: string): void {
82
+ process.stderr.write(s);
83
+ }
84
+
85
+ function toStdout(s: string): void {
86
+ process.stdout.write(s);
87
+ }
88
+
89
+ /** A compact human-readable summary of a value for --json status output. */
90
+ function pretty(obj: unknown): string {
91
+ return JSON.stringify(obj, null, 2);
92
+ }
93
+
94
+ /**
95
+ * Build a headless observer that writes a single concise progress line per
96
+ * lifecycle event to stderr. Keeps a long research run visible without spamming
97
+ * stdout (the report goes to stdout, untouched).
98
+ */
99
+ function makeProgressObserver(): HeadlessObserverOptions {
100
+ return {
101
+ enableLogging: false,
102
+ onProgress: (event: string, data?: any) => {
103
+ switch (event) {
104
+ case 'planning_success':
105
+ toStderr(` • plan: ${data?.plan?.researchers?.length ?? '?'} researcher(s)\n`);
106
+ break;
107
+ case 'round_start':
108
+ toStderr(` • round ${data?.round}\n`);
109
+ break;
110
+ case 'search_start':
111
+ toStderr(` • search burst: ${data?.queries?.length ?? 0} query/${(data?.queries?.length ?? 0) === 1 ? '' : 's'}\n`);
112
+ break;
113
+ case 'researcher_start':
114
+ toStderr(` • researcher ${data?.id}: ${data?.name}\n`);
115
+ break;
116
+ case 'researcher_failure':
117
+ toStderr(` • researcher ${data?.id} failed: ${data?.error}\n`);
118
+ break;
119
+ case 'evaluation_decision':
120
+ toStderr(` • evaluator: ${data?.action}\n`);
121
+ break;
122
+ case 'tokens_consumed':
123
+ if (data?.cost) toStderr(` • tokens: ${data?.tokens ?? 0} ($${Number(data.cost).toFixed(4)})\n`);
124
+ break;
125
+ case 'complete':
126
+ toStderr(` • complete (${data?.result?.length ?? 0} chars)\n`);
127
+ break;
128
+ case 'error':
129
+ toStderr(` • error: ${data?.message}\n`);
130
+ break;
131
+ default:
132
+ // Other granular events are intentionally silent to keep output clean.
133
+ break;
134
+ }
135
+ },
136
+ };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Configuration bootstrap
141
+ // ---------------------------------------------------------------------------
142
+
143
+
144
+ /**
145
+ * Bridge the CLI's config files into process.env for keys not already set in the
146
+ * real environment. Layers the base `~/.pi/research/config.env` then the CLI's
147
+ * own `~/.pi/research/cli.env` overlay (overlay wins), or a `--config <path>`
148
+ * override in place of the base.
149
+ *
150
+ * Why: auth values (PI_RESEARCH_API_KEY / _PROVIDER) are read directly from
151
+ * process.env, NOT from the typed Config, so a key placed only in a config file
152
+ * would otherwise be silently ignored. We bridge only when a key is absent from
153
+ * the real environment, so real shell exports always win.
154
+ */
155
+ export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; loaded: boolean } {
156
+ const basePath = explicitConfigPath ?? getGlobalEnvFilePath();
157
+ const cliOverlayPath = getInterfaceEnvFilePath('cli');
158
+ let loaded = false;
159
+ // Merge base then overlay into one map FIRST (overlay wins), and only then
160
+ // promote into process.env. Promoting per-file would invert the documented
161
+ // precedence: the base file's key would land in process.env on the first
162
+ // iteration and the "don't clobber the real environment" guard would then
163
+ // block the overlay's value — cli.env could never override config.env.
164
+ const merged: Record<string, string> = {};
165
+ for (const filePath of [basePath, cliOverlayPath]) {
166
+ try {
167
+ if (!fs.existsSync(filePath)) continue;
168
+ Object.assign(merged, parseDotEnv(fs.readFileSync(filePath, 'utf-8')));
169
+ loaded = true;
170
+ } catch {
171
+ // ignore unreadable file; fall through to the next
172
+ }
173
+ }
174
+ for (const [k, v] of Object.entries(merged)) {
175
+ // Skip per-directory (project-scoped) keys: promoting them into process.env would make
176
+ // config.env out-rank the per-directory registry, defeating a per-cwd override. They are
177
+ // still applied as a lower-precedence file layer by getConfig()/loadEnvFiles().
178
+ if (isProjectScopedKey(k)) continue;
179
+ if (process.env[k] === undefined && (k.startsWith('PI_RESEARCH_') || k === 'STACKEXCHANGE_API_KEY' || k === 'GITHUB_TOKEN' || k === 'NVD_API_KEY')) {
180
+ process.env[k] = v;
181
+ }
182
+ }
183
+ return { path: basePath, loaded };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Credential / model detection (no SDK init — cheap & safe for `status`)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export interface ResolvedConfigPaths {
191
+ /** Global config file (~/.pi/research/config.env). */
192
+ configEnv: string;
193
+ /** Global config dir (~/.pi/research). */
194
+ configDir: string;
195
+ /** Per-interface overlay for THIS standalone CLI / agent skill (~/.pi/research/cli.env). */
196
+ cliIfaceEnv: string;
197
+ /** Per-interface overlay for the pi extension (~/.pi/research/pi.env). */
198
+ piIfaceEnv: string;
199
+ /** pi auth storage (~/.pi/agent/auth.json). */
200
+ piAuth: string;
201
+ /** pi model definitions (~/.pi/agent/models.json). */
202
+ piModels: string;
203
+ /** pi-research state dir (~/.pi/research/state). */
204
+ piState: string;
205
+ }
206
+
207
+ function resolvedConfigPaths(): ResolvedConfigPaths {
208
+ const agentDir = getAgentDir();
209
+ const configDir = getGlobalConfigDir();
210
+ return {
211
+ configEnv: getGlobalEnvFilePath(),
212
+ configDir,
213
+ cliIfaceEnv: getInterfaceEnvFilePath('cli'),
214
+ piIfaceEnv: getInterfaceEnvFilePath('pi'),
215
+ piAuth: path.join(agentDir, 'auth.json'),
216
+ piModels: path.join(agentDir, 'models.json'),
217
+ piState: process.env['PI_RESEARCH_STATE_DIR'] ?? path.join(getGlobalConfigDir(), 'state'),
218
+ };
219
+ }
220
+
221
+ function fileExists(p: string): boolean {
222
+ try {
223
+ return fs.existsSync(p);
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ export interface CredentialDetection {
230
+ /** Where credentials will come from once initResearchSDK runs. */
231
+ source: 'explicit-env' | 'pi-config' | 'none';
232
+ apiKeyConfigured: boolean;
233
+ provider?: string;
234
+ model?: string;
235
+ modelFrom: 'PI_RESEARCH_MODEL' | 'config.env' | 'unset';
236
+ piAuthPresent: boolean;
237
+ piModelsPresent: boolean;
238
+ /** A short, human-readable problem string, or undefined when ready. */
239
+ problem?: string;
240
+ }
241
+
242
+ /**
243
+ * Detect how the SDK will authenticate WITHOUT initializing it. Used both for
244
+ * the `status` command and for the fast-fail pre-flight before every run.
245
+ */
246
+ function detectCredentials(): CredentialDetection {
247
+ const paths = resolvedConfigPaths();
248
+ const piAuthPresent = fileExists(paths.piAuth);
249
+ const piModelsPresent = fileExists(paths.piModels);
250
+
251
+ const apiKey = process.env['PI_RESEARCH_API_KEY'];
252
+ const providerEnv = process.env['PI_RESEARCH_PROVIDER'];
253
+ const modelEnv = process.env['PI_RESEARCH_MODEL'];
254
+ const modelConfig = getConfig(process.cwd(), 'cli').RESEARCH_MODEL;
255
+
256
+ const model = modelEnv ?? modelConfig;
257
+ const modelFrom = modelEnv ? 'PI_RESEARCH_MODEL' : modelConfig ? 'config.env' : 'unset';
258
+ // Provider is either explicit, or inferred from a "provider/id" model string.
259
+ const provider =
260
+ providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
261
+
262
+ // --- Explicit API-key path -------------------------------------------------
263
+ if (apiKey) {
264
+ if (!provider) {
265
+ return {
266
+ source: 'explicit-env',
267
+ apiKeyConfigured: true,
268
+ provider,
269
+ model,
270
+ modelFrom,
271
+ piAuthPresent,
272
+ piModelsPresent,
273
+ problem:
274
+ 'PI_RESEARCH_API_KEY is set but no provider is configured. Set PI_RESEARCH_PROVIDER ' +
275
+ '(e.g. openai) or use a "provider/model-id" value for PI_RESEARCH_MODEL.',
276
+ };
277
+ }
278
+ if (!model) {
279
+ return {
280
+ source: 'explicit-env',
281
+ apiKeyConfigured: true,
282
+ provider,
283
+ model,
284
+ modelFrom,
285
+ piAuthPresent,
286
+ piModelsPresent,
287
+ problem:
288
+ 'PI_RESEARCH_API_KEY is set but no model is configured. Set PI_RESEARCH_MODEL to a ' +
289
+ '"provider/model-id" (e.g. openai/gpt-4o).',
290
+ };
291
+ }
292
+ return {
293
+ source: 'explicit-env',
294
+ apiKeyConfigured: true,
295
+ provider,
296
+ model,
297
+ modelFrom,
298
+ piAuthPresent,
299
+ piModelsPresent,
300
+ };
301
+ }
302
+
303
+ // --- pi auth storage path --------------------------------------------------
304
+ if (piAuthPresent) {
305
+ // The SDK will resolve a model from the registry (models.json + PI_RESEARCH_MODEL).
306
+ return {
307
+ source: 'pi-config',
308
+ apiKeyConfigured: true, // key lives in auth.json; SDK reads it
309
+ provider,
310
+ model,
311
+ modelFrom,
312
+ piAuthPresent,
313
+ piModelsPresent,
314
+ };
315
+ }
316
+
317
+ // --- Nothing configured ----------------------------------------------------
318
+ return {
319
+ source: 'none',
320
+ apiKeyConfigured: false,
321
+ provider,
322
+ model,
323
+ modelFrom,
324
+ piAuthPresent,
325
+ piModelsPresent,
326
+ problem:
327
+ 'No model or API key is configured for pi-research. Provide credentials via environment ' +
328
+ 'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
329
+ 'pi, via ~/.pi/agent/auth.json.',
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Build a ready-to-run summary block (config locations + current detection) used
335
+ * in error messages and the `status` command so users always know where things
336
+ * are configured.
337
+ */
338
+ function configBlock(det: CredentialDetection, extraNote?: string): string {
339
+ const paths = resolvedConfigPaths();
340
+ const lines: string[] = [];
341
+ lines.push('Configuration locations:');
342
+ lines.push(` • base config file: ${paths.configEnv}`);
343
+ lines.push(` • cli overlay: ${paths.cliIfaceEnv} (optional; overrides base for this CLI / agent skill)`);
344
+ lines.push(` • env vars: PI_RESEARCH_API_KEY / PI_RESEARCH_PROVIDER / PI_RESEARCH_MODEL`);
345
+ lines.push(` • pi auth storage: ${paths.piAuth}`);
346
+ lines.push(` • pi models: ${paths.piModels}`);
347
+ lines.push('');
348
+ lines.push('Detected:');
349
+ lines.push(` • credential source: ${det.source}`);
350
+ lines.push(` • api key configured: ${det.apiKeyConfigured}`);
351
+ lines.push(` • provider: ${det.provider ?? '(unset)'}`);
352
+ lines.push(` • model: ${det.model ?? '(unset)'} [from: ${det.modelFrom}]`);
353
+ lines.push(` • pi auth.json: ${det.piAuthPresent ? 'present' : 'absent'}`);
354
+ lines.push(` • pi models.json: ${det.piModelsPresent ? 'present' : 'absent'}`);
355
+ if (extraNote) lines.push(` • note: ${extraNote}`);
356
+ return lines.join('\n');
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Subcommands
361
+ // ---------------------------------------------------------------------------
362
+
363
+ interface ResearchArgs {
364
+ query: string;
365
+ depth?: number;
366
+ model?: string;
367
+ excludeTools?: string[];
368
+ initialLinks?: string[];
369
+ json?: boolean;
370
+ }
371
+
372
+ /**
373
+ * Research subcommand. Initializes the SDK, runs deep/quick research, prints the
374
+ * markdown report to stdout, and always shuts down (even on failure).
375
+ */
376
+ async function cmdResearch(args: ResearchArgs): Promise<number> {
377
+ const det = detectCredentials();
378
+ if (det.problem) {
379
+ if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
380
+ else toStderr(`\nError: ${det.problem}\n\n${configBlock(det)}\n`);
381
+ return EXIT.CONFIG;
382
+ }
383
+
384
+ // Apply the same length/whitespace/dangerous-content gate the pi tool path enforces.
385
+ // The CLI (and the skill launcher that forwards to it) otherwise reached the orchestrator
386
+ // with an unbounded or whitespace-only query — parseArgs only rejects a fully empty one.
387
+ let query: string;
388
+ try {
389
+ query = validateAndSanitizeQuery(args.query);
390
+ } catch (e) {
391
+ const vmsg = e instanceof Error ? e.message : String(e);
392
+ if (args.json) toStdout(pretty({ ok: false, error: vmsg, exitCode: EXIT.USAGE }));
393
+ else toStderr(`\nError: ${vmsg}\n`);
394
+ return EXIT.USAGE;
395
+ }
396
+
397
+ const depth: 0 | 1 | 2 | 3 =
398
+ (args.depth ?? getConfig(process.cwd(), 'cli').DEFAULT_RESEARCH_DEPTH) as 0 | 1 | 2 | 3;
399
+ // An explicit --model wins over configured/PI_RESEARCH_MODEL for this run.
400
+ const runModel = args.model ?? det.model;
401
+ toStderr(`[pi-research] starting research (depth ${depth})${runModel ? ` with ${runModel}` : ''}…\n`);
402
+
403
+ // Stream concise lifecycle progress to stderr so a long run is never silent.
404
+ // Mirrors the headless observer pattern used by the pi-research SDK consumers.
405
+ const observer: HeadlessObserverOptions = makeProgressObserver();
406
+
407
+ let exit: number = EXIT.OK;
408
+ try {
409
+ await initResearchSDK({
410
+ model: runModel,
411
+ // Provider/key are read from process.env by the SDK; pass-through for clarity.
412
+ apiKey: process.env['PI_RESEARCH_API_KEY'],
413
+ provider: process.env['PI_RESEARCH_PROVIDER'],
414
+ // Drive the SDK with the CLI's own resolved config (base config.env +
415
+ // cli.env overlay), not the SDK's default base read.
416
+ config: getConfig(process.cwd(), 'cli'),
417
+ ignoreGlobalConfig: true,
418
+ });
419
+
420
+ let report = await runDeepResearch(query, {
421
+ depth,
422
+ observer,
423
+ ...(args.excludeTools ? { excludeTools: args.excludeTools } : {}),
424
+ ...(args.initialLinks ? { initialLinks: args.initialLinks } : {}),
425
+ });
426
+
427
+ // Optionally persist the report to a file (opt-in via
428
+ // PI_RESEARCH_REPORT_EXPORT_ENABLED). The SDK is a pure library and does not
429
+ // write files; the CLI front-end does, mirroring the pi tool path.
430
+ // The saved path is surfaced in BOTH the report text and the JSON output so
431
+ // the calling agent can tell the user where the file is.
432
+ let reportPath: string | null = null;
433
+ const exportCfg = getConfig(process.cwd(), 'cli');
434
+ if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
435
+ reportPath = await exportResearchReport(args.query, report, depth <= 1 ? 'quick' : 'deep', process.cwd(), exportCfg.RESEARCH_REPORT_EXPORT_DIR);
436
+ if (reportPath) {
437
+ report = appendExportMessage(report, reportPath);
438
+ toStderr(`[pi-research] report saved to: ${reportPath}\n`);
439
+ }
440
+ }
441
+
442
+ if (args.json) {
443
+ toStdout(pretty({ ok: true, depth, report, ...(reportPath ? { reportPath } : {}) }));
444
+ } else {
445
+ toStdout(report.endsWith('\n') ? report : report + '\n');
446
+ }
447
+ } catch (err) {
448
+ exit = reportError(err, 'research', args.json);
449
+ } finally {
450
+ await safeShutdown();
451
+ }
452
+ return exit;
453
+ }
454
+
455
+ /**
456
+ * Knowledge-search subcommand (the second research tool). Returns a tri-state
457
+ * result; `found: 'no'`/`'maybe'` tells the caller to follow up with live
458
+ * research.
459
+ */
460
+ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
461
+ const det = detectCredentials();
462
+ if (det.problem) {
463
+ if (json) { toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG })); return EXIT.CONFIG; }
464
+ toStderr(`\nError: ${det.problem}\n\n${configBlock(det)}\n`);
465
+ return EXIT.CONFIG;
466
+ }
467
+
468
+ const mode = getConfig(process.cwd(), 'cli').KNOWLEDGE_STORE_MODE;
469
+ if (mode === 'none') {
470
+ const paths = resolvedConfigPaths();
471
+ const msg =
472
+ 'Knowledge store is disabled (PI_RESEARCH_KNOWLEDGE_STORE_MODE=none). ' +
473
+ "Set it to 'project' or 'global' to enable local knowledge search.\n\n" +
474
+ `Configure in:\n ${paths.configEnv}\n ${paths.cliIfaceEnv} (cli overlay)\n or via PI_RESEARCH_KNOWLEDGE_STORE_MODE env var`;
475
+ if (json) toStdout(pretty({ found: 'no', text: msg, configured: false }));
476
+ else toStderr(`\n[pi-research] ${msg}\n`);
477
+ return EXIT.CONFIG;
478
+ }
479
+
480
+ let result: KnowledgeSearchResult;
481
+ let exit: number = EXIT.OK;
482
+ try {
483
+ await initResearchSDK({
484
+ model: det.model,
485
+ apiKey: process.env['PI_RESEARCH_API_KEY'],
486
+ provider: process.env['PI_RESEARCH_PROVIDER'],
487
+ config: getConfig(process.cwd(), 'cli'),
488
+ ignoreGlobalConfig: true,
489
+ });
490
+ toStderr(`[pi-research] searching knowledge store (${queries.length} query/${queries.length === 1 ? '' : 's'})…\n`);
491
+ result = await searchKnowledge(queries);
492
+ } catch (err) {
493
+ return reportError(err, 'knowledge search', json);
494
+ } finally {
495
+ await safeShutdown();
496
+ }
497
+
498
+ if (json) {
499
+ toStdout(pretty(result));
500
+ } else {
501
+ toStdout((result.text.endsWith('\n') ? result.text : result.text + '\n'));
502
+ }
503
+ return exit;
504
+ }
505
+
506
+ /**
507
+ * Status subcommand: print where things are and whether the skill is ready to
508
+ * run, without initializing the SDK. Machine-readable with --json.
509
+ */
510
+ async function cmdStatus(json?: boolean): Promise<number> {
511
+ const det = detectCredentials();
512
+ const paths = resolvedConfigPaths();
513
+ const cfg = getConfig(process.cwd(), 'cli');
514
+ const summary = {
515
+ package: '@lincoln504/pi-research',
516
+ version: PKG_VERSION,
517
+ cli: `node ${process.argv[1]}`,
518
+ ready: !det.problem,
519
+ credentials: {
520
+ source: det.source,
521
+ apiKeyConfigured: det.apiKeyConfigured,
522
+ provider: det.provider ?? null,
523
+ model: det.model ?? null,
524
+ modelFrom: det.modelFrom,
525
+ problem: det.problem ?? null,
526
+ },
527
+ knowledgeStoreMode: cfg.KNOWLEDGE_STORE_MODE,
528
+ defaultDepth: cfg.DEFAULT_RESEARCH_DEPTH,
529
+ paths,
530
+ };
531
+
532
+ if (json) {
533
+ toStdout(pretty(summary));
534
+ return EXIT.OK;
535
+ }
536
+
537
+ toStdout(`pi-research ${PKG_VERSION}\n`);
538
+ toStdout(`ready: ${summary.ready ? 'yes' : 'no'}\n\n`);
539
+ toStdout(configBlock(det, summary.ready ? undefined : '— fix the problem above, then re-run.') + '\n');
540
+ toStdout(`\nknowledge store mode: ${cfg.KNOWLEDGE_STORE_MODE} (default depth: ${cfg.DEFAULT_RESEARCH_DEPTH})\n`);
541
+ return EXIT.OK;
542
+ }
543
+
544
+ /**
545
+ * knowledge-config subcommand: show or set the per-directory knowledge-store mode.
546
+ *
547
+ * `set` persists the mode for the CURRENT working directory via the same registry writer the
548
+ * interactive /research-config menu uses (saveConfig scope 'local' → ~/.pi/research/state/
549
+ * project-settings.json keyed by the normalized cwd), then resetConfig() so the new value is
550
+ * what the very next getConfig() returns. This is the supported way for an agent to change the
551
+ * setting on the user's behalf without hand-editing the locked registry JSON. A machine-wide
552
+ * default is set instead via the PI_RESEARCH_KNOWLEDGE_STORE_MODE env var (per-run) or config.env.
553
+ */
554
+ async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeConfig']>): Promise<number> {
555
+ const cwd = process.cwd();
556
+
557
+ if (kc.action === 'set') {
558
+ const before = getConfig(cwd, 'cli').KNOWLEDGE_STORE_MODE;
559
+ const cfg = { ...getConfig(cwd, 'cli'), KNOWLEDGE_STORE_MODE: kc.mode! };
560
+ // Persist ONLY the knowledge-store mode for this directory — not the whole local-key set —
561
+ // so an unrelated per-directory DEFAULT_RESEARCH_DEPTH isn't frozen as a side effect.
562
+ saveConfig(cfg, 'local', cwd, ['PI_RESEARCH_KNOWLEDGE_STORE_MODE']); // per-directory registry write
563
+ resetConfig(); // drop the cached config so `after` re-resolves from disk
564
+ const info = describeKnowledgeStoreMode(cwd, 'cli');
565
+ // The write always persists to the registry, but a real env var out-ranks the registry, so
566
+ // the EFFECTIVE mode can still differ from what was requested. Surface that instead of
567
+ // silently reporting a no-op.
568
+ const overridden = info.mode !== kc.mode;
569
+ if (kc.json) {
570
+ toStdout(pretty({ command: 'knowledge-config', action: 'set', requested: kc.mode, previous: before, saved: true, effectiveOverriddenBy: overridden ? info.origin : null, ...info, cwd }));
571
+ return EXIT.OK;
572
+ }
573
+ toStdout(
574
+ `knowledge store mode for this directory: ${before} -> ${kc.mode} (saved per-directory)\n` +
575
+ ` scope: ${kc.mode === 'none' ? 'disabled here' : kc.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
576
+ ` saved: per-directory (${cwd})\n` +
577
+ ` db dir: ${info.dbDir}\n` +
578
+ (overridden
579
+ ? `\nNOTE: the effective mode here is still '${info.mode}' because a higher-precedence ${info.origin} overrides it. Clear that source for this setting to take effect.\n`
580
+ : ''),
581
+ );
582
+ return EXIT.OK;
583
+ }
584
+
585
+ // show
586
+ const info = describeKnowledgeStoreMode(cwd, 'cli');
587
+ if (kc.json) {
588
+ toStdout(pretty({ command: 'knowledge-config', action: 'show', ...info, cwd }));
589
+ return EXIT.OK;
590
+ }
591
+ toStdout(
592
+ `knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
593
+ ` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
594
+ ` db dir: ${info.dbDir}\n` +
595
+ `\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
596
+ `change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
597
+ );
598
+ return EXIT.OK;
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Error reporting & shutdown
603
+ // ---------------------------------------------------------------------------
604
+
605
+ /** Distinguish setup errors from runtime errors and print a clean, located message. */
606
+ function reportError(err: unknown, what: string, json?: boolean): number {
607
+ const msg = err instanceof Error ? err.message : String(err);
608
+ const lower = msg.toLowerCase();
609
+
610
+ // Auth / model / config-shaped errors → CONFIG exit + config block.
611
+ const isConfigError =
612
+ lower.includes('no llm model available') ||
613
+ lower.includes('not found in pi') ||
614
+ lower.includes('no api key') ||
615
+ lower.includes('provider must be specified') ||
616
+ lower.includes('invalid model string') ||
617
+ lower.includes('authentication') ||
618
+ lower.includes('unauthorized') ||
619
+ lower.includes('api key');
620
+
621
+ const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
622
+ const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
623
+
624
+ // --json: emit a structured error on stdout so a machine consumer never has to
625
+ // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
626
+ if (json) {
627
+ const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
628
+ if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
629
+ payload['stack'] = err.stack;
630
+ }
631
+ toStdout(pretty(payload));
632
+ return exitCode;
633
+ }
634
+
635
+ if (isConfigError) {
636
+ toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
637
+ return exitCode;
638
+ }
639
+ // Rate limits are operational, not fatal setup problems.
640
+ if (isRateLimit) {
641
+ toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
642
+ return exitCode;
643
+ }
644
+ toStderr(`\nError: pi-research ${what} failed: ${msg}\n`);
645
+ if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
646
+ toStderr('\n' + err.stack + '\n');
647
+ }
648
+ return exitCode;
649
+ }
650
+
651
+ /**
652
+ * Best-effort SDK shutdown that never throws.
653
+ * Bounded by SHUTDOWN_TIMEOUT_MS: WASM threads (ONNX/embedding model), LanceDB
654
+ * connections, and the browser orphan sweep can all outlast their own internal
655
+ * timeouts. We must not let the CLI hang forever on exit.
656
+ */
657
+ const SHUTDOWN_TIMEOUT_MS = 8_000;
658
+
659
+ async function safeShutdown(): Promise<void> {
660
+ try {
661
+ await Promise.race([
662
+ shutdownResearchSDK(),
663
+ new Promise<void>((resolve) =>
664
+ setTimeout(() => {
665
+ toStderr('[pi-research] shutdown timeout — forcing exit\n');
666
+ resolve();
667
+ }, SHUTDOWN_TIMEOUT_MS),
668
+ ),
669
+ ]);
670
+ } catch {
671
+ // Swallow — nothing actionable for the caller, and we must not mask the real result.
672
+ }
673
+ }
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // Argument parsing
677
+ // ---------------------------------------------------------------------------
678
+
679
+ interface ParsedArgs {
680
+ command?: string;
681
+ research?: ResearchArgs;
682
+ knowledge?: { queries: string[]; json?: boolean };
683
+ knowledgeConfig?: { action: 'show' | 'set'; mode?: 'none' | 'project' | 'global'; json?: boolean };
684
+ status?: { json?: boolean };
685
+ configPath?: string;
686
+ json?: boolean;
687
+ }
688
+
689
+ function buildHelp(): string {
690
+ const p = resolvedConfigPaths();
691
+ return `${BINARY_NAME} v${PKG_VERSION} — multi-agent web research (pi-research SDK)
692
+
693
+ USAGE
694
+ ${BINARY_NAME} <command> [options]
695
+
696
+ COMMANDS
697
+ research "<query>" Run multi-agent web research (default depth: 1).
698
+ --depth <0-3> 0 = quick, 1 = normal (default), 2 = deep, 3 = ultra.
699
+ --model <provider/id> Override the model for this run.
700
+ --exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
701
+ --initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
702
+ --config <path> Use this config file instead of the base config.env.
703
+ --json Emit a JSON object instead of a markdown report.
704
+
705
+ knowledge "<q1>" ["<q2>" ...] Search local knowledge store before live research (up to 5 queries).
706
+ --config <path> Use this config file instead of the base config.env.
707
+ --json Emit a JSON object.
708
+
709
+ knowledge-config [show] Show the knowledge-store mode for this directory + its source.
710
+ --config <path> Use this config file instead of the base config.env.
711
+ --json Emit a JSON object.
712
+ knowledge-config set <mode> Set the mode for THIS directory (none | project | global);
713
+ persisted per-directory. Takes effect on the next run.
714
+
715
+ status Show detected config, model/key, and readiness.
716
+ --config <path> Use this config file instead of the base config.env.
717
+ --json Emit a JSON object.
718
+
719
+ help, --help, -h Show this help.
720
+
721
+ CONFIGURE
722
+ Credentials are resolved in this order (first match wins):
723
+
724
+ env vars: PI_RESEARCH_API_KEY PI_RESEARCH_PROVIDER PI_RESEARCH_MODEL
725
+ cli overlay: ${p.cliIfaceEnv} (optional; this CLI / agent skill only)
726
+ base config: ${p.configEnv}
727
+ pi auth: ${p.piAuth}
728
+ pi models: ${p.piModels}
729
+
730
+ The pi extension has its own optional overlay file:
731
+ pi extension: ${p.piIfaceEnv}
732
+
733
+ Knowledge store — ON by default in every directory (mode 'global', one shared store).
734
+ per-directory: ${BINARY_NAME} knowledge-config set <none|project|global>
735
+ machine-wide: PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${p.configEnv}
736
+ 'project' scopes the store to the current directory; 'none' disables it here.
737
+
738
+ Run \`${BINARY_NAME} status\` to see exactly what is detected.
739
+
740
+ EXIT CODES
741
+ 0 success
742
+ 64 bad arguments
743
+ 78 not configured — message prints the exact locations to fix
744
+ 70 runtime error (network, provider, internal)
745
+ `;
746
+ }
747
+
748
+ export function parseArgs(argv: string[]): ParsedArgs {
749
+ const out: ParsedArgs = {};
750
+ const args = argv.slice(2);
751
+
752
+ if (args.length === 0) {
753
+ out.command = 'help';
754
+ return out;
755
+ }
756
+
757
+ const cmd = args[0];
758
+ if (cmd === '-h' || cmd === '--help' || cmd === 'help') {
759
+ out.command = 'help';
760
+ return out;
761
+ }
762
+ if (cmd === '-v' || cmd === '--version') {
763
+ out.command = 'version';
764
+ return out;
765
+ }
766
+
767
+ const rest = args.slice(1);
768
+
769
+ if (cmd === 'status') {
770
+ const json = rest.includes('--json');
771
+ let configPath: string | undefined;
772
+ const ci = rest.indexOf('--config');
773
+ if (ci !== -1 && rest[ci + 1]) configPath = rest[ci + 1];
774
+ out.command = 'status';
775
+ out.status = { json };
776
+ out.configPath = configPath;
777
+ return out;
778
+ }
779
+
780
+ if (cmd === 'knowledge') {
781
+ const positional: string[] = [];
782
+ let json = false;
783
+ let configPath: string | undefined;
784
+ for (let i = 0; i < rest.length; i++) {
785
+ const a = rest[i];
786
+ if (a === '--json') json = true;
787
+ else if (a === '--config' && rest[i + 1]) configPath = rest[++i];
788
+ else if (a?.startsWith('--')) {
789
+ throw new UsageError(`unknown option for knowledge: ${a}`);
790
+ } else if (a !== undefined) {
791
+ positional.push(a);
792
+ }
793
+ }
794
+ if (positional.length === 0) throw new UsageError('knowledge requires at least one query.');
795
+ if (positional.length > 5) throw new UsageError('knowledge accepts at most 5 queries.');
796
+ out.command = 'knowledge';
797
+ out.knowledge = { queries: positional, json };
798
+ out.configPath = configPath;
799
+ return out;
800
+ }
801
+
802
+ if (cmd === 'knowledge-config') {
803
+ let json = false;
804
+ let configPath: string | undefined;
805
+ const positional: string[] = [];
806
+ for (let i = 0; i < rest.length; i++) {
807
+ const a = rest[i];
808
+ if (a === '--json') json = true;
809
+ else if (a === '--config' && rest[i + 1]) configPath = rest[++i];
810
+ else if (a?.startsWith('--')) {
811
+ throw new UsageError(`unknown option for knowledge-config: ${a}`);
812
+ } else if (a !== undefined) {
813
+ positional.push(a);
814
+ }
815
+ }
816
+ const action = positional[0] ?? 'show';
817
+ if (action === 'show') {
818
+ out.command = 'knowledge-config';
819
+ out.knowledgeConfig = { action: 'show', json };
820
+ out.configPath = configPath;
821
+ return out;
822
+ }
823
+ if (action === 'set') {
824
+ const mode = positional[1];
825
+ if (mode === undefined) {
826
+ throw new UsageError('knowledge-config set requires a mode: none | project | global.');
827
+ }
828
+ if (mode !== 'none' && mode !== 'project' && mode !== 'global') {
829
+ throw new UsageError(`invalid knowledge store mode "${mode}". Use one of: none | project | global.`);
830
+ }
831
+ if (positional.length > 2) {
832
+ throw new UsageError(`unexpected argument "${positional[2]}" after "knowledge-config set ${mode}".`);
833
+ }
834
+ out.command = 'knowledge-config';
835
+ out.knowledgeConfig = { action: 'set', mode, json };
836
+ out.configPath = configPath;
837
+ return out;
838
+ }
839
+ throw new UsageError(`unknown knowledge-config action "${action}". Use: show | set <none|project|global>.`);
840
+ }
841
+
842
+ if (cmd === 'research') {
843
+ const r: ResearchArgs = { query: '' };
844
+ const initialLinks: string[] = [];
845
+ let depth: number | undefined;
846
+ let model: string | undefined;
847
+ let excludeTools: string[] | undefined;
848
+ let json = false;
849
+ let configPath: string | undefined;
850
+ const positional: string[] = [];
851
+
852
+ for (let i = 0; i < rest.length; i++) {
853
+ const a = rest[i];
854
+ if (a === '--') {
855
+ // Everything after -- is the query / positional.
856
+ positional.push(...rest.slice(i + 1).filter((x): x is string => x !== undefined));
857
+ break;
858
+ }
859
+ if (a === '--depth') {
860
+ const v = rest[++i];
861
+ const n = v === undefined ? NaN : parseInt(v, 10);
862
+ if (isNaN(n) || n < 0 || n > 3) throw new UsageError('--depth must be an integer 0–3.');
863
+ depth = n;
864
+ } else if (a === '--model') {
865
+ model = rest[++i];
866
+ if (!model) throw new UsageError('--model requires a value.');
867
+ } else if (a === '--exclude-tools') {
868
+ const v = rest[++i];
869
+ excludeTools = v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
870
+ } else if (a === '--initial-links') {
871
+ // Consume until the next flag or end. Validate each entry: these are templated
872
+ // verbatim into the researcher's "investigate these first" evidence block, so
873
+ // (like the query) they must be bounded and shape-checked — reject non-http(s)
874
+ // or oversized tokens and cap the count so a caller can't inject arbitrary
875
+ // instructions framed as trusted seed evidence or blow the prompt budget.
876
+ // Shares validateInitialLink with the research tool's initialLinks
877
+ // parameter (research-tool-definition.ts) so the two paths cannot drift.
878
+ let j = i + 1;
879
+ while (j < rest.length && !rest[j]?.startsWith('--')) {
880
+ const link = rest[j];
881
+ if (link) {
882
+ const linkError = validateInitialLink(link);
883
+ if (linkError) throw new UsageError(`--initial-links: ${linkError}.`);
884
+ if (initialLinks.length >= MAX_INITIAL_LINKS) throw new UsageError(`--initial-links: at most ${MAX_INITIAL_LINKS} URLs may be provided.`);
885
+ initialLinks.push(link);
886
+ }
887
+ j++;
888
+ }
889
+ i = j - 1;
890
+ } else if (a === '--config') {
891
+ configPath = rest[++i];
892
+ if (!configPath) throw new UsageError('--config requires a path.');
893
+ } else if (a === '--json') {
894
+ json = true;
895
+ } else if (a?.startsWith('--')) {
896
+ throw new UsageError(`unknown option: ${a}`);
897
+ } else if (a !== undefined) {
898
+ positional.push(a);
899
+ }
900
+ }
901
+
902
+ // A links-only invocation is a dead path: cmdResearch feeds the (empty) query
903
+ // through validateAndSanitizeQuery, which rejects it anyway — but with a
904
+ // confusing "empty query" error at exit 64. Reject it up front with one
905
+ // clear message instead: --initial-links SEEDS a query, it cannot replace one.
906
+ if (positional.length === 0) {
907
+ throw new UsageError(
908
+ initialLinks.length > 0
909
+ ? 'research requires a query; --initial-links seeds URLs for a query but cannot replace it.'
910
+ : 'research requires a query.',
911
+ );
912
+ }
913
+ r.query = positional.join(' ').trim();
914
+ r.depth = depth;
915
+ r.model = model;
916
+ r.excludeTools = excludeTools;
917
+ r.initialLinks = initialLinks.length ? initialLinks : undefined;
918
+ r.json = json;
919
+ out.command = 'research';
920
+ out.research = r;
921
+ out.configPath = configPath;
922
+ return out;
923
+ }
924
+
925
+ throw new UsageError(`unknown command "${cmd}". Run \`${BINARY_NAME} help\`.`);
926
+ }
927
+
928
+ export class UsageError extends Error {}
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // Main
932
+ // ---------------------------------------------------------------------------
933
+
934
+ // Flush stdout+stderr before exiting so buffered output (e.g. --json into a pipe) is not
935
+ // truncated. Uses process.exit() (not exitCode) so native handles (WASM threads, LanceDB)
936
+ // don't keep the loop alive after work is done. Module-scoped so BOTH the entry-point and
937
+ // the signal handler drain the same way — the signal path previously called process.exit()
938
+ // directly and could truncate output on Ctrl-C mid-write.
939
+ const flushAndExit = (code: number): void => {
940
+ process.stdout.write('', () => {
941
+ process.stderr.write('', () => {
942
+ process.exit(code);
943
+ });
944
+ });
945
+ };
946
+
947
+ async function main(argv: string[]): Promise<number> {
948
+ // Register signal handlers so SIGINT/SIGTERM always trigger SDK shutdown
949
+ // before the process exits. safeShutdown() is idempotent — safe to call
950
+ // before SDK init (it no-ops) and safe to call from multiple signals.
951
+ let _signalCleanupDone = false;
952
+ const onSignal = (sig: string) => {
953
+ if (_signalCleanupDone) return;
954
+ _signalCleanupDone = true;
955
+ toStderr(`\n[pi-research] ${sig} — cleaning up…\n`);
956
+ // Fire-and-forget: we can't await here, but safeShutdown swallows errors
957
+ // and the finally blocks in each command handler also call it. Drain via
958
+ // flushAndExit (not a bare process.exit) so buffered stdout isn't truncated.
959
+ void safeShutdown().finally(() => flushAndExit(EXIT.SOFTWARE));
960
+ };
961
+ process.once('SIGINT', () => onSignal('SIGINT'));
962
+ process.once('SIGTERM', () => onSignal('SIGTERM'));
963
+
964
+ let parsed: ParsedArgs;
965
+ try {
966
+ parsed = parseArgs(argv);
967
+ } catch (err) {
968
+ const msg = err instanceof Error ? err.message : String(err);
969
+ toStderr(`\nError: ${msg}\n\n${buildHelp()}`);
970
+ return EXIT.USAGE;
971
+ }
972
+
973
+ // Bridge config.env → process.env for commands that touch the engine.
974
+ // help/version are pure and need no credentials/config.
975
+ const noBridge = new Set(['help', 'version']);
976
+ if (!noBridge.has(parsed.command ?? '')) {
977
+ bridgeConfigEnv(parsed.configPath);
978
+ // Self-heal cross-harness skill installs on every engine-touching CLI run. The
979
+ // CLI — not the interactive pi extension — is the surface that skill invocations
980
+ // from Claude Code / Codex / OpenClaw actually drive, so without this an update
981
+ // that relocates the package leaves a sibling skill symlink dangling until the
982
+ // user happens to open an interactive pi session. Best-effort and idempotent;
983
+ // it early-returns when no skills are installed and a filesystem hiccup here must
984
+ // never block the command. Mirrors the reconcile in the extension's activation.
985
+ try {
986
+ const { reconcileSkillInstalls } = await import('./skill-install/skill-installer.ts');
987
+ reconcileSkillInstalls();
988
+ } catch { /* best-effort self-heal; never block the CLI */ }
989
+ }
990
+
991
+ switch (parsed.command) {
992
+ case 'help':
993
+ toStdout(buildHelp());
994
+ return EXIT.OK;
995
+ case 'version':
996
+ toStdout(`${BINARY_NAME} ${PKG_VERSION}\n`);
997
+ return EXIT.OK;
998
+ case 'status':
999
+ return cmdStatus(parsed.status?.json);
1000
+ case 'knowledge':
1001
+ return cmdKnowledge(parsed.knowledge!.queries, parsed.knowledge!.json);
1002
+ case 'knowledge-config':
1003
+ return cmdKnowledgeConfig(parsed.knowledgeConfig!);
1004
+ case 'research':
1005
+ return cmdResearch(parsed.research!);
1006
+ default:
1007
+ toStdout(buildHelp());
1008
+ return EXIT.OK;
1009
+ }
1010
+ }
1011
+
1012
+ // ---------------------------------------------------------------------------
1013
+ // Entry-point guard
1014
+ // ---------------------------------------------------------------------------
1015
+
1016
+ // Only run when this module is the direct entry point (node cli.mjs <args>),
1017
+ // not when it is imported by a test or another module. We compare the
1018
+ // resolved on-disk path of this module's URL against process.argv[1].
1019
+ const _isMain = (() => {
1020
+ try {
1021
+ if (!process.argv[1]) return false;
1022
+ // realpath BOTH sides: when installed via an npm `bin` symlink (the primary global-install
1023
+ // path), argv[1] is the symlink while import.meta.url is realpath-resolved by Node. A plain
1024
+ // path.resolve does not follow symlinks, so the two never matched and main() was silently
1025
+ // skipped — the CLI exited 0 with empty output. Resolving symlinks on both sides fixes it.
1026
+ const modulePath = fs.realpathSync(path.resolve(fileURLToPath(import.meta.url)));
1027
+ const invokedPath = fs.realpathSync(path.resolve(process.argv[1]));
1028
+ return modulePath === invokedPath;
1029
+ } catch {
1030
+ return false;
1031
+ }
1032
+ })();
1033
+
1034
+ if (_isMain) {
1035
+ // Top-level entry. Never let an unhandled rejection escape — print a clean error.
1036
+ // flushAndExit (module-scoped above) drains both streams before process.exit() so
1037
+ // buffered output isn't truncated and native handles don't keep the loop alive.
1038
+ main(process.argv)
1039
+ .then(flushAndExit)
1040
+ .catch((err) => {
1041
+ toStderr(`\nError: fatal: ${err instanceof Error ? err.message : String(err)}\n`);
1042
+ flushAndExit(EXIT.SOFTWARE);
1043
+ });
1044
+ }