@lincoln504/pi-research 0.1.13 → 1.0.1

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 +55 -138
  3. package/dist/cli.mjs +23375 -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 +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -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 +1195 -0
  30. package/src/config.ts +1032 -155
  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 +258 -11
  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 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  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
@@ -0,0 +1,872 @@
1
+ /**
2
+ * Research Configuration Command
3
+ *
4
+ * Consolidated interactive TUI menu for managing pi-research.
5
+ * Mirrored after the official Pi settings experience:
6
+ * - Single flat scrollable list
7
+ * - Active item centered with description
8
+ * - Enter/Space to cycle values
9
+ * - Automatic saving on change
10
+ * - Search support
11
+ *
12
+ * Usage:
13
+ * - /research-config - Opens interactive TUI menu
14
+ */
15
+
16
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
17
+ import type { TUI } from '@earendil-works/pi-tui';
18
+ import {
19
+ SettingsList,
20
+ type SettingItem,
21
+ visibleWidth,
22
+ truncateToWidth,
23
+ } from '@earendil-works/pi-tui';
24
+ import { setInteractiveTuiActive, initGlobalTuiController } from './tui/tui-controller.ts';
25
+ import { normalizeSessionId } from './orchestration/session-state.ts';
26
+ import { getConfig, saveConfig, resetConfig, getDbDir } from './config.ts';
27
+ import { healthRegistry } from './healthcheck/index.ts';
28
+ import { getService, clearService, tryGetServiceContainerFromCtx } from './core/service-registry.ts';
29
+ import { ServiceNames, IKnowledgeStoreService } from './core/service-interfaces.ts';
30
+ // Type-only: avoids statically loading the native ML/vector stack into the
31
+ // extension load path. The value (clearEmbeddingInstance) is dynamically
32
+ // imported at its call sites, only when a knowledge command actually runs.
33
+ import type { KnowledgeStoreService } from './infrastructure/knowledge-store-service.ts';
34
+ import type { Theme } from './types/research-panel-types.ts';
35
+ // Import the constant directly from its source module rather than the knowledge
36
+ // barrel (./knowledge/index.ts), which would transitively load lancedb.
37
+ import { SUPPORTED_MODELS } from './knowledge/model-config.ts';
38
+ import { metrics } from './utils/metrics.ts';
39
+ import {
40
+ extractRunStats,
41
+ aggregateSessionStats,
42
+ buildSessionOverview,
43
+ buildRunCompactLine,
44
+ } from './utils/metrics-summary.ts';
45
+ import { logger } from './logger.ts';
46
+ import { formatTimeAgo, formatDuration } from './utils/text-utils.ts';
47
+ import { buildDefaultDebugLogPath } from './utils/log-utils.ts';
48
+ import {
49
+ HARNESSES,
50
+ installSkill,
51
+ uninstallSkill,
52
+ skillInstallCandidates,
53
+ skillUninstallCandidates,
54
+ SKILL_AGENT_TARGETS,
55
+ } from './skill-install/skill-installer.ts';
56
+ import * as path from 'node:path';
57
+
58
+ // ============================================================================
59
+ // Utilities
60
+ // ============================================================================
61
+
62
+ /**
63
+ * TUI setting id → canonical env-var key written to config.env / the project
64
+ * registry. Passed to saveConfig as `changedKeys` so a save persists ONLY the
65
+ * setting the user actually changed — never a snapshot of the whole resolved
66
+ * config (which would freeze env overrides and current defaults into the file).
67
+ * Several ids do not map 1:1 (e.g. RESEARCHER_TIMEOUT_MS → PI_RESEARCH_TIMEOUT_MS),
68
+ * hence an explicit map instead of `PI_RESEARCH_${id}`.
69
+ */
70
+ const ENV_KEY_BY_SETTING_ID: Record<string, string> = {
71
+ DEFAULT_RESEARCH_DEPTH: 'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH',
72
+ KNOWLEDGE_STORE_MODE: 'PI_RESEARCH_KNOWLEDGE_STORE_MODE',
73
+ RESEARCHER_TIMEOUT_MS: 'PI_RESEARCH_TIMEOUT_MS',
74
+ MAX_CONCURRENT_RESEARCHERS: 'PI_RESEARCH_MAX_RESEARCHERS',
75
+ MAX_SCRAPE_BATCHES: 'PI_RESEARCH_MAX_SCRAPE_BATCHES',
76
+ RESEARCH_REPORT_EXPORT_ENABLED: 'PI_RESEARCH_REPORT_EXPORT_ENABLED',
77
+ DEBUG: 'PI_RESEARCH_DEBUG',
78
+ EMBEDDING_MODEL: 'PI_RESEARCH_EMBEDDING_MODEL',
79
+ EMBEDDING_DEVICE: 'PI_RESEARCH_EMBEDDING_DEVICE',
80
+ KNOWLEDGE_STORE_CACHE_TTL_DAYS: 'PI_RESEARCH_CACHE_TTL_DAYS',
81
+ };
82
+
83
+ // ============================================================================
84
+ // Command Handler
85
+ // ============================================================================
86
+
87
+ /**
88
+ * Main command handler for /research-config
89
+ */
90
+ export async function handleResearchConfigCommand(
91
+ _args: string,
92
+ ctx: ExtensionContext,
93
+ pi: ExtensionAPI
94
+ ): Promise<void> {
95
+ if (!ctx.hasUI) {
96
+ ctx.ui.notify('Interactive menu requires UI mode', 'error');
97
+ return;
98
+ }
99
+ await showInteractiveMenu(ctx, pi);
100
+ }
101
+
102
+ // ============================================================================
103
+ // Interactive TUI Menu
104
+ // ============================================================================
105
+
106
+ /**
107
+ * Show interactive TUI menu for research configuration
108
+ */
109
+ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
110
+ // Ensure the global TUI controller is initialized. Normalize the session id (→ 'default'
111
+ // when unresolved) like every other call site: initGlobalTuiController unconditionally sets
112
+ // state.piSessionId, and an undefined value there makes setInteractiveTuiActive skip
113
+ // hideMasterWidget/refreshAllSessions — leaving the live panel stacked under the menu.
114
+ const piSessionId = normalizeSessionId((ctx as any).sessionId || (ctx as any).sessionManager?.getSessionId());
115
+ initGlobalTuiController(ctx.ui, piSessionId);
116
+
117
+ const cwd = ctx.cwd || process.cwd();
118
+ const initialConfig = { ...getConfig(cwd) };
119
+ const config = { ...getConfig(cwd) };
120
+ const container = tryGetServiceContainerFromCtx(ctx);
121
+ const depthLabels: Record<number, string> = { 1: 'normal', 2: 'deep', 3: 'ultra' };
122
+
123
+ const anyKnowledgeStore = config.KNOWLEDGE_STORE_MODE !== 'none';
124
+
125
+ // Coding-agent skill install/uninstall gating, computed once from disk:
126
+ // • Install is offered only when a target agent's ROOT config folder already
127
+ // exists (~/.claude or ~/.codex) — we never create an agent's home dir.
128
+ // • Uninstall is offered only when the skill is actually installed (an owned
129
+ // symlink/copy at ~/.<agent>/skills/pi-research) — a deeper-level check.
130
+ const anyAgentRootPresent = skillInstallCandidates().length > 0;
131
+ const skillInstalledAnywhere = skillUninstallCandidates().length > 0;
132
+
133
+ const initialItems: SettingItem[] = [
134
+ // ── Project-scoped settings (saved per-directory) ──
135
+ {
136
+ id: 'DEFAULT_RESEARCH_DEPTH',
137
+ label: '/research depth [project]',
138
+ description: 'Default depth for the /research command (normal, deep, ultra).\n[project] means configured independently per directory.',
139
+ currentValue: depthLabels[config.DEFAULT_RESEARCH_DEPTH] || String(config.DEFAULT_RESEARCH_DEPTH),
140
+ values: ['normal', 'deep', 'ultra'],
141
+ },
142
+ {
143
+ id: 'KNOWLEDGE_STORE_MODE',
144
+ label: 'Knowledge Mode [project]',
145
+ description: 'Knowledge store scope — none (disabled), project (scope knowledge per this directory), or global (scope knowledge globally, shared across all projects).\n[project] means configured independently per directory.',
146
+ currentValue: config.KNOWLEDGE_STORE_MODE,
147
+ values: ['none', 'project', 'global'],
148
+ },
149
+ {
150
+ id: 'RESEARCHER_TIMEOUT_MS',
151
+ label: 'Researcher Timeout',
152
+ description: 'Minutes before a stalled research track is forcefully cancelled.',
153
+ currentValue: String(Math.round(config.RESEARCHER_TIMEOUT_MS / 60000)),
154
+ values: ['3', '5', '10', '15', '20', '30'],
155
+ },
156
+ {
157
+ id: 'MAX_CONCURRENT_RESEARCHERS',
158
+ label: 'Max Concurrency',
159
+ description: 'Parallel researcher threads. Reduce this if you hit API rate-limit errors.',
160
+ currentValue: String(config.MAX_CONCURRENT_RESEARCHERS),
161
+ values: ['1', '2', '3', '4', '5'],
162
+ },
163
+ {
164
+ id: 'MAX_SCRAPE_BATCHES',
165
+ label: 'Scrape Batches',
166
+ description: 'Max URL batches a researcher can fetch (0 = unlimited). Lower values conserve API credit.',
167
+ currentValue: config.MAX_SCRAPE_BATCHES === 0 ? 'unlimited' : String(config.MAX_SCRAPE_BATCHES),
168
+ values: ['unlimited', '1', '2', '3', '5', '10', '15'],
169
+ },
170
+ {
171
+ id: 'RESEARCH_REPORT_EXPORT_ENABLED',
172
+ label: 'Auto-export Report',
173
+ description: 'Write a markdown report to disk when each research run completes.',
174
+ currentValue: config.RESEARCH_REPORT_EXPORT_ENABLED ? 'true' : 'false',
175
+ values: ['true', 'false'],
176
+ },
177
+ // Browser Workers is intentionally NOT exposed here. It is an
178
+ // environment-only setting (PI_RESEARCH_WORKER_THREADS, default 4) so that
179
+ // CPU/RAM-sensitive concurrency is not casually changed from the menu.
180
+ ...(anyKnowledgeStore ? [
181
+ {
182
+ id: 'EMBEDDING_MODEL',
183
+ label: 'Embedding Model',
184
+ description: 'Vector model for semantic search. Changing it clears the current knowledge store and starts fresh.',
185
+ currentValue: config.EMBEDDING_MODEL.split('/').pop()!,
186
+ values: SUPPORTED_MODELS.map(m => m.id.split('/').pop()!),
187
+ },
188
+ {
189
+ id: 'EMBEDDING_DEVICE',
190
+ label: 'Embedding Device',
191
+ description: 'Hardware backend for the embedding model. GPU probes whether WebGPU actually runs on this machine and automatically falls back to CPU if it does not (safe everywhere). CPU forces CPU-only inference. (Raw forced-GPU is available via the PI_RESEARCH_EMBEDDING_DEVICE=webgpu env var for benchmarking.)',
192
+ currentValue: config.EMBEDDING_DEVICE === 'cpu' ? 'CPU' : 'GPU',
193
+ values: ['GPU', 'CPU'],
194
+ },
195
+ {
196
+ id: 'KNOWLEDGE_STORE_CACHE_TTL_DAYS',
197
+ label: 'Cache Retention',
198
+ description: 'Days to retain findings before automated eviction. Longer = more disk, shorter = fresher data.',
199
+ currentValue: String(config.KNOWLEDGE_STORE_CACHE_TTL_DAYS),
200
+ values: ['7', '14', '30', '60', '90', '180', '365'],
201
+ },
202
+ ] as SettingItem[] : []),
203
+ {
204
+ id: 'DEBUG',
205
+ label: 'Debug Logging',
206
+ description: `Write verbose diagnostics to ${buildDefaultDebugLogPath()} for troubleshooting.`,
207
+ currentValue: config.DEBUG ? 'true' : 'false',
208
+ values: ['true', 'false'],
209
+ },
210
+
211
+ // ── Actions ──
212
+ {
213
+ id: 'ACTION_HEALTH',
214
+ label: 'Run Health Check',
215
+ description: 'Test browser pool, GPU, and knowledge store connectivity.',
216
+ currentValue: 'run',
217
+ values: ['run'],
218
+ },
219
+ ...(anyKnowledgeStore ? [
220
+ {
221
+ id: 'ACTION_KNOWLEDGE_STATUS',
222
+ label: 'Store Status',
223
+ description: 'Show entry counts, disk usage, and the active embedding model.',
224
+ currentValue: 'run',
225
+ values: ['run'],
226
+ },
227
+ ] as SettingItem[] : []),
228
+ ...(config.KNOWLEDGE_STORE_MODE === 'project' ? [
229
+ {
230
+ id: 'ACTION_KNOWLEDGE_CLEAR_LOCAL',
231
+ label: 'Clear Project Store',
232
+ description: 'Permanently delete all entries tied to this project directory.',
233
+ currentValue: 'run',
234
+ values: ['run'],
235
+ },
236
+ ] as SettingItem[] : []),
237
+ ...(config.KNOWLEDGE_STORE_MODE === 'global' ? [
238
+ {
239
+ id: 'ACTION_KNOWLEDGE_CLEAR_GLOBAL',
240
+ label: 'Clear User Store',
241
+ description: 'Permanently delete all globally shared store entries.',
242
+ currentValue: 'run',
243
+ values: ['run'],
244
+ },
245
+ ] as SettingItem[] : []),
246
+ {
247
+ id: 'ACTION_METRICS',
248
+ label: 'Session Metrics',
249
+ description: 'Show token usage, API cost estimates, and success rates. You will be offered the option to reset counters after viewing.',
250
+ currentValue: 'run',
251
+ values: ['run'],
252
+ },
253
+ {
254
+ id: 'ACTION_LOGS_CLEAR',
255
+ label: 'Clear Debug Logs',
256
+ description: 'Delete the diagnostic log file and all archived rotation files.',
257
+ currentValue: 'run',
258
+ values: ['run'],
259
+ },
260
+
261
+ // ── Coding-agent skill ──
262
+ // Install only shows when a target agent's root config dir exists; uninstall
263
+ // only shows when the skill is actually installed somewhere.
264
+ ...(anyAgentRootPresent ? [
265
+ {
266
+ id: 'ACTION_SKILL_INSTALL',
267
+ label: 'Install in External Agents',
268
+ description: 'Symlink the pi-research skill into your other coding agents (Claude, Codex, OpenClaw) so they can run web research too. Only agents already set up on this machine are touched.',
269
+ currentValue: 'run',
270
+ values: ['run'],
271
+ },
272
+ ] as SettingItem[] : []),
273
+ ...(skillInstalledAnywhere ? [
274
+ {
275
+ id: 'ACTION_SKILL_UNINSTALL',
276
+ label: 'Remove from External Agents',
277
+ description: 'Remove the pi-research skill from your other coding agents (Claude, Codex, OpenClaw). Only the symlinks this extension created are removed; unrelated skills are left untouched.',
278
+ currentValue: 'run',
279
+ values: ['run'],
280
+ },
281
+ ] as SettingItem[] : []),
282
+ ];
283
+
284
+ setInteractiveTuiActive(true);
285
+ try {
286
+ await ctx.ui.custom(
287
+ (_tui: TUI, theme: Theme, _kb: any, done: (val: any) => void) => {
288
+ // The "[project]" tag marks settings that are saved independently per
289
+ // directory. It is rendered in the warning (yellow) color so it stands
290
+ // out, while the rest of the label keeps its normal/accent color. The
291
+ // tag is embedded in the label text (SettingsList pads it for alignment),
292
+ // so we split on it here rather than recoloring the whole padded string.
293
+ const PROJECT_TAG = '[project]';
294
+ const colorizeLabel = (text: string, selected: boolean): string => {
295
+ const idx = text.indexOf(PROJECT_TAG);
296
+ if (idx === -1) {
297
+ return selected ? theme.fg('accent', text) : text;
298
+ }
299
+ const before = text.slice(0, idx);
300
+ const after = text.slice(idx + PROJECT_TAG.length); // trailing alignment padding
301
+ const beforeColored = before ? (selected ? theme.fg('accent', before) : before) : '';
302
+ return `${beforeColored}${theme.fg('warning', PROJECT_TAG)}${after}`;
303
+ };
304
+
305
+ const listTheme = {
306
+ label: colorizeLabel,
307
+ value: (text: string, selected: boolean) => selected ? theme.fg('accent', text) : theme.fg('muted', text),
308
+ description: (text: string) => {
309
+ // SettingsList calls this once per wrapped description line, so we
310
+ // can't inspect the line alone — we look up the currently selected
311
+ // item (respecting the active search filter) and color the whole
312
+ // description yellow when it describes a destructive action.
313
+ const list = settingsList as unknown as {
314
+ searchEnabled?: boolean;
315
+ filteredItems?: SettingItem[];
316
+ items?: SettingItem[];
317
+ selectedIndex: number;
318
+ };
319
+ const displayItems = list.searchEnabled ? list.filteredItems : list.items;
320
+ const selectedItem = displayItems?.[list.selectedIndex];
321
+ const desc = selectedItem?.description?.toLowerCase() || '';
322
+ if (desc.includes('delete') || desc.includes('clear')) {
323
+ return theme.fg('warning', text);
324
+ }
325
+ return theme.fg('dim', text);
326
+ },
327
+ cursor: theme.fg('accent', '→ '),
328
+ hint: (text: string) => theme.fg('dim', text),
329
+ };
330
+
331
+ let settingsList: SettingsList;
332
+
333
+ const wrappedDone = (val: any) => {
334
+ done(val);
335
+ };
336
+
337
+ settingsList = new SettingsList(
338
+ initialItems,
339
+ 10,
340
+ listTheme,
341
+ async (id, newValue) => {
342
+ // 1. Handle Settings Changes (Auto-save)
343
+ let changed = true;
344
+ let scope: 'local' | 'user' = 'local';
345
+
346
+ if (id === 'DEFAULT_RESEARCH_DEPTH') {
347
+ const depthMap: Record<string, number> = { 'normal': 1, 'deep': 2, 'ultra': 3 };
348
+ config.DEFAULT_RESEARCH_DEPTH = depthMap[newValue] || 1;
349
+ } else if (id === 'MAX_CONCURRENT_RESEARCHERS') {
350
+ config.MAX_CONCURRENT_RESEARCHERS = parseInt(newValue, 10);
351
+ scope = 'user';
352
+ } else if (id === 'MAX_SCRAPE_BATCHES') {
353
+ config.MAX_SCRAPE_BATCHES = newValue === 'unlimited' ? 0 : parseInt(newValue, 10);
354
+ scope = 'user';
355
+ } else if (id === 'RESEARCHER_TIMEOUT_MS') {
356
+ config.RESEARCHER_TIMEOUT_MS = parseInt(newValue, 10) * 60000;
357
+ scope = 'user';
358
+ } else if (id === 'DEBUG') {
359
+ config.DEBUG = newValue === 'true';
360
+ scope = 'user';
361
+ } else if (id === 'RESEARCH_REPORT_EXPORT_ENABLED') {
362
+ config.RESEARCH_REPORT_EXPORT_ENABLED = newValue === 'true';
363
+ scope = 'user';
364
+ } else if (id === 'KNOWLEDGE_STORE_MODE') {
365
+ config.KNOWLEDGE_STORE_MODE = newValue as 'none' | 'project' | 'global';
366
+ } else if (id === 'EMBEDDING_MODEL') {
367
+ config.EMBEDDING_MODEL = SUPPORTED_MODELS.find(m => m.id.split('/').pop() === newValue)?.id ?? newValue;
368
+ // DESTRUCTIVE + DEFERRED: changing the model clears the knowledge store, so it is
369
+ // NOT auto-saved on cycle like the other settings. Persist + clear happen in the
370
+ // close handler, gated on an explicit ctx.ui.confirm — and an Esc/cancel close
371
+ // reverts the change entirely (nothing was written here, so there is nothing to
372
+ // roll back on disk).
373
+ changed = false;
374
+ } else if (id === 'EMBEDDING_DEVICE') {
375
+ // TUI offers only GPU (= safe auto/probe-then-fallback) and CPU.
376
+ // Raw forced 'webgpu' (no probe) stays reachable via env var only.
377
+ config.EMBEDDING_DEVICE = newValue === 'CPU' ? 'cpu' : 'auto';
378
+ scope = 'user';
379
+ } else if (id === 'KNOWLEDGE_STORE_CACHE_TTL_DAYS') {
380
+ config.KNOWLEDGE_STORE_CACHE_TTL_DAYS = parseInt(newValue, 10);
381
+ scope = 'user';
382
+ } else {
383
+ changed = false;
384
+ }
385
+
386
+ if (changed) {
387
+ try {
388
+ // Persist ONLY the key that changed — for BOTH scopes. A local write scoped to
389
+ // all keys would freeze the sibling local key per-directory; a user write scoped
390
+ // to all keys would snapshot the entire resolved config (env overrides included)
391
+ // into config.env, freezing values the user never chose.
392
+ saveConfig(config, scope, cwd, [ENV_KEY_BY_SETTING_ID[id] ?? `PI_RESEARCH_${id}`]);
393
+ resetConfig();
394
+ } catch (e: unknown) {
395
+ ctx.ui.notify(`Failed to save: ${e instanceof Error ? e.message : String(e)}`, 'error');
396
+ }
397
+ }
398
+
399
+ // If Knowledge Mode was just turned on, warm the store in the background so the first
400
+ // search after enabling doesn't block on a cold init (native embed stack + init lock,
401
+ // a few seconds). Fire-and-forget: getStore() drives initialize() to completion. The
402
+ // search tool also awaits readiness, so this is a latency optimisation, not a
403
+ // correctness dependency — the store works without it, just slower on the first query.
404
+ if (id === 'KNOWLEDGE_STORE_MODE' && newValue !== 'none') {
405
+ const kmContainer = tryGetServiceContainerFromCtx(ctx);
406
+ void getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, kmContainer)
407
+ .then((s) => s.getStore())
408
+ .catch((e) => logger.debug('[research-config] knowledge-store warm-up failed:', e));
409
+ }
410
+
411
+ // 2. Handle Actions
412
+ if (id === 'ACTION_HEALTH') {
413
+ wrappedDone({ type: 'action', action: 'health' });
414
+ } else if (id === 'ACTION_KNOWLEDGE_STATUS') {
415
+ wrappedDone({ type: 'action', action: 'knowledge_status' });
416
+ } else if (id === 'ACTION_KNOWLEDGE_CLEAR_GLOBAL') {
417
+ wrappedDone({ type: 'action', action: 'knowledge_clear_global' });
418
+ } else if (id === 'ACTION_KNOWLEDGE_CLEAR_LOCAL') {
419
+ wrappedDone({ type: 'action', action: 'knowledge_clear_local' });
420
+ } else if (id === 'ACTION_METRICS') {
421
+ wrappedDone({ type: 'action', action: 'metrics' });
422
+ } else if (id === 'ACTION_LOGS_CLEAR') {
423
+ wrappedDone({ type: 'action', action: 'logs_clear' });
424
+ } else if (id === 'ACTION_SKILL_INSTALL') {
425
+ wrappedDone({ type: 'action', action: 'skill_install' });
426
+ } else if (id === 'ACTION_SKILL_UNINSTALL') {
427
+ wrappedDone({ type: 'action', action: 'skill_uninstall' });
428
+ }
429
+
430
+ },
431
+ () => wrappedDone({ type: 'cancel' }),
432
+ { enableSearch: true }
433
+ );
434
+
435
+ return {
436
+ render: (width: number) => {
437
+ if (width < 4) return [];
438
+ const border = theme.fg('muted', '─'.repeat(width));
439
+ // Drop the blank separator lines the SettingsList widget inserts (before the
440
+ // description and before the hint) so the menu renders as one gap-free block.
441
+ const listLines = settingsList.render(width).filter(line => visibleWidth(line) > 0);
442
+
443
+ // Header with title and search hint
444
+ const titleText = 'Research Configuration';
445
+ const hintText = 'Type to search';
446
+ const header = `${titleText} ${theme.fg('dim', `(${hintText})`)}`;
447
+
448
+ // Footer: which directory the [project]-scoped settings apply to.
449
+ // "[project]" is yellow (matching the per-row tag); the rest is dim.
450
+ // Kept on a single line so the outer truncate clips it cleanly when
451
+ // the terminal is narrow.
452
+ const dirName = path.basename(cwd);
453
+ // Two-space indent so the footer's "[project]" lines up with the list rows
454
+ // and the "[project]" in the description (both use a 2-space left margin).
455
+ const pathInfoLine = ` ${theme.fg('warning', '[project]')}${theme.fg('dim', ` directory: ${dirName}`)}`;
456
+
457
+ // No blank line before the footer: keep it flush under the hint line.
458
+ const lines = [border, header, border, ...listLines, pathInfoLine];
459
+ return lines.map(line => {
460
+ if (visibleWidth(line) > width) {
461
+ return truncateToWidth(line, width);
462
+ }
463
+ return line;
464
+ });
465
+ },
466
+ handleInput: (data: string) => settingsList.handleInput(data),
467
+ invalidate: () => settingsList.invalidate(),
468
+ };
469
+ }
470
+ // Rendered inline (default): the menu replaces the editor and takes
471
+ // keyboard focus. While a research run is active, the live research panel
472
+ // is removed from the screen for the duration of the menu (see
473
+ // setInteractiveTuiActive → hideMasterWidget), so the inline menu gets the
474
+ // full screen and leaves no ghost rows on close — without an overlay popup.
475
+ )
476
+ .then(async (result: any) => {
477
+ // 1. Handle Critical Config Changes (Model/Device)
478
+ // We do this after the TUI closes to avoid race conditions and redundant clears
479
+ let modelChangeConfirmed = false;
480
+ if (config.EMBEDDING_MODEL !== initialConfig.EMBEDDING_MODEL) {
481
+ // The model change was deliberately NOT auto-saved on cycle (see the change handler):
482
+ // it clears the knowledge store, so it needs the same explicit confirm as the other
483
+ // destructive actions — and an Esc/cancel close must abandon it without prompting.
484
+ const cancelled = result?.type === 'cancel';
485
+ modelChangeConfirmed = !cancelled && await ctx.ui.confirm(
486
+ 'Change Embedding Model',
487
+ `Switch the embedding model to ${config.EMBEDDING_MODEL.split('/').pop()}? This permanently clears the current knowledge store.`,
488
+ );
489
+ if (modelChangeConfirmed) {
490
+ logger.info(`[research-config] Embedding model changed from ${initialConfig.EMBEDDING_MODEL} to ${config.EMBEDDING_MODEL}. Clearing knowledge store.`);
491
+ try {
492
+ saveConfig(config, 'user', cwd, ['PI_RESEARCH_EMBEDDING_MODEL']);
493
+ resetConfig();
494
+ } catch (e: unknown) {
495
+ modelChangeConfirmed = false;
496
+ ctx.ui.notify(`Failed to save: ${e instanceof Error ? e.message : String(e)}`, 'error');
497
+ }
498
+ }
499
+ if (modelChangeConfirmed) {
500
+ try {
501
+ const service = await getService<KnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
502
+ await service.clear();
503
+ await clearService(ServiceNames.KNOWLEDGE_STORE, container);
504
+ const { clearEmbeddingInstance } = await import('./infrastructure/embedding/embedding-factory.ts');
505
+ clearEmbeddingInstance();
506
+ ctx.ui.notify('Model updated. Store cleared.', 'info');
507
+ } catch (e: unknown) {
508
+ logger.warn('[research-config] Failed to clear knowledge store on model change:', e);
509
+ }
510
+ } else {
511
+ // Declined, cancelled, or the save failed: revert the in-memory value so nothing
512
+ // downstream acts on a model that was never persisted.
513
+ config.EMBEDDING_MODEL = initialConfig.EMBEDDING_MODEL;
514
+ if (!cancelled) ctx.ui.notify('Embedding model unchanged.', 'info');
515
+ }
516
+ }
517
+ if (!modelChangeConfirmed && config.EMBEDDING_DEVICE !== initialConfig.EMBEDDING_DEVICE) {
518
+ logger.info('[research-config] Device changed. Resetting service.');
519
+ try {
520
+ await clearService(ServiceNames.KNOWLEDGE_STORE, container);
521
+ const { clearEmbeddingInstance } = await import('./infrastructure/embedding/embedding-factory.ts');
522
+ clearEmbeddingInstance();
523
+ ctx.ui.notify('Device updated. Service refreshed.', 'info');
524
+
525
+ } catch (e: unknown) {
526
+ logger.warn('[research-config] Failed to refresh service on device change:', e);
527
+ }
528
+ }
529
+
530
+ // 2. Handle Actions after the TUI closes to avoid UI conflicts
531
+ if (result?.type === 'action') {
532
+ switch (result.action) {
533
+ case 'health':
534
+ await runHealthCheckAction(ctx, pi);
535
+ break;
536
+ case 'knowledge_status':
537
+ await showKnowledgeStatusAction(ctx, pi);
538
+ break;
539
+ case 'knowledge_clear_global': {
540
+ const confirmed = await ctx.ui.confirm('Clear User Store', 'Permanently delete all user-scoped store data?');
541
+ if (confirmed) {
542
+ try {
543
+ const service = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
544
+ await service.clearGlobal();
545
+ ctx.ui.notify('User store cleared.', 'info');
546
+ } catch (e: unknown) {
547
+ ctx.ui.notify(`Clear failed: ${e instanceof Error ? e.message : String(e)}`, 'error');
548
+ }
549
+ }
550
+ break;
551
+ }
552
+ case 'knowledge_clear_local': {
553
+ const confirmed = await ctx.ui.confirm('Clear Project Store', 'Permanently delete all project-scoped store data?');
554
+ if (confirmed) {
555
+ try {
556
+ const service = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
557
+ await service.clearLocal();
558
+ ctx.ui.notify('Project store cleared.', 'info');
559
+ } catch (e: unknown) {
560
+ ctx.ui.notify(`Clear failed: ${e instanceof Error ? e.message : String(e)}`, 'error');
561
+ }
562
+ }
563
+ break;
564
+ }
565
+ case 'metrics':
566
+ await showMetricsAction(ctx, pi);
567
+ if (await ctx.ui.confirm('Session Metrics', 'Reset all session counters?')) {
568
+ metrics.clearSession();
569
+ ctx.ui.notify('Session metrics reset.', 'info');
570
+ }
571
+ break;
572
+ case 'logs_clear': {
573
+ const confirmed = await ctx.ui.confirm('Clear Logs', 'Delete the main diagnostic log file and all archived rotation files?');
574
+ if (confirmed) {
575
+ logger.clear();
576
+ ctx.ui.notify('Diagnostic logs cleared.', 'info');
577
+ }
578
+ break;
579
+ }
580
+ case 'skill_install':
581
+ await installSkillAction(ctx, pi);
582
+ break;
583
+ case 'skill_uninstall':
584
+ await uninstallSkillAction(ctx, pi);
585
+ break;
586
+ }
587
+ }
588
+ });
589
+ } catch (error: unknown) {
590
+ logger.error(`[Config] Menu error: ${error instanceof Error ? error.message : String(error)}`);
591
+ } finally {
592
+ setInteractiveTuiActive(false);
593
+ }
594
+ }
595
+
596
+ // ============================================================================
597
+ // Action Handlers (Internal)
598
+ // ============================================================================
599
+
600
+ /** id → human label for the coding agents the installer targets. */
601
+ const HARNESS_LABEL: Record<string, string> = Object.fromEntries(
602
+ HARNESSES.map(h => [h.id, h.label]),
603
+ );
604
+
605
+ /**
606
+ * Symlink the pi-research skill into each target coding agent (Claude, Codex)
607
+ * whose ROOT config folder already exists. Agents without a config folder are
608
+ * skipped — we never create an agent's home dir. An agent slot already occupied
609
+ * by an unrelated skill is left untouched.
610
+ */
611
+ async function installSkillAction(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
612
+ try {
613
+ const presentById = new Map(skillInstallCandidates().map(d => [d.id, true] as const));
614
+ const targets = SKILL_AGENT_TARGETS.filter(id => presentById.get(id));
615
+
616
+ // resolveSkillSourceDir (inside installSkill) throws if the bundled SKILL.md
617
+ // cannot be located — caught below and surfaced to the user.
618
+ const results = targets.length ? installSkill([...targets]) : [];
619
+ const resById = new Map(results.map(r => [r.tool, r]));
620
+
621
+ const lines: string[] = ['## pi-research skill — install', ''];
622
+ let ok = 0;
623
+ for (const id of SKILL_AGENT_TARGETS) {
624
+ const label = HARNESS_LABEL[id] ?? id;
625
+ if (!presentById.get(id)) {
626
+ lines.push(`- **${label}** — not detected on this machine; skipped.`);
627
+ continue;
628
+ }
629
+ const r = resById.get(id);
630
+ switch (r?.status) {
631
+ case 'installed':
632
+ ok++;
633
+ lines.push(`- **${label}** — ${r.type === 'copy' ? 'copied (symlink unavailable)' : 'symlinked'} → \`${r.path}\``);
634
+ break;
635
+ case 'already-installed':
636
+ ok++;
637
+ lines.push(`- **${label}** — already installed → \`${r.path}\``);
638
+ break;
639
+ case 'skipped-foreign':
640
+ lines.push(`- **${label}** — a different skill already occupies this slot; left untouched.`);
641
+ break;
642
+ case 'error':
643
+ lines.push(`- **${label}** — failed: ${r.message ?? 'unknown error'}`);
644
+ break;
645
+ default:
646
+ lines.push(`- **${label}** — ${r?.status ?? 'no result'}`);
647
+ }
648
+ }
649
+ lines.push('', 'The skill activates automatically — just ask the agent to pi-research something.');
650
+
651
+ pi.sendMessage({ customType: 'skill-install-result', content: lines.join('\n'), display: true });
652
+ if (ctx.hasUI) {
653
+ if (ok > 0) ctx.ui.notify(`Skill installed for ${ok} coding agent${ok === 1 ? '' : 's'}.`, 'info');
654
+ else if (targets.length === 0) ctx.ui.notify('No coding agents detected (Claude, Codex).', 'warning');
655
+ else ctx.ui.notify('No agents installed — see the message for details.', 'warning');
656
+ }
657
+ } catch (e: unknown) {
658
+ const msg = e instanceof Error ? e.message : String(e);
659
+ logger.error(`[research-config] Skill install failed: ${msg}`);
660
+ if (ctx.hasUI) ctx.ui.notify(`Skill install failed: ${msg}`, 'error');
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Remove the pi-research skill symlinks this extension created from Claude and
666
+ * Codex. Only installs we own are removed; foreign skills are kept.
667
+ */
668
+ async function uninstallSkillAction(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
669
+ // Deeper-level gate than install: only act on agents where the skill is
670
+ // actually installed (an owned symlink/copy present on disk).
671
+ const installedTargets = skillUninstallCandidates();
672
+ if (installedTargets.length === 0) {
673
+ if (ctx.hasUI) ctx.ui.notify('The skill is not installed in any coding agent.', 'info');
674
+ return;
675
+ }
676
+
677
+ const names = installedTargets.map(d => HARNESS_LABEL[d.id] ?? d.id).join(' and ');
678
+ const confirmed = await ctx.ui.confirm(
679
+ 'Uninstall Skill',
680
+ `Remove the pi-research skill from ${names}? (Only symlinks this extension created are removed.)`,
681
+ );
682
+ if (!confirmed) return;
683
+ try {
684
+ const results = uninstallSkill(installedTargets.map(d => d.id));
685
+
686
+ const lines: string[] = ['## pi-research skill — uninstall', ''];
687
+ let removed = 0;
688
+ if (results.length === 0) {
689
+ lines.push('- Nothing to remove — the skill was not installed in any coding agent.');
690
+ } else {
691
+ for (const r of results) {
692
+ const label = HARNESS_LABEL[r.tool] ?? r.tool;
693
+ switch (r.status) {
694
+ case 'removed':
695
+ removed++;
696
+ lines.push(`- **${label}** — removed → \`${r.path}\``);
697
+ break;
698
+ case 'not-present':
699
+ lines.push(`- **${label}** — was not installed.`);
700
+ break;
701
+ case 'skipped-foreign':
702
+ lines.push(`- **${label}** — not ours; left untouched.`);
703
+ break;
704
+ case 'error':
705
+ lines.push(`- **${label}** — failed: ${r.message ?? 'unknown error'}`);
706
+ break;
707
+ default:
708
+ lines.push(`- **${label}** — ${r.status}`);
709
+ }
710
+ }
711
+ }
712
+
713
+ pi.sendMessage({ customType: 'skill-uninstall-result', content: lines.join('\n'), display: true });
714
+ if (ctx.hasUI) {
715
+ ctx.ui.notify(removed > 0 ? `Skill removed from ${removed} coding agent${removed === 1 ? '' : 's'}.` : 'No installs were removed.', 'info');
716
+ }
717
+ } catch (e: unknown) {
718
+ const msg = e instanceof Error ? e.message : String(e);
719
+ logger.error(`[research-config] Skill uninstall failed: ${msg}`);
720
+ if (ctx.hasUI) ctx.ui.notify(`Skill uninstall failed: ${msg}`, 'error');
721
+ }
722
+ }
723
+
724
+ async function runHealthCheckAction(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
725
+ if (ctx.hasUI) {
726
+ ctx.ui.notify('Running health checks.', 'info');
727
+ }
728
+ try {
729
+ const systemHealth = await healthRegistry.runAll({ force: true });
730
+ const outputLines: string[] = [];
731
+ outputLines.push('## System Health Status');
732
+ outputLines.push('');
733
+
734
+ const statusIcon = systemHealth.status === 'healthy' ? '[OK]' :
735
+ systemHealth.status === 'degraded' ? '[WARN]' : '[ERROR]';
736
+ outputLines.push(`**${statusIcon} Status: ${systemHealth.status.toUpperCase()}**`);
737
+ outputLines.push('');
738
+
739
+ for (const component of systemHealth.components) {
740
+ const icon = component.healthy ? '[OK]' : '[FAIL]';
741
+ outputLines.push(`${icon} **${component.component}**`);
742
+ if (component.error) outputLines.push(` - Error: ${component.error}`);
743
+ outputLines.push(` - Duration: ${component.durationMs.toFixed(1)}ms`);
744
+ outputLines.push('');
745
+ }
746
+
747
+ pi.sendMessage({
748
+ customType: 'health-result',
749
+ content: outputLines.join('\n'),
750
+ display: true,
751
+ details: { health: systemHealth },
752
+ });
753
+ if (ctx.hasUI) {
754
+ ctx.ui.notify(`Health check complete: ${systemHealth.status}.`, 'info');
755
+ }
756
+ } catch (error: unknown) {
757
+ if (ctx.hasUI) {
758
+ ctx.ui.notify(`Health check failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
759
+ }
760
+ }
761
+ }
762
+
763
+ async function showKnowledgeStatusAction(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
764
+ try {
765
+ const cwd = ctx.cwd || process.cwd();
766
+ const container = tryGetServiceContainerFromCtx(ctx);
767
+ // Reset config to ensure we read the latest from file (in case TUI changed it)
768
+ resetConfig();
769
+ const config = getConfig(cwd);
770
+
771
+ const service = await getService<KnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
772
+ const store = await service.getStore();
773
+ if (!store) {
774
+ throw new Error('Knowledge store is disabled or not initialized');
775
+ }
776
+ const counts = await store.countScoped(cwd);
777
+ const dbDir = getDbDir(config, cwd);
778
+
779
+ pi.sendMessage({
780
+ customType: 'knowledge-status',
781
+ content: `## Knowledge Store\n\n- Status: Operational\n- Project Entries: ${counts.local}\n- User Entries: ${counts.global}\n- Total Projects: ${counts.projects}\n- Model: ${config.EMBEDDING_MODEL}\n- Device: ${config.EMBEDDING_DEVICE}\n- Path: \`${dbDir}\``,
782
+ display: true,
783
+ });
784
+ } catch (error: unknown) {
785
+ if (ctx.hasUI) {
786
+ ctx.ui.notify(`Failed to get knowledge status: ${error instanceof Error ? error.message : String(error)}`, 'error');
787
+ }
788
+ }
789
+ }
790
+
791
+ async function showMetricsAction(_ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
792
+ const runHistory = metrics.getRunHistory();
793
+ const sessionStart = metrics.getSessionStartedAt();
794
+
795
+ const lines: string[] = ['## Session Metrics', ''];
796
+ lines.push(`Session started ${formatTimeAgo(new Date(sessionStart).toISOString())}`);
797
+ lines.push('');
798
+
799
+ // ── Session Overview ──────────────────────────────────────────────────
800
+ const sessionStats = aggregateSessionStats(runHistory);
801
+ sessionStats.sessionStartedAt = sessionStart;
802
+
803
+ if (runHistory.length === 0) {
804
+ lines.push('_No research runs in this session._');
805
+ } else {
806
+ lines.push(buildSessionOverview(sessionStats));
807
+ lines.push('');
808
+
809
+ // ── Last Run Detail ──────────────────────────────────────────────────
810
+ const lastRun = runHistory[runHistory.length - 1]!;
811
+ const lastStats = extractRunStats(lastRun.snapshot);
812
+
813
+ lines.push(`### Last Run`);
814
+ const statusIcon = lastRun.status === 'success' ? '[ok]'
815
+ : lastRun.status === 'cancelled' ? '[cancelled]'
816
+ : '[failed]';
817
+ lines.push(`${statusIcon} \`${lastRun.runId.slice(0, 8)}\` — ${formatDuration(lastRun.durationMs)} — completed ${formatTimeAgo(new Date(lastRun.completedAt).toISOString())}`);
818
+ lines.push('');
819
+
820
+ if (lastStats) {
821
+ const activityParts: string[] = [];
822
+ if (lastStats.researchersLaunched > 0) activityParts.push(`**${lastStats.researchersLaunched}** researchers`);
823
+ if (lastStats.roundsCompleted > 0) activityParts.push(`**${lastStats.roundsCompleted}** rounds`);
824
+ if (activityParts.length > 0) lines.push(activityParts.join(' · '));
825
+
826
+ const discoveryParts: string[] = [];
827
+ if (lastStats.searchQueries > 0) discoveryParts.push(`**${lastStats.searchQueries}** searches`);
828
+ if (lastStats.urlsDiscovered > 0) discoveryParts.push(`**${lastStats.urlsDiscovered}** discovered`);
829
+ if (lastStats.urlsAnalyzed > 0) {
830
+ const layerParts: string[] = [`${lastStats.urlsAnalyzed} analyzed`];
831
+ if (lastStats.fetchSuccess > 0 && lastStats.browserSuccess > 0) {
832
+ layerParts.push(`${lastStats.fetchSuccess} fetch`);
833
+ layerParts.push(`${lastStats.browserSuccess} browser`);
834
+ }
835
+ discoveryParts.push(`**${layerParts.join(', ')}**`);
836
+ }
837
+ if (discoveryParts.length > 0) lines.push(discoveryParts.join(' · '));
838
+
839
+ const toolParts: string[] = [];
840
+ if (lastStats.toolUsage.searches > 0) toolParts.push(`${lastStats.toolUsage.searches} searches`);
841
+ if (lastStats.toolUsage.scrapes > 0) toolParts.push(`${lastStats.toolUsage.scrapes} scrapes`);
842
+ if (lastStats.toolUsage.securitySearches > 0) toolParts.push(`${lastStats.toolUsage.securitySearches} security`);
843
+ if (lastStats.toolUsage.stackexchangeQueries > 0) toolParts.push(`${lastStats.toolUsage.stackexchangeQueries} StackExchange`);
844
+ if (lastStats.toolUsage.knowledgeLookups > 0) toolParts.push(`${lastStats.toolUsage.knowledgeLookups} knowledge`);
845
+ if (toolParts.length > 0) lines.push(`Tools: ${toolParts.join(' · ')}`);
846
+
847
+ const resourceParts: string[] = [];
848
+ if (lastStats.tokens > 0) resourceParts.push(`**${lastStats.tokens.toLocaleString('en-US')}** tokens`);
849
+ if (lastStats.urlsFailed > 0) resourceParts.push(`**${lastStats.urlsFailed}** failed`);
850
+ if (lastStats.errors > 0) resourceParts.push(`**${lastStats.errors}** errors`);
851
+ if (resourceParts.length > 0) lines.push(resourceParts.join(' · '));
852
+
853
+ lines.push('');
854
+ }
855
+
856
+ // ── Prior Runs ─────────────────────────────────────────────────────
857
+ if (runHistory.length > 1) {
858
+ const prior = runHistory.slice(0, -1).slice().reverse();
859
+ lines.push(`### Prior Runs`);
860
+ for (const run of prior) {
861
+ lines.push(buildRunCompactLine(run as any));
862
+ }
863
+ lines.push('');
864
+ }
865
+ }
866
+
867
+ pi.sendMessage({
868
+ customType: 'metrics-result',
869
+ content: lines.join('\n'),
870
+ display: true,
871
+ });
872
+ }