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:
parent
9b25b33a50
commit
b21871b8fc
4 changed files with 629 additions and 0 deletions
464
opencode-plugin/src/index.ts
Normal file
464
opencode-plugin/src/index.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue