@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.
- package/LICENSE +1 -1
- package/README.md +55 -138
- package/dist/cli.mjs +23375 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +130 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +211 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +122 -0
- package/docs/THIRD-PARTY-NOTICES.md +66 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1195 -0
- package/src/config.ts +1032 -155
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +258 -11
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -212
- package/src/infrastructure/searxng-lifecycle.ts +0 -644
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -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
|
+
}
|