Running plugins
Shipped in v3-alpha (Beta). The @mnemosyne/plugins runtime lets you extend
Mnemosyne without forking. Interfaces may still change before 3.0 stable.
This is the operational companion to the
plugin architecture concept page, which explains what
plugins are. This page covers how to run them: the loader, configuration, the
built-ins, the isolation model, and the lifecycle. It follows
docs/architecture/FEDERATION.md (Part 1) and the threat model in
docs/architecture/PLUGIN_SECURITY.md.
The PluginLoader
Plugins are orchestrated by the PluginLoader in @mnemosyne/plugins, which is
wired at server boot. The loader resolves each plugin, calls its lifecycle and
write/read/event hooks, and enforces a per-hook timeout. A plugin is registered
either as a built-in (via the BUILTIN_FACTORIES map) or by dynamic import of
an npm package.
The supported, boot-time entry point is createPluginLoaderFromEnv, which reads
the MNEMO_PLUGINS environment variable — a comma-separated list of package
names — and loads each one:
# Load two package plugins at boot
export MNEMO_PLUGINS="@acme/mnemosyne-erp-plugin,@acme/mnemosyne-classifier"Each named package must export an object satisfying MnemoPlugin as its
default export or as a named plugin export, with at least id, name, and
version strings. Loading never throws: a bad package is captured as a
failed PluginLoadResult and the remaining plugins still boot.
With an empty or absent MNEMO_PLUGINS, no plugin code runs at all — the
runtime is disabled-by-default (fail-safe). MNEMO_PLUGINS is the real gate.
A plugins.allowUnsandboxedPlugins flag exists in config.ts, but per
PLUGIN_SECURITY.md it is not consulted at server boot and must not be
treated as a security control.
Built-in plugins
Ten built-ins ship in packages/plugins/src/builtins and are registered in the
BUILTIN_FACTORIES map. Each is created by a create<Name>Plugin() factory.
| Plugin | Purpose | Direction | Trigger |
|---|---|---|---|
git-verifier | Verify source_type = codebase facts against repo state; close stale ones | repo → invalidate stale facts | scheduled (default daily) |
outline-sync | Mirror Outline KB articles into facts | Outline → facts | interval poll (+ webhook) |
webhook-notify | Push memory events to external HTTP endpoints (HMAC-signed, retried) | memory events → HTTP POST | event hooks |
obsidian-export | Export the graph as a Markdown vault | memory → .md files | on demand |
claude-import | Bootstrap from a Claude memory export | JSON → facts | on demand |
slack-events | Record Slack messages as events | Slack → events | webhook |
github-events | Record PR/issue activity | GitHub → events + tasks | webhook |
calendar-sync | Import calendar events as episodes | Google/Outlook → episodes | interval poll |
feedback-loop | Turn thumbs up/down into worth updates | UI rating → worth update | feedback route |
event-reactions | React to recorded events | events → reactions | event hook |
The federation spec’s §6 lists nine of these; the tenth, event-reactions, is
present in the codebase and registered in BUILTIN_FACTORIES, which is why the
threat model in PLUGIN_SECURITY.md counts ten built-ins.
How plugins are configured
A plugin receives a constrained PluginContext — the entire surface it gets.
There is intentionally no raw DB connection, no cross-workspace access, no admin
op, and no peek at other plugins’ state:
interface PluginContext {
readonly workspaceId: string;
readonly config: Record<string, string | undefined>;
readonly maxSensitivity: Sensitivity; // write-path sensitivity ceiling
// Read (RBAC-scoped to workspaceId)
recall(query: string): Promise<PluginRecallHit[]>;
getFact(id: string): Promise<PluginFact | null>;
// Write (rate-limited + audited; sensitivity clamped to maxSensitivity)
createFact(input: PluginCreateFactInput): Promise<PluginFact>;
createEvent(input: PluginCreateEventInput): Promise<PluginEvent>;
createTask(input: PluginCreateTaskInput): Promise<PluginTask>;
createEpisode(input: PluginCreateEpisodeInput): Promise<PluginEpisode>;
// Plugin-private state + logging
readonly state: PluginStateStore; // persisted in mnemo_plugin_state
readonly log: PluginLogger;
}Per-plugin config is passed through as a string map on the registration and
exposed read-only at ctx.config. Writes are workspace-scoped and audited with
actorId = "plugin:<id>". The maxSensitivity ceiling caps the sensitivity of
any fact a plugin writes; the loader clamps it after the hook chain (so a
plugin cannot re-escalate post-clamp), comparing via the integer
SENSITIVITY_ORDER map rather than string comparison.
Isolation model
In v3.0, plugins run in the same Node.js process as the server with full
access to memory, filesystem, network, database connections, and environment
variables (including secrets). There is no sandbox, no capability
restriction, and no isolation. Loading a plugin is equivalent to granting
that package unconditional access as the server process user — treat plugin
packages with the same scrutiny as production server code, and never set
MNEMO_PLUGINS to packages you have not audited.
The PluginContext boundary provides fault isolation (a crashing or hanging
hook is caught, logged, and contained without taking down the server) but not
security isolation. Per PLUGIN_SECURITY.md, this posture is professionally
adequate for first-party built-ins (T0) and operator-installed plugins (T1) —
the operator already holds DB and secret access — but it is not acceptable
for untrusted third-party (T2) plugins, of which there is no marketplace today.
The fault-isolation mechanics, from the loader:
- Per-hook timeout. Each hook is wrapped with a 2-second timeout.
- Auto-disable. On a timeout or 3 consecutive failures, the plugin is
disabled and
MNEMO_010is logged. A disabled plugin is skipped on subsequent hook runs. - Capability-light context. The context never hands over the raw DB handle or master key.
- Sensitivity clamp on the write path (see above).
The roadmap in PLUGIN_SECURITY.md and FEDERATION.md §3 specifies true
isolation to build when the T2 trigger fires — a worker_threads harness with a
per-worker memory cap, a message-passing hook RPC protocol, and a deny-by-default
capability proxy (no process, fs, net, or DB handle reachable inside the
worker). That work is gated on the introduction of untrusted plugins; building
it before then is treated as over-engineering by the threat model.
Plugin lifecycle
The loader drives an optional set of hooks across each plugin (all hooks are optional; the host never calls a hook a plugin does not implement):
onInit(ctx)— called once at boot. Throwing is caught and logged per-plugin; remaining plugins still boot. (onBootis a deprecated alias.)onHealthCheck()— returns{ healthy, detail? }.- Write-path —
onBeforeWrite(returningnullrejects the write and short-circuits the chain), thenonAfterWrite. - Read-path —
onBeforeRecall, thenonAfterRecall. - Event hooks (async, fire-and-forget) —
onFactCreated,onTaskCompleted,onSessionCollapsed,onConflictDetected,onWorthChanged,onEventRecorded. sync(ctx)— polling ingestion onsyncInterval; the loader persists the returned cursor viactx.state.verify(facts, ctx)— codebase-aware fact verification.onShutdown(ctx)— called on shutdown with a 5-second timeout; errors and timeouts are caught and logged per-plugin.
Multiple plugins implementing the same write-path hook run in registration
order, and a null return from any one of them short-circuits the rest of the
chain.
Testing a plugin
@mnemosyne/plugins ships an in-memory plugin state store (exported from
state.ts) and the built-in factories, so a plugin can be registered directly
against a PluginLoader in tests without a running server. Register with
loader.register(plugin, config) and drive the hook runners
(runBeforeWrite, runEventHook, runSync, …) to exercise behavior.
See also
- Plugin architecture — what plugins are
- Security & isolation — the surrounding tenancy model
- Running federation — plugins can implement custom sync