Nicholai a7494397f2
docs(all): comprehensive documentation overhaul (#57)
Restructure docs/ into architecture/, modules/, and
development/ directories. Add thorough documentation
for Compass Core platform and HPS Compass modules.
Rewrite CLAUDE.md as a lean quick-reference that
points to the full docs. Rename files to lowercase,
consolidate old docs, add gotchas section.

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-07 19:17:37 -07:00

302 lines
12 KiB
Markdown
Executable File

Plugin and Skills System
===
Compass's AI agent can be extended with plugins and skills. The two terms describe different levels of integration: skills are lightweight prompt injections loaded from GitHub, while full plugins can contribute tools, components, query types, and action handlers.
The system lives in `src/lib/agent/plugins/` with four core files: types, skills-client, loader, and registry.
Skills vs plugins
---
A **skill** is a SKILL.md file hosted on GitHub (following the skills.sh format). When installed, the markdown body gets injected into the agent's system prompt at priority 80. That's it - skills don't run code, they just add knowledge and instructions to the agent. Think of them as specialized system prompt modules.
A **plugin** is a full TypeScript module that exports a manifest and optional tools, prompt sections, components, query types, and action handlers. Plugins can actually extend the agent's capabilities with new tool calls and UI components.
The tradeoff is clear: skills are safe and easy to install (just markdown text), while plugins require more trust since they execute code.
Source types
---
Four source types are defined in `src/lib/agent/plugins/types.ts`:
```typescript
export const PLUGIN_SOURCE_TYPES = [
"builtin", // bundled with Compass
"local", // loaded from local filesystem
"npm", // installed from npm
"skills", // GitHub-hosted SKILL.md files
] as const
```
Currently, `builtin` and `skills` are the only fully implemented source types. The `local` and `npm` loaders return "not yet supported" errors - they're infrastructure for future expansion.
How skills work
---
### Installation flow
When a user asks the agent to install a skill, the `installSkill` tool triggers this sequence:
1. The source string (e.g., `"owner/repo"` or `"owner/repo/skill-name"`) is parsed into GitHub coordinates
2. `fetchSkillFromGitHub()` tries multiple URL patterns against raw.githubusercontent.com:
- `owner/repo/main/SKILL.md`
- `owner/repo/main/skills/SKILL.md`
- `owner/repo/main/<path>/SKILL.md`
- `owner/repo/main/skills/<path>/SKILL.md`
3. The fetched markdown is parsed by `parseSkillMd()`, which extracts YAML frontmatter and body
4. The skill is saved to the database as a plugin record with `sourceType: "skills"`
5. The markdown body is stored in the `plugin_config` table under the key `"content"`
6. The registry cache is cleared so the next request picks up the new skill
### SKILL.md format
A SKILL.md file has YAML frontmatter followed by a markdown body:
```markdown
---
name: "My Skill"
description: "What this skill teaches the agent"
allowedTools: "queryData, navigateTo"
userInvocable: true
---
The actual prompt content that gets injected into
the agent's system prompt goes here.
```
The frontmatter fields:
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Display name for the skill |
| `description` | no | Brief description |
| `allowedTools` | no | Comma-separated list of tools this skill expects to use |
| `userInvocable` | no | Whether users can invoke this skill directly |
The parser (`parseSkillMd`) is intentionally lenient with YAML - it handles both quoted and unquoted values, normalizes key casing, and stores unknown frontmatter keys in a metadata bag.
### Prompt injection at priority 80
When the registry builds, skills become `PluginModule` objects with a single prompt section:
```typescript
const mod: PluginModule = {
manifest: { /* ... */ },
promptSections: [{
heading: row.name,
content: configRow.value, // the SKILL.md body
priority: 80,
}],
}
```
Priority 80 means skills inject after the core system prompt (which uses lower priorities) but before any high-priority overrides. The registry's `getPromptSections()` sorts all sections by priority, so skills slot into a predictable position.
Plugin architecture
---
### Capabilities
Plugins declare what they contribute:
```typescript
export const PLUGIN_CAPABILITIES = [
"tools", // new tool calls for the agent
"actions", // client-side action handlers
"components", // UI components for dynamic rendering
"prompt", // system prompt sections
"queries", // new query types for the queryData tool
] as const
```
### PluginManifest
Every plugin declares a manifest:
```typescript
export interface PluginManifest {
readonly id: string // kebab-case identifier
readonly name: string
readonly description: string
readonly version: SemVer // branded string type, validated by isSemVer()
readonly capabilities: ReadonlyArray<PluginCapability>
readonly requiredEnvVars?: ReadonlyArray<string>
readonly optionalEnvVars?: ReadonlyArray<string>
readonly dependencies?: ReadonlyArray<string>
readonly author?: string
}
```
The loader validates manifests strictly: `id` must be kebab-case, `version` must be valid semver, and capabilities must be from the known set. Missing required env vars cause the plugin to be silently skipped during registry build (not errored) - this prevents a misconfigured plugin from breaking the entire agent.
### PluginModule
The runtime shape a full plugin exports:
```typescript
export interface PluginModule {
readonly manifest: PluginManifest
readonly tools?: Readonly<Record<string, unknown>>
readonly promptSections?: ReadonlyArray<PromptSection>
readonly components?: ReadonlyArray<PluginComponent>
readonly queryTypes?: ReadonlyArray<PluginQueryType>
readonly actionHandlers?: ReadonlyArray<PluginActionHandler>
readonly onEnable?: (ctx: PluginContext) => Promise<PluginResult>
readonly onDisable?: (ctx: PluginContext) => Promise<void>
}
```
Lifecycle hooks (`onEnable`/`onDisable`) receive a context with the database, environment variables, and current user ID. The `onEnable` hook returns a `PluginResult` so it can report failures.
Plugin registry
---
The registry (`src/lib/agent/plugins/registry.ts`) aggregates all enabled plugins into a single interface:
```typescript
interface PluginRegistry {
readonly plugins: ReadonlyMap<string, PluginModule>
getTools(): Readonly<Record<string, unknown>>
getPromptSections(): ReadonlyArray<PromptSection>
getComponents(): ReadonlyArray<PluginComponent>
getQueryTypes(): ReadonlyArray<PluginQueryType>
getActionHandlers(): ReadonlyArray<PluginActionHandler>
}
```
### Build process
`buildRegistry()` queries all enabled plugin rows from the database, then for each:
1. **Skills**: loads the `"content"` config value and wraps it as a PluginModule with a prompt section at priority 80
2. **Builtin/local/npm**: calls `loadPluginModule()` which validates the manifest and checks required env vars
The result is a `Map<string, PluginModule>` wrapped in a registry object that provides accessor methods. These methods merge contributions from all plugins - `getTools()` combines all tool definitions, `getPromptSections()` collects and sorts all prompt injections, etc.
### 30-second TTL cache
The registry is cached per worker isolate with a 30-second TTL:
```typescript
let cached: {
readonly registry: PluginRegistry
readonly expiresAt: number
} | null = null
const TTL_MS = 30_000
export async function getRegistry(
db: DbClient,
env: Readonly<Record<string, string>>,
): Promise<PluginRegistry> {
const now = Date.now()
if (cached && now < cached.expiresAt) {
return cached.registry
}
const registry = await buildRegistry(db, env)
cached = { registry, expiresAt: now + TTL_MS }
return registry
}
```
This means installing or toggling a skill takes effect within 30 seconds, or immediately if `clearRegistryCache()` is called (which all the skill management actions do). The cache is per-isolate, so different Workers instances have independent caches.
Agent tools for skill management
---
Four tools in `src/lib/agent/tools.ts` let the AI agent manage skills:
### installSkill
Installs a skill from GitHub. Takes a `source` string in `owner/repo` or `owner/repo/skill-name` format. Requires admin role. The tool description instructs the agent to always confirm with the user before installing.
### listInstalledSkills
Lists all installed skills with their status, source, and a content preview (first 200 characters of the prompt body).
### toggleInstalledSkill
Enables or disables an installed skill. Takes a `pluginId` and `enabled` boolean. Requires admin role.
### uninstallSkill
Permanently removes a skill. Deletes the plugin record, its config entries, and all event log entries. Requires admin role. The tool description instructs the agent to always confirm before uninstalling.
All four tools gate on `user.role !== "admin"` and return error messages for non-admin users.
Database tables
---
Three tables in `src/db/schema-plugins.ts`:
### plugins
The main registry of installed plugins and skills.
| Column | Type | Description |
|--------|------|-------------|
| `id` | text (PK) | For skills: `"skill-"` + kebab-cased source path |
| `name` | text | Display name from manifest or SKILL.md frontmatter |
| `description` | text | Optional description |
| `version` | text | Semver string |
| `source` | text | Source identifier (GitHub path for skills, package name for npm) |
| `source_type` | text | One of: builtin, local, npm, skills |
| `capabilities` | text | Comma-separated capability list |
| `required_env_vars` | text | Comma-separated env var names (optional) |
| `status` | text | `enabled`, `disabled`, or `error`. Defaults to `disabled`. |
| `status_reason` | text | Explanation for error status (optional) |
| `enabled_by` | text (FK -> users) | Who enabled this plugin |
| `enabled_at` | text | When it was enabled |
| `installed_at` | text | When it was installed |
| `updated_at` | text | Last modification |
### plugin_config
Key-value configuration per plugin. For skills, this stores the SKILL.md body under the key `"content"` and optionally the allowed tools under `"allowedTools"`.
| Column | Type | Description |
|--------|------|-------------|
| `id` | text (PK) | UUID |
| `plugin_id` | text (FK -> plugins, cascade) | Parent plugin |
| `key` | text | Config key |
| `value` | text | Config value |
| `is_encrypted` | integer (boolean) | Whether the value is encrypted. Defaults to false. |
| `updated_at` | text | Last modification |
### plugin_events
Audit log for plugin lifecycle events.
| Column | Type | Description |
|--------|------|-------------|
| `id` | text (PK) | UUID |
| `plugin_id` | text (FK -> plugins, cascade) | Related plugin |
| `event_type` | text | One of: installed, enabled, disabled, configured, error |
| `details` | text | Human-readable description (e.g., "installed from owner/repo by user@email.com") |
| `user_id` | text (FK -> users) | Who triggered the event |
| `created_at` | text | When it happened |
Events cascade-delete with their parent plugin, so uninstalling a skill cleans up its entire audit trail.
Server actions
---
Five actions in `src/app/actions/plugins.ts`:
- `installSkill(source)` - fetches from GitHub, creates plugin + config + event records, clears cache
- `uninstallSkill(pluginId)` - deletes events, config, and plugin records (in that order), clears cache
- `toggleSkill(pluginId, enabled)` - updates status, logs event, clears cache
- `getInstalledSkills()` - lists all skills-type plugins with content previews
The `skillId()` helper converts a GitHub source path to a stable ID: `"owner/repo/name"` becomes `"skill-owner-repo-name"`. This ensures the same source always maps to the same plugin ID, preventing duplicate installs.
All actions follow the standard pattern: auth check, discriminated union return, `clearRegistryCache()` after mutations.