diff --git a/opencode-plugin/INSTALL.md b/opencode-plugin/INSTALL.md new file mode 100644 index 0000000..9539736 --- /dev/null +++ b/opencode-plugin/INSTALL.md @@ -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. diff --git a/opencode-plugin/package.json b/opencode-plugin/package.json new file mode 100644 index 0000000..c17c00f --- /dev/null +++ b/opencode-plugin/package.json @@ -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" +} diff --git a/opencode-plugin/src/index.ts b/opencode-plugin/src/index.ts new file mode 100644 index 0000000..c5335e1 --- /dev/null +++ b/opencode-plugin/src/index.ts @@ -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; +} + +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 { + 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 { + 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 { + 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 { + 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 => { + 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(); + + 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).type as string; + + if (eventType === "session.created") { + const sessionID = (event as Record) + .properties as Record; + 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) + .properties as Record; + 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; diff --git a/opencode-plugin/tsconfig.json b/opencode-plugin/tsconfig.json new file mode 100644 index 0000000..1596512 --- /dev/null +++ b/opencode-plugin/tsconfig.json @@ -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"] +}