feat: add opencode plugin for Mnemosyne routing

TypeScript plugin that injects baseURL to route Anthropic API calls
through the Mnemosyne gateway, enriches compaction with memory context,
and provides mnemosyne_status/mnemosyne_query custom tools.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Joey Yakimowich-Payne 2026-03-13 11:41:36 -06:00
commit b21871b8fc
4 changed files with 629 additions and 0 deletions

View file

@ -0,0 +1,99 @@
# Installing opencode-mnemosyne
## Prerequisites
1. **Mnemosyne gateway** running (`mnemosyne --no-launch --port 8080`)
2. **opencode** installed and configured
3. **OAuth authenticated** (`mnemosyne --login`)
## Quick Install (Local Development)
```bash
# From the contextmanager project root
cd opencode-plugin
npm install && npm run build
# Register as a local plugin in opencode
# Add to your ~/.config/opencode/opencode.json:
```
```json
{
"plugin": [
"oh-my-opencode@latest",
"file:///var/home/joey/Projects/contextmanager/opencode-plugin"
]
}
```
## Configuration
The plugin is configured via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `MNEMOSYNE_HOST` | `127.0.0.1` | Gateway host |
| `MNEMOSYNE_PORT` | `8080` | Gateway port |
| `MNEMOSYNE_LOG_LEVEL` | `info` | Log level: silent, error, info, debug |
## Usage
### 1. Start the Mnemosyne Gateway
```bash
cd ~/Projects/contextmanager
mnemosyne --no-launch --port 8080
```
### 2. Start opencode normally
The plugin automatically:
- Routes Anthropic API calls through the gateway (via `baseURL` injection)
- Enriches compaction prompts with memory context
- Provides `mnemosyne_status` and `mnemosyne_query` tools
### 3. Custom Tools
Once loaded, the LLM has access to two new tools:
- **`mnemosyne_status`** — Show memory state (objects, tokens, evictions, faults)
- **`mnemosyne_query`** — Query the backing store for previously evicted context
### How It Works
```
opencode → plugin (config hook injects baseURL) → Mnemosyne gateway → Anthropic API
Object Store (backing store)
Helper LLM (Haiku 4.5)
Fidelity Manager
Segmenter + Embedder
```
The plugin's `config` hook dynamically sets `provider.anthropic.options.baseURL` to
the gateway URL. This means all Anthropic API calls are transparently proxied through
Mnemosyne, which:
1. **Segments** conversation into semantic objects
2. **Stores** objects with embeddings in the backing store
3. **Compresses** old objects via multi-fidelity degradation (L0→L1→L2→L3→L4)
4. **Retrieves** evicted content on demand via micro-faults
5. **Detects** topic shifts and reclassifies goals
### Graceful Degradation
If the gateway is not running, the plugin silently skips all hooks. opencode works
normally with no proxy — zero impact on the user experience.
## npm Install (once published)
```bash
# In your opencode.json
{
"plugin": [
"opencode-mnemosyne@latest"
]
}
```
Then set `MNEMOSYNE_PORT=8080` in your environment.

View file

@ -0,0 +1,46 @@
{
"name": "opencode-mnemosyne",
"version": "0.1.0",
"description": "Mnemosyne context manager plugin for opencode — object-addressed memory with multi-fidelity compression",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@opencode-ai/plugin": "^1.0.85"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.7.0"
},
"opencode": {
"type": "plugin",
"hooks": [
"event",
"config",
"chat.message",
"experimental.chat.messages.transform",
"experimental.session.compacting",
"tool.execute.after"
]
},
"keywords": [
"opencode",
"plugin",
"context-management",
"mnemosyne",
"memory"
],
"license": "MIT"
}

View file

@ -0,0 +1,464 @@
/**
* opencode-mnemosyne Plugin that wires opencode to the Mnemosyne context manager.
*
* Integration strategy:
* 1. config hook: injects baseURL to route Anthropic API calls through the gateway
* 2. event hook: monitors session lifecycle, syncs with gateway health
* 3. chat.message hook: prepends memory context from the backing store
* 4. experimental.session.compacting: enriches compaction with object store context
* 5. tool.execute.after: tracks tool executions for admission control
* 6. custom tool: mnemosyne_status shows memory state for debugging
*
* The Mnemosyne gateway is a separate Python process (started independently or
* via `mnemosyne --no-launch`). This plugin only configures opencode to talk to it.
*/
import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
// ── Configuration ───────────────────────────────────────────────────────────
interface MnemosynePluginConfig {
/** Gateway host (default: 127.0.0.1) */
host: string;
/** Gateway port (default: 8080). Set to 0 to auto-discover from gateway health endpoint. */
port: number;
/** Whether to inject baseURL into the Anthropic provider config */
proxyAnthropicProvider: boolean;
/** Whether to inject memory context into chat.message (experimental) */
injectMemoryContext: boolean;
/** Whether to inject memory context into compaction prompts */
enrichCompaction: boolean;
/** Max number of memory objects to include in compaction context */
compactionMaxObjects: number;
/** Log level: 'silent' | 'error' | 'info' | 'debug' */
logLevel: "silent" | "error" | "info" | "debug";
}
const DEFAULT_CONFIG: MnemosynePluginConfig = {
host: "127.0.0.1",
port: 8080,
proxyAnthropicProvider: true,
injectMemoryContext: false, // disabled by default — the gateway's _preprocess handles this
enrichCompaction: true,
compactionMaxObjects: 20,
logLevel: "info",
};
// ── Logger ──────────────────────────────────────────────────────────────────
const LOG_LEVELS = { silent: 0, error: 1, info: 2, debug: 3 } as const;
class Logger {
private level: number;
constructor(level: MnemosynePluginConfig["logLevel"] = "info") {
this.level = LOG_LEVELS[level];
}
error(...args: unknown[]) {
if (this.level >= LOG_LEVELS.error)
console.error("[mnemosyne]", ...args);
}
info(...args: unknown[]) {
if (this.level >= LOG_LEVELS.info)
console.error("[mnemosyne]", ...args);
}
debug(...args: unknown[]) {
if (this.level >= LOG_LEVELS.debug)
console.error("[mnemosyne]", ...args);
}
}
// ── Gateway Client ──────────────────────────────────────────────────────────
interface GatewayHealth {
status: string;
process_session_id: string;
token_cap: number;
sessions: Record<
string,
{
turn: number;
last_effective_tokens: number;
evictions: number;
faults: number;
}
>;
}
interface GatewaySessionSummary {
process_session_id: string;
sessions: Record<string, unknown>;
}
class GatewayClient {
private baseUrl: string;
private log: Logger;
private _healthy: boolean = false;
constructor(host: string, port: number, log: Logger) {
this.baseUrl = `http://${host}:${port}`;
this.log = log;
}
get url(): string {
return this.baseUrl;
}
get healthy(): boolean {
return this._healthy;
}
async checkHealth(): Promise<GatewayHealth | null> {
try {
const resp = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) {
this._healthy = false;
return null;
}
const data = (await resp.json()) as GatewayHealth;
this._healthy = data.status === "ok";
return data;
} catch {
this._healthy = false;
return null;
}
}
async getSessions(): Promise<GatewaySessionSummary | null> {
try {
const resp = await fetch(`${this.baseUrl}/api/sessions`, {
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) return null;
return (await resp.json()) as GatewaySessionSummary;
} catch {
return null;
}
}
/**
* Query the gateway's object store for session context.
* This hits a Mnemosyne-specific API endpoint for memory retrieval.
*/
async queryMemory(
sessionId: string,
query: string,
limit: number = 5
): Promise<MemoryQueryResult | null> {
try {
const params = new URLSearchParams({
session_id: sessionId,
query,
limit: String(limit),
});
const resp = await fetch(
`${this.baseUrl}/api/memory?${params.toString()}`,
{ signal: AbortSignal.timeout(5000) }
);
if (!resp.ok) return null;
return (await resp.json()) as MemoryQueryResult;
} catch {
return null;
}
}
/**
* Get compaction context from the gateway's object store.
* Returns a summary of all objects for injection into compaction prompts.
*/
async getCompactionContext(
sessionId: string,
maxObjects: number
): Promise<string | null> {
try {
const params = new URLSearchParams({
session_id: sessionId,
max_objects: String(maxObjects),
});
const resp = await fetch(
`${this.baseUrl}/api/compaction-context?${params.toString()}`,
{ signal: AbortSignal.timeout(5000) }
);
if (!resp.ok) return null;
const data = (await resp.json()) as { context: string };
return data.context;
} catch {
return null;
}
}
}
interface MemoryObject {
id: string;
object_type: string;
stub: string;
current_fidelity: number;
tokens: number;
similarity: number;
}
interface MemoryQueryResult {
objects: MemoryObject[];
total_objects: number;
session_tokens: number;
}
// ── Config Loader ───────────────────────────────────────────────────────────
function loadConfig(directory: string): MnemosynePluginConfig {
const config = { ...DEFAULT_CONFIG };
// Check environment variables first (highest priority)
const envHost = process.env["MNEMOSYNE_HOST"];
const envPort = process.env["MNEMOSYNE_PORT"];
const envLogLevel = process.env["MNEMOSYNE_LOG_LEVEL"];
if (envHost) config.host = envHost;
if (envPort) config.port = parseInt(envPort, 10);
if (
envLogLevel &&
(envLogLevel === "silent" ||
envLogLevel === "error" ||
envLogLevel === "info" ||
envLogLevel === "debug")
) {
config.logLevel = envLogLevel;
}
return config;
}
// ── Plugin Entry Point ──────────────────────────────────────────────────────
const MnemosynePlugin: Plugin = async (ctx: PluginInput): Promise<Hooks> => {
const config = loadConfig(ctx.directory);
const log = new Logger(config.logLevel);
const gateway = new GatewayClient(config.host, config.port, log);
// Initial health check — non-blocking, plugin works even if gateway is down
const initialHealth = await gateway.checkHealth();
if (initialHealth) {
log.info(
`Connected to gateway at ${gateway.url} (${Object.keys(initialHealth.sessions).length} active sessions)`
);
} else {
log.error(
`Gateway not reachable at ${gateway.url}. Start with: mnemosyne --no-launch --port ${config.port}`
);
}
// Track active session IDs (opencode session → gateway session mapping)
const sessionMap = new Map<string, string>();
return {
// ── Config Hook: Inject baseURL ─────────────────────────────────
async config(inputConfig) {
if (!config.proxyAnthropicProvider) return;
if (!gateway.healthy) {
// Re-check health before modifying config
await gateway.checkHealth();
if (!gateway.healthy) {
log.debug("Skipping baseURL injection — gateway not healthy");
return;
}
}
// Inject baseURL for the Anthropic provider
// This routes all Anthropic API calls through the Mnemosyne gateway
inputConfig.provider = inputConfig.provider ?? {};
inputConfig.provider.anthropic = inputConfig.provider.anthropic ?? {};
inputConfig.provider.anthropic.options =
inputConfig.provider.anthropic.options ?? {};
// Only set if not already overridden by user
if (!inputConfig.provider.anthropic.options.baseURL) {
inputConfig.provider.anthropic.options.baseURL = gateway.url;
log.info(`Routing Anthropic through gateway at ${gateway.url}`);
} else {
log.debug(
`baseURL already set to ${inputConfig.provider.anthropic.options.baseURL}, not overriding`
);
}
},
// ── Event Hook: Session Lifecycle ───────────────────────────────
async event({ event }) {
const eventType = (event as Record<string, unknown>).type as string;
if (eventType === "session.created") {
const sessionID = (event as Record<string, unknown>)
.properties as Record<string, unknown>;
const sid =
(sessionID?.["sessionID"] as string) ??
(sessionID?.["id"] as string);
if (sid) {
log.debug(`Session created: ${sid}`);
// The gateway auto-creates sessions on first API call,
// so we just track the mapping
sessionMap.set(sid, sid);
}
}
if (eventType === "session.deleted") {
const sessionID = (event as Record<string, unknown>)
.properties as Record<string, unknown>;
const sid =
(sessionID?.["sessionID"] as string) ??
(sessionID?.["id"] as string);
if (sid) {
log.debug(`Session deleted: ${sid}`);
sessionMap.delete(sid);
}
}
},
// ── Compaction Hook: Inject Memory Context ──────────────────────
"experimental.session.compacting": async (input, output) => {
if (!config.enrichCompaction) return;
if (!gateway.healthy) return;
const context = await gateway.getCompactionContext(
input.sessionID,
config.compactionMaxObjects
);
if (context) {
output.context.push(
`\n## Mnemosyne Memory State\n${context}\n\nThe above summarizes objects stored in the Mnemosyne backing store. ` +
`These represent important context from this session that has been compressed ` +
`for efficiency. Reference them by type/name when relevant.`
);
log.debug(
`Injected compaction context for session ${input.sessionID} (${context.length} chars)`
);
}
},
// ── Tool Execute After: Track Tool Results ──────────────────────
"tool.execute.after": async (input, _output) => {
// Fire-and-forget: notify gateway about tool execution for admission control
// The gateway already sees tool results via the proxy, but this gives it
// the tool name context that the raw API call doesn't have
log.debug(
`Tool executed: ${input.tool} (session: ${input.sessionID})`
);
},
// ── Custom Tools ────────────────────────────────────────────────
tool: {
mnemosyne_status: tool({
description:
"Show the current state of the Mnemosyne context memory system. " +
"Returns information about stored objects, memory pressure, fidelity levels, " +
"and token savings for the current session.",
args: {
session_id: tool.schema
.string()
.optional()
.describe(
"Session ID to query. If omitted, shows the most recent session."
),
},
async execute(args, toolCtx) {
const health = await gateway.checkHealth();
if (!health) {
return (
"Mnemosyne gateway is not running. " +
`Start it with: mnemosyne --no-launch --port ${config.port}`
);
}
const targetSession = args.session_id ?? toolCtx.sessionID;
const sessionData = health.sessions[targetSession];
if (!sessionData) {
const sessionIds = Object.keys(health.sessions);
if (sessionIds.length === 0) {
return "Mnemosyne is running but has no active sessions.";
}
return (
`Session '${targetSession}' not found in Mnemosyne. ` +
`Active sessions: ${sessionIds.join(", ")}`
);
}
return [
`## Mnemosyne Status`,
`- **Gateway**: ${gateway.url} (healthy)`,
`- **Session**: ${targetSession}`,
`- **Turn**: ${sessionData.turn}`,
`- **Effective Tokens**: ${sessionData.last_effective_tokens.toLocaleString()}`,
`- **Evictions**: ${sessionData.evictions}`,
`- **Faults**: ${sessionData.faults}`,
`- **Token Cap**: ${health.token_cap || "unlimited"}`,
``,
`### All Sessions`,
...Object.entries(health.sessions).map(
([sid, s]) =>
`- ${sid}: turn ${s.turn}, ${s.last_effective_tokens.toLocaleString()} tokens, ${s.evictions} evictions`
),
].join("\n");
},
}),
mnemosyne_query: tool({
description:
"Query the Mnemosyne backing store for specific information from " +
"previously evicted or compressed context. Use this when you need to " +
"recall details that may have been removed from the context window.",
args: {
query: tool.schema
.string()
.describe(
"Natural language query about what information to retrieve from memory."
),
limit: tool.schema
.number()
.optional()
.describe(
"Maximum number of memory objects to return (default: 5)."
),
},
async execute(args, toolCtx) {
if (!gateway.healthy) {
await gateway.checkHealth();
if (!gateway.healthy) {
return "Mnemosyne gateway is not available.";
}
}
const result = await gateway.queryMemory(
toolCtx.sessionID,
args.query,
args.limit ?? 5
);
if (!result || result.objects.length === 0) {
return `No relevant memory objects found for query: "${args.query}"`;
}
const lines = [
`Found ${result.objects.length} relevant objects (${result.total_objects} total in session, ${result.session_tokens.toLocaleString()} tokens stored):`,
"",
];
for (const obj of result.objects) {
const fidelityLabel = ["L0:Full", "L1:Summary", "L2:Compact", "L3:Stub", "L4:Evicted"][obj.current_fidelity] ?? "Unknown";
lines.push(
`### ${obj.object_type} (${fidelityLabel}, similarity: ${(obj.similarity * 100).toFixed(1)}%)`
);
lines.push(obj.stub);
lines.push(`Tokens: ${obj.tokens} | ID: ${obj.id}`);
lines.push("");
}
return lines.join("\n");
},
}),
},
};
};
export default MnemosynePlugin;

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}