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,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;