OperationsRunning plugins

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.

PluginPurposeDirectionTrigger
git-verifierVerify source_type = codebase facts against repo state; close stale onesrepo → invalidate stale factsscheduled (default daily)
outline-syncMirror Outline KB articles into factsOutline → factsinterval poll (+ webhook)
webhook-notifyPush memory events to external HTTP endpoints (HMAC-signed, retried)memory events → HTTP POSTevent hooks
obsidian-exportExport the graph as a Markdown vaultmemory → .md fileson demand
claude-importBootstrap from a Claude memory exportJSON → factson demand
slack-eventsRecord Slack messages as eventsSlack → eventswebhook
github-eventsRecord PR/issue activityGitHub → events + taskswebhook
calendar-syncImport calendar events as episodesGoogle/Outlook → episodesinterval poll
feedback-loopTurn thumbs up/down into worth updatesUI rating → worth updatefeedback route
event-reactionsReact to recorded eventsevents → reactionsevent 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_010 is 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. (onBoot is a deprecated alias.)
  • onHealthCheck() — returns { healthy, detail? }.
  • Write-path — onBeforeWrite (returning null rejects the write and short-circuits the chain), then onAfterWrite.
  • Read-path — onBeforeRecall, then onAfterRecall.
  • Event hooks (async, fire-and-forget) — onFactCreated, onTaskCompleted, onSessionCollapsed, onConflictDetected, onWorthChanged, onEventRecorded.
  • sync(ctx) — polling ingestion on syncInterval; the loader persists the returned cursor via ctx.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