Skip to content

Commit 905fed6

Browse files
committed
v3.0(items #2 + #6): plugin contract v2 with mcpConfig auto-wrap + Serena reference plugin
ITEM #2 — Plugin contract v2 Extends ContextProviderPlugin so plugin authors can declare an MCP server via 'mcpConfig' and skip writing resolve()/isAvailable() by hand. The loader auto-wraps via createMcpProvider() from item #1. Classic plugins (custom resolve()) continue to work unchanged — if both fields are present, the author's resolve() wins (they opted into custom logic). Type changes (src/providers/types.ts): - ContextProviderPlugin stays strict (extends ContextProvider fully) — this is the POST-VALIDATION shape the resolver consumes - NEW: RawPluginShape — the pre-validation shape a plugin-file author writes in .mjs. tier/tokenBudget/timeoutMs/resolve/isAvailable all optional (loader fills from factory when mcpConfig present) Loader changes (src/providers/plugin-loader.ts): - validatePlugin() branches on 'has mcpConfig vs. has resolve()' - name/label/version always required - Classic path: tier/tokenBudget/timeoutMs/isAvailable required - mcpConfig path: config validated via validateProviderConfig(), merged with plugin fields (author overrides win over factory defaults) - One clear error per rejection — 'invalid mcpConfig: <reason>' tells you exactly which sub-field on which plugin is broken Tests (+7 cases in tests/providers/plugin-loader.test.ts): - mcpConfig-only plugin auto-wraps resolve + isAvailable - Plugin with neither resolve nor mcpConfig rejected (clear message) - Invalid mcpConfig rejected (bad command, bad http url) - Custom resolve wins over mcpConfig when both present - Plugin tokenBudget override wins over factory default - Missing version rejected even for mcpConfig plugins ITEM #6 — Serena plugin reference docs/plugins/examples/serena-plugin.mjs (~60 lines incl. docs) — the full Serena (oraios/serena) wrapper as an mcpConfig-only plugin. Install is cp + enable. Thanks to item #2, NO custom transport code needed. docs/plugins/examples/static-context-plugin.mjs — the classic-path reference showing a tier 1 plugin with hand-rolled resolve() for users who just want to inject a fixed string on every Read. docs/plugins/README.md — author-facing guide. Shape 1 (MCP-backed), Shape 2 (classic), template tokens, safety guarantees, debugging checklist, publishing notes. FULL SUITE 808 -> 815 tests (+7), all passing. TypeScript clean, lint clean. V3.0 PROGRESS Done: #1 foundation, #2, #6, #7, #9, #10, #11 = 7 of 12 scope items. Next: #3 budget-weighted resolver + mistakes-boost (~2-3d).
1 parent c719591 commit 905fed6

6 files changed

Lines changed: 475 additions & 30 deletions

File tree

docs/plugins/README.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# engramx Plugins
2+
3+
> A plugin is a single `.mjs` file in `~/.engram/plugins/` that adds a new
4+
> Context Spine provider to engramx. Two shapes are supported:
5+
6+
1. **MCP-backed** — declare an `mcpConfig` and the loader auto-wraps an
7+
MCP server of your choice. ~10 lines.
8+
2. **Classic** — write your own `resolve()` and `isAvailable()`. Full
9+
control over what goes into the context packet.
10+
11+
Both shapes live side-by-side in the same directory. Pick whichever fits.
12+
13+
---
14+
15+
## Install
16+
17+
1. Copy an example from `docs/plugins/examples/` to `~/.engram/plugins/`:
18+
19+
```bash
20+
cp docs/plugins/examples/serena-plugin.mjs ~/.engram/plugins/serena.mjs
21+
```
22+
23+
2. Verify it loaded:
24+
25+
```bash
26+
engram plugin list
27+
```
28+
29+
You should see your plugin listed with its name + label.
30+
31+
3. Trigger any file read in Claude Code. The plugin's contribution will
32+
appear in the rich context packet header (e.g. `SEMANTIC SYMBOLS
33+
(mcp:serena):`).
34+
35+
---
36+
37+
## Shape 1 — MCP-backed plugin
38+
39+
See `examples/serena-plugin.mjs` for the full example. The essence:
40+
41+
```javascript
42+
export default {
43+
name: "mcp:my-server",
44+
label: "MY CONTEXT",
45+
version: "0.1.0",
46+
mcpConfig: {
47+
transport: "stdio",
48+
command: "my-mcp-server",
49+
args: [],
50+
tools: [
51+
{ name: "get_context", args: { file: "{filePath}" } },
52+
],
53+
},
54+
};
55+
```
56+
57+
**Template tokens available in `tools[].args`:**
58+
59+
| Token | Value |
60+
|-------|-------|
61+
| `{filePath}` | Relative POSIX path (e.g. `src/auth/login.ts`) |
62+
| `{projectRoot}` | Absolute project root path |
63+
| `{imports}` | Comma-separated import names (`"jsonwebtoken,express"`) |
64+
| `{fileBasename}` | Basename only (e.g. `login.ts`) |
65+
66+
Unknown tokens pass through verbatim. Non-string values (`true`, `10`, …)
67+
pass through unchanged.
68+
69+
**Transports:** `stdio` ships in v3.0. `http` is declared but deferred
70+
until the SSE-streaming + Host/Origin hardening work lands (v3.0 item #5).
71+
72+
---
73+
74+
## Shape 2 — Classic plugin
75+
76+
See `examples/static-context-plugin.mjs`. Key fields:
77+
78+
| Field | Type | Notes |
79+
|-------|------|-------|
80+
| `name` | string | Unique identifier (no collision with built-ins) |
81+
| `label` | string | Section header in the context packet |
82+
| `version` | string | Semver |
83+
| `tier` | `1 \| 2` | 1 = internal (fast), 2 = external (cached). See `src/providers/types.ts`. |
84+
| `tokenBudget` | number | Max tokens this plugin may emit per Read |
85+
| `timeoutMs` | number | Per-resolve() timeout |
86+
| `resolve(filePath, context)` | async | Return a `ProviderResult` or `null` |
87+
| `isAvailable()` | async | Return `false` to silently skip this plugin |
88+
89+
`resolve()` must return `null` on any error path — it must NOT throw. A
90+
thrown error is swallowed by the resolver's Promise.allSettled, so your
91+
plugin just goes silently missing rather than breaking the session.
92+
93+
---
94+
95+
## Safety guarantees
96+
97+
A broken plugin CANNOT break engramx. The plugin loader:
98+
99+
1. Imports your file in a try/catch.
100+
2. Validates the shape (missing fields → skip with stderr warning).
101+
3. For `mcpConfig`, validates the MCP schema before auto-wrapping.
102+
4. Deduplicates names — first-loaded wins.
103+
5. Surfaces the list of loads + failures via `engram plugin list`.
104+
105+
A plugin that throws at import, fails shape validation, or has an invalid
106+
`mcpConfig` simply doesn't appear in the provider list. Other plugins and
107+
built-ins are unaffected.
108+
109+
---
110+
111+
## Debugging a plugin that "won't load"
112+
113+
1. `engram plugin list` — shows loaded + failed with one-line reason.
114+
2. For MCP-backed plugins, try the underlying command manually:
115+
```bash
116+
uvx --from git+https://github.com/oraios/serena serena start-mcp-server
117+
```
118+
If it fails here, engramx can't make it work either. Fix the upstream
119+
first, then re-test.
120+
3. Check `~/.engram/` exists and is writable (the loader creates
121+
`plugins/` on demand).
122+
4. Enable verbose logs: `ENGRAM_LOG=debug engram query "hello"` shows
123+
the full load trace.
124+
125+
---
126+
127+
## Publishing a plugin for others
128+
129+
Plugins are currently installed by copy-paste — there's no plugin
130+
registry yet. The recommended path:
131+
132+
1. Ship your plugin file in a public git repo with clear install notes
133+
(one README + one `.mjs`).
134+
2. Use a `mcp:your-tool-name` or `your-org:name` namespace to avoid
135+
collisions.
136+
3. Include a version bump policy in your README so users know when to
137+
update.
138+
139+
A first-party plugin registry is tracked as post-v3.0 work (dependent on
140+
Official MCP Registry verified-tier requirements solidifying).
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* engramx plugin: Serena — LSP-backed semantic code retrieval
3+
*
4+
* Serena (https://github.com/oraios/serena) is an open-source MCP server
5+
* that talks to language servers for 20+ languages and returns precise
6+
* symbol-level context — far more accurate than regex or tree-sitter
7+
* alone. This plugin wraps Serena as an engramx Context Spine provider.
8+
*
9+
* INSTALL
10+
* 1. Install Serena if you haven't:
11+
* https://github.com/oraios/serena#installation
12+
* The quickest path: `pipx install uv` (or uv's own installer),
13+
* which gives you `uvx` — the command below then fetches Serena
14+
* on-demand at first use.
15+
*
16+
* 2. Copy this file to ~/.engram/plugins/serena.mjs:
17+
* cp docs/plugins/examples/serena-plugin.mjs ~/.engram/plugins/serena.mjs
18+
*
19+
* 3. Verify it loaded:
20+
* engram plugin list
21+
* (you should see `mcp:serena SEMANTIC SYMBOLS (mcp-backed)`)
22+
*
23+
* HOW IT WORKS
24+
* The `mcpConfig` declaration below tells engramx's plugin loader to
25+
* auto-wrap Serena via createMcpProvider(). On every Read, engramx
26+
* calls `find_symbol` against Serena with the current file path,
27+
* receives back the symbol structure, and merges it into the rich
28+
* context packet. If Serena isn't running or the call times out, the
29+
* plugin goes dormant for 30 seconds before retry — engramx's built-in
30+
* AST miner covers the gap.
31+
*
32+
* TUNING
33+
* - tools: add more Serena tools to enrich context further. See
34+
* `uvx --from git+https://github.com/oraios/serena serena --list-tools`
35+
* for the full catalog.
36+
* - tokenBudget: Serena can be verbose. 250 tokens per Read is a
37+
* reasonable default for symbol-rich files; raise if you find its
38+
* output being truncated too aggressively.
39+
* - timeoutMs: cold-start for Serena's first request (per-language LSP
40+
* boot) is slow — keep ≥2s or you'll get zero results on the first
41+
* file of a session.
42+
*/
43+
export default {
44+
name: "mcp:serena",
45+
label: "SEMANTIC SYMBOLS",
46+
version: "0.1.0",
47+
description: "LSP-backed symbol retrieval via oraios/serena",
48+
author: "engramx community",
49+
tokenBudget: 250,
50+
timeoutMs: 2500,
51+
mcpConfig: {
52+
transport: "stdio",
53+
command: "uvx",
54+
args: [
55+
"--from",
56+
"git+https://github.com/oraios/serena",
57+
"serena",
58+
"start-mcp-server",
59+
],
60+
tools: [
61+
{
62+
name: "find_symbol",
63+
args: { name_path: "{fileBasename}" },
64+
confidence: 0.9,
65+
},
66+
],
67+
},
68+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* engramx plugin: static-context — inject a fixed block of text into
3+
* every Read.
4+
*
5+
* Trivial example of the CLASSIC plugin path (plugin writes its own
6+
* `resolve()` and `isAvailable()` — no mcpConfig involved). Useful for:
7+
* - Project-specific reminders you want on every file Read
8+
* - House-rule blocks that belong in the context, not CLAUDE.md
9+
* - Quick experiments before promoting to a real MCP-backed plugin
10+
*
11+
* Install: copy to `~/.engram/plugins/static-context.mjs` and edit
12+
* the `MESSAGE` constant.
13+
*/
14+
15+
const MESSAGE = `
16+
! Reminder: all DB migrations must pass on SQLite 3.35+ (the CI runner)
17+
! House style: feature branches named feat/<issue-number>-<slug>
18+
`.trim();
19+
20+
export default {
21+
name: "static-context",
22+
label: "PROJECT REMINDER",
23+
version: "0.1.0",
24+
description: "Always-on project reminder injected at every Read.",
25+
tier: 1,
26+
tokenBudget: 50,
27+
timeoutMs: 200,
28+
async resolve() {
29+
return {
30+
provider: "static-context",
31+
content: MESSAGE,
32+
confidence: 0.6,
33+
cached: false,
34+
};
35+
},
36+
async isAvailable() {
37+
return MESSAGE.length > 0;
38+
},
39+
};

src/providers/plugin-loader.ts

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import { existsSync, readdirSync, mkdirSync } from "node:fs";
1414
import { join } from "node:path";
1515
import { homedir } from "node:os";
1616
import { pathToFileURL } from "node:url";
17-
import type { ContextProviderPlugin } from "./types.js";
17+
import type { ContextProvider, ContextProviderPlugin, RawPluginShape } from "./types.js";
18+
import { validateProviderConfig, type McpProviderConfig } from "./mcp-config.js";
19+
import { createMcpProvider } from "./mcp-client.js";
1820

1921
/**
2022
* Resolve the plugins directory at call time, not module-load time.
@@ -48,6 +50,11 @@ export function ensurePluginsDir(dir?: string): void {
4850
/**
4951
* Validate a loaded module exports a ContextProviderPlugin-shaped object.
5052
* Returns null + reason if invalid, the plugin object if valid.
53+
*
54+
* v3.0 — a plugin may declare `mcpConfig` INSTEAD of writing its own
55+
* `resolve()` / `isAvailable()`. In that case the loader auto-wraps via
56+
* `createMcpProvider()` and fills in the ContextProvider contract from
57+
* the MCP factory. If BOTH are present, the custom `resolve()` wins.
5158
*/
5259
export function validatePlugin(mod: unknown): { plugin: ContextProviderPlugin | null; reason: string } {
5360
if (!mod || typeof mod !== "object") {
@@ -60,41 +67,91 @@ export function validatePlugin(mod: unknown): { plugin: ContextProviderPlugin |
6067
return { plugin: null, reason: "default export is not an object" };
6168
}
6269

63-
const p = candidate as Partial<ContextProviderPlugin>;
64-
const required: (keyof ContextProviderPlugin)[] = [
65-
"name",
66-
"label",
67-
"tier",
68-
"tokenBudget",
69-
"timeoutMs",
70-
"version",
71-
"resolve",
72-
"isAvailable",
73-
];
74-
75-
for (const field of required) {
76-
if (p[field] === undefined || p[field] === null) {
77-
return { plugin: null, reason: `missing required field: ${field}` };
78-
}
79-
}
70+
const p = candidate as Partial<RawPluginShape>;
8071

81-
if (typeof p.resolve !== "function") {
82-
return { plugin: null, reason: "resolve must be a function" };
72+
// Always-required identification fields
73+
if (typeof p.name !== "string" || p.name.length === 0) {
74+
return { plugin: null, reason: "name must be a non-empty string" };
8375
}
84-
if (typeof p.isAvailable !== "function") {
85-
return { plugin: null, reason: "isAvailable must be a function" };
76+
if (typeof p.label !== "string" || p.label.length === 0) {
77+
return { plugin: null, reason: `[${p.name}] label must be a non-empty string` };
8678
}
87-
if (p.tier !== 1 && p.tier !== 2) {
88-
return { plugin: null, reason: `tier must be 1 or 2 (got ${String(p.tier)})` };
79+
if (typeof p.version !== "string" || p.version.length === 0) {
80+
return { plugin: null, reason: `[${p.name}] version must be a non-empty string` };
8981
}
90-
if (typeof p.name !== "string" || p.name.length === 0) {
91-
return { plugin: null, reason: "name must be a non-empty string" };
82+
83+
const hasMcpConfig = p.mcpConfig !== undefined && p.mcpConfig !== null;
84+
const hasResolve = typeof p.resolve === "function";
85+
86+
if (!hasMcpConfig && !hasResolve) {
87+
return {
88+
plugin: null,
89+
reason: `[${p.name}] plugin needs either a resolve() function or an mcpConfig declaration`,
90+
};
91+
}
92+
93+
// Classic path — plugin wrote its own resolve/isAvailable
94+
if (hasResolve) {
95+
const classicRequired: (keyof RawPluginShape)[] = [
96+
"tier",
97+
"tokenBudget",
98+
"timeoutMs",
99+
"isAvailable",
100+
];
101+
for (const field of classicRequired) {
102+
if (p[field] === undefined || p[field] === null) {
103+
return { plugin: null, reason: `[${p.name}] missing required field: ${field}` };
104+
}
105+
}
106+
if (typeof p.isAvailable !== "function") {
107+
return { plugin: null, reason: `[${p.name}] isAvailable must be a function` };
108+
}
109+
if (p.tier !== 1 && p.tier !== 2) {
110+
return { plugin: null, reason: `[${p.name}] tier must be 1 or 2 (got ${String(p.tier)})` };
111+
}
112+
return { plugin: candidate as ContextProviderPlugin, reason: "" };
92113
}
93114

94-
// Sanity: plugin names must not collide with built-in provider names.
95-
// The resolver applies that check separately — here we just accept
96-
// anything that passes shape validation.
97-
return { plugin: candidate as ContextProviderPlugin, reason: "" };
115+
// mcpConfig path — validate the declared MCP config and auto-wrap.
116+
// Note the validator sees the raw shape — it expects name/label on the
117+
// mcpConfig itself, so we fill them in from the plugin's outer name/label
118+
// if the inner fields are missing. This keeps the plugin file terse:
119+
// authors write `name` once at the plugin level.
120+
const rawConfig = p.mcpConfig as Record<string, unknown>;
121+
const normalizedConfig = {
122+
name: p.name,
123+
label: p.label,
124+
...rawConfig,
125+
};
126+
const validation = validateProviderConfig(normalizedConfig);
127+
if (!validation.ok) {
128+
return {
129+
plugin: null,
130+
reason: `[${p.name}] invalid mcpConfig: ${validation.reason}`,
131+
};
132+
}
133+
const mcpProvider: ContextProvider = createMcpProvider(
134+
validation.value as McpProviderConfig
135+
);
136+
137+
// Merge the MCP-derived contract onto the plugin so it's a full
138+
// ContextProviderPlugin. Plugin-declared fields (tier/tokenBudget/
139+
// timeoutMs) win if present — lets authors override the MCP defaults.
140+
const merged: ContextProviderPlugin = {
141+
name: p.name,
142+
label: p.label,
143+
version: p.version,
144+
description: p.description,
145+
author: p.author,
146+
mcpConfig: p.mcpConfig,
147+
tier: p.tier ?? mcpProvider.tier,
148+
tokenBudget: p.tokenBudget ?? mcpProvider.tokenBudget,
149+
timeoutMs: p.timeoutMs ?? mcpProvider.timeoutMs,
150+
resolve: mcpProvider.resolve.bind(mcpProvider),
151+
isAvailable: mcpProvider.isAvailable.bind(mcpProvider),
152+
};
153+
154+
return { plugin: merged, reason: "" };
98155
}
99156

100157
/**

0 commit comments

Comments
 (0)