@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
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Skill installer / uninstaller
3
+ *
4
+ * Installs the bundled `research` Agent Skill into the skills directories of the
5
+ * coding-agent harnesses present on this machine, by symlink (default) or copy.
6
+ * The skill is pi-research's only cross-harness surface — every supported target
7
+ * uses the same agentskills.io SKILL.md directory model, so a single symlink per
8
+ * harness is all it takes; harnesses that integrate differently (e.g. via MCP
9
+ * extensions) are intentionally NOT targeted here.
10
+ *
11
+ * Everything created is recorded in a manifest (~/.pi/research/installed-skills.json)
12
+ * so uninstall removes EXACTLY what install created — and only if the target on
13
+ * disk is still ours (a dangling/owned symlink, or a copy carrying our marker).
14
+ * A foreign `research/` directory is never clobbered or deleted.
15
+ *
16
+ * Target-path confidence (see HARNESSES): 'confirmed' paths are documented by the
17
+ * harness; 'partial'/'unverified' paths come from community/spec sources and are
18
+ * only written on explicit opt-in (callers must pass them deliberately).
19
+ */
20
+
21
+ import * as fs from 'node:fs';
22
+ import * as os from 'node:os';
23
+ import * as path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ export type HarnessConfidence = 'confirmed' | 'partial' | 'unverified';
27
+
28
+ export interface HarnessDef {
29
+ /** Stable id used on the CLI (e.g. "claude"). */
30
+ id: string;
31
+ /** Human label. */
32
+ label: string;
33
+ /** Base config dir under $HOME used for presence detection (e.g. ".claude"). */
34
+ baseDir: string;
35
+ /** Skills dir under $HOME the skill is linked into (e.g. ".claude/skills"). */
36
+ skillsDir: string;
37
+ /** How sure we are the skillsDir path is correct for this harness. */
38
+ confidence: HarnessConfidence;
39
+ /** One-line note shown for non-confirmed targets. */
40
+ note?: string;
41
+ }
42
+
43
+ /**
44
+ * Supported targets. ONLY the skills-directory (SKILL.md) integration model.
45
+ * Paths are relative to the user's home directory and resolved per-call so tests
46
+ * can override $HOME.
47
+ */
48
+ export const HARNESSES: readonly HarnessDef[] = [
49
+ { id: 'claude', label: 'Claude', baseDir: '.claude', skillsDir: path.join('.claude', 'skills'), confidence: 'confirmed' },
50
+ { id: 'openclaw', label: 'OpenClaw', baseDir: '.openclaw', skillsDir: path.join('.openclaw', 'skills'), confidence: 'confirmed', note: 'OpenClaw reads ~/.openclaw/skills as a managed skill root and accepts symlinked skill folders (docs.openclaw.ai/tools/skills).' },
51
+ { id: 'pi', label: 'pi', baseDir: '.pi', skillsDir: path.join('.pi', 'skills'), confidence: 'confirmed' },
52
+ { id: 'cursor', label: 'Cursor', baseDir: '.cursor', skillsDir: path.join('.cursor', 'skills'), confidence: 'partial', note: 'Cursor project-level skills are documented; the global ~/.cursor/skills path is community-reported.' },
53
+ { id: 'codex', label: 'OpenAI Codex CLI', baseDir: '.codex', skillsDir: path.join('.codex', 'skills'), confidence: 'unverified', note: 'Codex skills support is emerging; path not confirmed by official docs.' },
54
+ { id: 'agents', label: 'Cross-tool (~/.agents/skills)', baseDir: '.agents', skillsDir: path.join('.agents', 'skills'), confidence: 'unverified', note: 'Proposed Universal Agents convention; not yet a ratified standard.' },
55
+ ] as const;
56
+
57
+ const SKILL_NAME = 'pi-research';
58
+ const PACKAGE_NAME = '@lincoln504/pi-research';
59
+
60
+ /**
61
+ * The coding agents the in-app installer (the /research-config TUI) targets:
62
+ * Claude, Codex, and OpenClaw. Each loads skills from a home-directory
63
+ * `~/.<agent>/skills/` path — OpenClaw reads `~/.openclaw/skills` as a managed
64
+ * skill root that accepts symlinked skill folders — so a single global symlink
65
+ * works. Every target is gated on the agent's config dir already existing (see
66
+ * skillInstallCandidates), so a tool that isn't set up is never offered and we
67
+ * never create its home dir.
68
+ *
69
+ * Cursor is deliberately excluded: it has no personal/global skills directory —
70
+ * Cursor only loads skills from a project-level `.cursor/skills/`, so a global
71
+ * `~/.cursor/skills` symlink would never be read. `pi` (the host itself) and the
72
+ * speculative `~/.agents` convention are likewise excluded from the one-click flow.
73
+ */
74
+ export const SKILL_AGENT_TARGETS = ['claude', 'codex', 'openclaw'] as const;
75
+
76
+ export interface ManifestEntry {
77
+ tool: string;
78
+ /** Absolute path of the installed skill directory (…/skills/pi-research). */
79
+ path: string;
80
+ type: 'symlink' | 'copy';
81
+ /** Absolute path of the skill source this was linked/copied from. */
82
+ source: string;
83
+ createdAt: string;
84
+ }
85
+
86
+ interface Manifest {
87
+ version: 1;
88
+ package: string;
89
+ entries: ManifestEntry[];
90
+ }
91
+
92
+ export interface InstallOptions {
93
+ /** Override the home directory (for tests). */
94
+ home?: string;
95
+ /** Copy instead of symlink. */
96
+ copy?: boolean;
97
+ /** Plan only — do not touch the filesystem. */
98
+ dryRun?: boolean;
99
+ /** Explicit skill source dir (else auto-resolved). */
100
+ skillSourceDir?: string;
101
+ }
102
+
103
+ export type InstallStatus = 'installed' | 'already-installed' | 'skipped-foreign' | 'planned' | 'error';
104
+
105
+ export interface InstallResult {
106
+ tool: string;
107
+ path: string;
108
+ type: 'symlink' | 'copy';
109
+ status: InstallStatus;
110
+ message?: string;
111
+ }
112
+
113
+ export type UninstallStatus = 'removed' | 'not-present' | 'skipped-foreign' | 'planned' | 'error';
114
+
115
+ export interface UninstallResult {
116
+ tool: string;
117
+ path: string;
118
+ status: UninstallStatus;
119
+ message?: string;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Path resolution
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function homeDir(opts?: { home?: string }): string {
127
+ return opts?.home ?? os.homedir();
128
+ }
129
+
130
+ /**
131
+ * Locate the bundled skill source directory (the one containing SKILL.md).
132
+ * Robust across both the bundled CLI (dist/cli.mjs) and unbundled test runs:
133
+ * honors PI_RESEARCH_SKILL_DIR, then walks up from this module and from cwd
134
+ * looking for `skills/pi-research/SKILL.md`.
135
+ */
136
+ export function resolveSkillSourceDir(explicit?: string): string {
137
+ const candidates: string[] = [];
138
+ if (explicit) candidates.push(explicit);
139
+ if (process.env['PI_RESEARCH_SKILL_DIR']) candidates.push(process.env['PI_RESEARCH_SKILL_DIR']!);
140
+
141
+ const fromHere = (() => {
142
+ try { return path.dirname(fileURLToPath(import.meta.url)); } catch { return undefined; }
143
+ })();
144
+
145
+ for (const start of [fromHere, process.cwd()]) {
146
+ if (!start) continue;
147
+ let dir = start;
148
+ for (let i = 0; i < 8; i++) {
149
+ candidates.push(path.join(dir, 'skills', SKILL_NAME));
150
+ const parent = path.dirname(dir);
151
+ if (parent === dir) break;
152
+ dir = parent;
153
+ }
154
+ }
155
+
156
+ for (const c of candidates) {
157
+ try {
158
+ if (fs.existsSync(path.join(c, 'SKILL.md'))) return path.resolve(c);
159
+ } catch { /* next */ }
160
+ }
161
+ throw new Error('Could not locate the bundled research skill (skills/pi-research/SKILL.md). Set PI_RESEARCH_SKILL_DIR to its path.');
162
+ }
163
+
164
+ export function getManifestPath(opts?: { home?: string }): string {
165
+ return path.join(homeDir(opts), '.pi', 'research', 'installed-skills.json');
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Manifest
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export function readManifest(opts?: { home?: string }): Manifest {
173
+ const p = getManifestPath(opts);
174
+ try {
175
+ if (fs.existsSync(p)) {
176
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
177
+ if (parsed && Array.isArray(parsed.entries)) {
178
+ return { version: 1, package: PACKAGE_NAME, entries: parsed.entries };
179
+ }
180
+ }
181
+ } catch { /* corrupt manifest → treat as empty */ }
182
+ return { version: 1, package: PACKAGE_NAME, entries: [] };
183
+ }
184
+
185
+ function writeManifest(manifest: Manifest, opts?: { home?: string }): void {
186
+ const p = getManifestPath(opts);
187
+ fs.mkdirSync(path.dirname(p), { recursive: true });
188
+ const tmp = `${p}.tmp`;
189
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
190
+ fs.renameSync(tmp, p);
191
+ }
192
+
193
+ function upsertEntry(manifest: Manifest, entry: ManifestEntry): void {
194
+ const idx = manifest.entries.findIndex(e => e.path === entry.path);
195
+ if (idx >= 0) manifest.entries[idx] = entry;
196
+ else manifest.entries.push(entry);
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Ownership checks (never delete what isn't ours)
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /** A symlink we own points at the skill source (our package's skills/pi-research). */
204
+ function isOwnedSymlink(targetPath: string, expectedSource?: string): boolean {
205
+ try {
206
+ const st = fs.lstatSync(targetPath);
207
+ if (!st.isSymbolicLink()) return false;
208
+ const dest = path.resolve(path.dirname(targetPath), fs.readlinkSync(targetPath));
209
+ if (expectedSource && path.resolve(expectedSource) === dest) return true;
210
+ // Fallback: own it only if it points at OUR package layout, i.e. the dest
211
+ // ends in `pi-research/skills/pi-research`. Requiring `pi-research` to be the
212
+ // immediate parent segment of `skills/pi-research` avoids the substring false
213
+ // positive where a foreign path like `/home/pi-researcher/.../skills/pi-research`
214
+ // (note: pi-researcher) would otherwise be misclassified as owned and deleted.
215
+ return /[/\\]pi-research[/\\]skills[/\\]pi-research$/.test(dest);
216
+ } catch { return false; }
217
+ }
218
+
219
+ /** A copy we own carries our package marker in its SKILL.md. */
220
+ function isOwnedCopy(targetPath: string): boolean {
221
+ try {
222
+ const md = fs.readFileSync(path.join(targetPath, 'SKILL.md'), 'utf-8');
223
+ return md.includes(PACKAGE_NAME);
224
+ } catch { return false; }
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Detection
229
+ // ---------------------------------------------------------------------------
230
+
231
+ export interface DetectedHarness extends HarnessDef {
232
+ present: boolean;
233
+ /** Absolute skills dir. */
234
+ absSkillsDir: string;
235
+ /** Absolute …/skills/pi-research path. */
236
+ absSkillPath: string;
237
+ /** Current install state of THIS skill at that path. */
238
+ installed: 'none' | 'owned-symlink' | 'owned-copy' | 'foreign';
239
+ }
240
+
241
+ export function detectHarnesses(opts?: { home?: string }): DetectedHarness[] {
242
+ const home = homeDir(opts);
243
+ return HARNESSES.map(h => {
244
+ const absSkillsDir = path.join(home, h.skillsDir);
245
+ const absSkillPath = path.join(absSkillsDir, SKILL_NAME);
246
+ let present = false;
247
+ try { present = fs.existsSync(path.join(home, h.baseDir)); } catch { /* no */ }
248
+ let installed: DetectedHarness['installed'] = 'none';
249
+ try {
250
+ if (fs.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
251
+ if (isOwnedSymlink(absSkillPath)) installed = 'owned-symlink';
252
+ else if (isOwnedCopy(absSkillPath)) installed = 'owned-copy';
253
+ else installed = 'foreign';
254
+ }
255
+ } catch { /* none */ }
256
+ return { ...h, present, absSkillsDir, absSkillPath, installed };
257
+ });
258
+ }
259
+
260
+ function isSymlinkPresent(p: string): boolean {
261
+ try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; }
262
+ }
263
+
264
+ const isTarget = (id: string): boolean =>
265
+ (SKILL_AGENT_TARGETS as readonly string[]).includes(id);
266
+
267
+ /**
268
+ * Install candidates: target agents (Claude, Codex) whose ROOT config folder
269
+ * already exists. Gate for the in-app installer — we never create an agent's
270
+ * home dir, so an agent that isn't set up is simply not offered.
271
+ */
272
+ export function skillInstallCandidates(opts?: { home?: string }): DetectedHarness[] {
273
+ return detectHarnesses(opts).filter(d => isTarget(d.id) && d.present);
274
+ }
275
+
276
+ /**
277
+ * Uninstall candidates: target agents where the skill is actually installed
278
+ * (an owned symlink/copy on disk) — a deeper-level gate than install. Foreign
279
+ * skills and not-installed agents are excluded.
280
+ */
281
+ export function skillUninstallCandidates(opts?: { home?: string }): DetectedHarness[] {
282
+ return detectHarnesses(opts).filter(d =>
283
+ isTarget(d.id) && (d.installed === 'owned-symlink' || d.installed === 'owned-copy'));
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Install
288
+ // ---------------------------------------------------------------------------
289
+
290
+ export function installSkill(toolIds: string[], opts: InstallOptions = {}): InstallResult[] {
291
+ const home = homeDir(opts);
292
+ const source = resolveSkillSourceDir(opts.skillSourceDir);
293
+ const manifest = readManifest(opts);
294
+ const results: InstallResult[] = [];
295
+ const now = new Date().toISOString();
296
+
297
+ for (const id of toolIds) {
298
+ const def = HARNESSES.find(h => h.id === id);
299
+ if (!def) {
300
+ results.push({ tool: id, path: '', type: 'symlink', status: 'error', message: `unknown target "${id}"` });
301
+ continue;
302
+ }
303
+ const absSkillsDir = path.join(home, def.skillsDir);
304
+ const absSkillPath = path.join(absSkillsDir, SKILL_NAME);
305
+ const type: 'symlink' | 'copy' = opts.copy ? 'copy' : 'symlink';
306
+
307
+ // Already there?
308
+ if (isOwnedSymlink(absSkillPath, source) || isOwnedCopy(absSkillPath)) {
309
+ // An owned symlink may point at a STALE source — e.g. after `pi update`
310
+ // relocated the package to a new (version-stamped) dir, the old link now
311
+ // dangles and the skill is silently broken. Re-point it to the current
312
+ // source. (Copies always reflect current content, so only links need this.)
313
+ try {
314
+ const lst = fs.lstatSync(absSkillPath);
315
+ if (lst.isSymbolicLink()) {
316
+ const dest = path.resolve(path.dirname(absSkillPath), fs.readlinkSync(absSkillPath));
317
+ if (dest !== path.resolve(source)) {
318
+ fs.unlinkSync(absSkillPath);
319
+ fs.symlinkSync(source, absSkillPath, process.platform === 'win32' ? 'junction' : 'dir');
320
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type: 'symlink', source, createdAt: now });
321
+ results.push({ tool: id, path: absSkillPath, type: 'symlink', status: 'installed', message: 're-pointed stale symlink to current source' });
322
+ continue;
323
+ }
324
+ }
325
+ } catch { /* fall through to already-installed below */ }
326
+ results.push({ tool: id, path: absSkillPath, type, status: 'already-installed' });
327
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type: isSymlinkPresent(absSkillPath) ? 'symlink' : 'copy', source, createdAt: now });
328
+ continue;
329
+ }
330
+ if (fs.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
331
+ results.push({ tool: id, path: absSkillPath, type, status: 'skipped-foreign', message: 'a non-pi-research "research" skill already exists here; left untouched' });
332
+ continue;
333
+ }
334
+ if (opts.dryRun) {
335
+ results.push({ tool: id, path: absSkillPath, type, status: 'planned' });
336
+ continue;
337
+ }
338
+
339
+ try {
340
+ fs.mkdirSync(absSkillsDir, { recursive: true });
341
+ if (type === 'symlink') {
342
+ try {
343
+ fs.symlinkSync(source, absSkillPath, process.platform === 'win32' ? 'junction' : 'dir');
344
+ } catch (err: any) {
345
+ // Windows without privilege, or cross-device: fall back to copy.
346
+ copyDir(source, absSkillPath);
347
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type: 'copy', source, createdAt: now });
348
+ results.push({ tool: id, path: absSkillPath, type: 'copy', status: 'installed', message: `symlink unavailable (${err?.code ?? err?.message}); copied instead` });
349
+ continue;
350
+ }
351
+ } else {
352
+ copyDir(source, absSkillPath);
353
+ }
354
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type, source, createdAt: now });
355
+ results.push({ tool: id, path: absSkillPath, type, status: 'installed' });
356
+ } catch (err: any) {
357
+ results.push({ tool: id, path: absSkillPath, type, status: 'error', message: err?.message ?? String(err) });
358
+ }
359
+ }
360
+
361
+ if (!opts.dryRun) writeManifest(manifest, opts);
362
+ return results;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Uninstall
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /**
370
+ * Remove the skill from the given tools (or every manifest entry when toolIds is
371
+ * omitted). Only removes paths we still own; foreign dirs are left untouched.
372
+ */
373
+ export function uninstallSkill(toolIds?: string[], opts: InstallOptions = {}): UninstallResult[] {
374
+ const manifest = readManifest(opts);
375
+ const results: UninstallResult[] = [];
376
+ const keep: ManifestEntry[] = [];
377
+
378
+ // Union of manifest entries and any owned installs discovered on disk, so a
379
+ // skill installed by an older version (or hand-symlinked into our package) is
380
+ // still cleanable.
381
+ const targets = new Map<string, { tool: string; path: string }>();
382
+ for (const e of manifest.entries) targets.set(e.path, { tool: e.tool, path: e.path });
383
+ for (const d of detectHarnesses(opts)) {
384
+ if (d.installed === 'owned-symlink' || d.installed === 'owned-copy') {
385
+ targets.set(d.absSkillPath, { tool: d.id, path: d.absSkillPath });
386
+ }
387
+ }
388
+
389
+ for (const { tool, path: skillPath } of targets.values()) {
390
+ if (toolIds && toolIds.length && !toolIds.includes(tool)) {
391
+ const e = manifest.entries.find(x => x.path === skillPath);
392
+ if (e) keep.push(e);
393
+ continue;
394
+ }
395
+ const owned = isOwnedSymlink(skillPath) || isOwnedCopy(skillPath);
396
+ const present = fs.existsSync(skillPath) || isSymlinkPresent(skillPath);
397
+ if (!present) {
398
+ results.push({ tool, path: skillPath, status: 'not-present' });
399
+ continue; // drop from manifest
400
+ }
401
+ if (!owned) {
402
+ results.push({ tool, path: skillPath, status: 'skipped-foreign', message: 'not a pi-research skill; left untouched' });
403
+ const e = manifest.entries.find(x => x.path === skillPath);
404
+ if (e) keep.push(e);
405
+ continue;
406
+ }
407
+ if (opts.dryRun) {
408
+ results.push({ tool, path: skillPath, status: 'planned' });
409
+ const e = manifest.entries.find(x => x.path === skillPath);
410
+ if (e) keep.push(e);
411
+ continue;
412
+ }
413
+ try {
414
+ if (isSymlinkPresent(skillPath)) fs.unlinkSync(skillPath);
415
+ else fs.rmSync(skillPath, { recursive: true, force: true });
416
+ results.push({ tool, path: skillPath, status: 'removed' });
417
+ } catch (err: any) {
418
+ results.push({ tool, path: skillPath, status: 'error', message: err?.message ?? String(err) });
419
+ const e = manifest.entries.find(x => x.path === skillPath);
420
+ if (e) keep.push(e);
421
+ }
422
+ }
423
+
424
+ if (!opts.dryRun) {
425
+ if (keep.length > 0) {
426
+ writeManifest({ version: 1, package: PACKAGE_NAME, entries: keep }, opts);
427
+ } else {
428
+ // Nothing left to track — remove the manifest entirely (matches cleanup.cjs).
429
+ try { fs.rmSync(getManifestPath(opts)); } catch { /* already gone */ }
430
+ }
431
+ }
432
+ return results;
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Reconcile (self-heal on startup)
437
+ // ---------------------------------------------------------------------------
438
+
439
+ export interface ReconcileResult {
440
+ /** Dead owned symlinks removed (target package gone, no current source). */
441
+ pruned: string[];
442
+ /** Stale/dangling owned symlinks re-pointed to the current package source. */
443
+ repointed: string[];
444
+ }
445
+
446
+ /**
447
+ * Self-heal the cross-harness skill installs. Cheap, best-effort, idempotent —
448
+ * meant to run once on extension/CLI startup.
449
+ *
450
+ * Why this exists: the host removes a pi-research install by deleting its
451
+ * directory (git path) or via `npm uninstall` — and modern npm does NOT run the
452
+ * `preuninstall` lifecycle for a removed dependency, so `cleanup.cjs` never
453
+ * fires on uninstall. A skill symlink the user installed into another agent
454
+ * (~/.claude/skills, ~/.codex/skills) therefore dangles after the package is
455
+ * removed or relocated by an update. The next time ANY pi-research runs, this:
456
+ * - re-points an owned symlink whose target moved (update/reinstall) to the
457
+ * current source, so the skill keeps working without a manual re-install;
458
+ * - removes an owned symlink that now dangles with no resolvable source, and
459
+ * drops manifest entries whose link the user deleted by hand.
460
+ * Foreign (non-owned) paths and self-contained copies are never touched.
461
+ */
462
+ export function reconcileSkillInstalls(opts: InstallOptions = {}): ReconcileResult {
463
+ const result: ReconcileResult = { pruned: [], repointed: [] };
464
+ const manifest = readManifest(opts);
465
+ if (manifest.entries.length === 0) return result;
466
+
467
+ let source: string | null;
468
+ try { source = resolveSkillSourceDir(opts.skillSourceDir); } catch { source = null; }
469
+
470
+ const keep: ManifestEntry[] = [];
471
+ for (const e of manifest.entries) {
472
+ // Copies are self-contained (no target to dangle); leave them as-is.
473
+ if (e.type !== 'symlink') { keep.push(e); continue; }
474
+
475
+ // Link the user deleted by hand — nothing on disk; drop the stale entry.
476
+ if (!isSymlinkPresent(e.path)) { result.pruned.push(e.path); continue; }
477
+
478
+ // Only ever touch links we own; a foreign link at this path stays + tracked.
479
+ if (!isOwnedSymlink(e.path)) { keep.push(e); continue; }
480
+
481
+ const targetLive = fs.existsSync(e.path); // existsSync follows the link
482
+ let dest: string | null = null;
483
+ try { dest = path.resolve(path.dirname(e.path), fs.readlinkSync(e.path)); } catch { /* unreadable */ }
484
+
485
+ const stale = source !== null && dest !== null && dest !== path.resolve(source);
486
+ if (!targetLive || stale) {
487
+ if (source !== null) {
488
+ // Re-point to the current package source (handles update/reinstall).
489
+ try {
490
+ fs.unlinkSync(e.path);
491
+ fs.symlinkSync(source, e.path, process.platform === 'win32' ? 'junction' : 'dir');
492
+ keep.push({ ...e, type: 'symlink', source });
493
+ result.repointed.push(e.path);
494
+ } catch { keep.push(e); /* leave entry; retry next startup */ }
495
+ } else if (!targetLive) {
496
+ // Dangling with no resolvable source (package fully removed): remove the
497
+ // dead link and drop the entry so the agent's skills dir stays clean.
498
+ try { fs.unlinkSync(e.path); result.pruned.push(e.path); }
499
+ catch { keep.push(e); }
500
+ } else {
501
+ keep.push(e);
502
+ }
503
+ continue;
504
+ }
505
+ keep.push(e);
506
+ }
507
+
508
+ if (keep.length > 0) writeManifest({ version: 1, package: PACKAGE_NAME, entries: keep }, opts);
509
+ else { try { fs.rmSync(getManifestPath(opts)); } catch { /* already gone */ } }
510
+ return result;
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Helpers
515
+ // ---------------------------------------------------------------------------
516
+
517
+ function copyDir(src: string, dest: string): void {
518
+ fs.cpSync(src, dest, { recursive: true, dereference: true });
519
+ }