Converting claude-mem (Claude Code plugin) to opencode-mem (OpenCode plugin)
Source: https://github.com/thedotmack/claude-mem
Fork: https://github.com/rynfar/claude-mem
Target: OpenCode plugin with persistent memory
License: AGPL-3.0 (same as original)
┌─────────────────────────────────────────────────────────────────┐
│ KEEP AS-IS (Worker) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ SQLite+FTS5 │ │ Chroma Vec │ │ SDK Agent (extraction) │ │
│ │ Database │ │ Search │ │ + prompts.ts │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
│ ↑ │
│ HTTP API (localhost:37777) │
└─────────────────────────────────────────────────────────────────┘
↑
┌─────────────────────────────────────────────────────────────────┐
│ REPLACE (Plugin Layer) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ OpenCode Plugin (src/index.ts) │ │
│ │ • event handler (session.created/idle/deleted) │ │
│ │ • chat.message handler (inject context) │ │
│ │ • tool.execute.after handler (capture observations) │ │
│ │ • config handler (register tools) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Memory Client (src/mem/client.ts) │ │
│ │ • HTTP calls to worker API │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Strategy: Keep the proven worker service (SQLite, Chroma, SDK Agent) unchanged. Replace only the Claude Code hook layer with OpenCode plugin handlers.
Complexity: Low
Time: 1-2 hours
| File | Changes |
|---|---|
package.json |
Rename to opencode-mem, update description, keywords, entrypoints |
README.md |
Replace Claude Code instructions with OpenCode usage |
.github/workflows/* |
Update artifact names, package name |
| File | Purpose |
|---|---|
src/index.ts |
OpenCode plugin entry point |
src/config/schema.ts |
Plugin configuration (Zod) |
| File | Reason |
|---|---|
plugin/hooks/hooks.json |
Claude Code specific |
plugin/.mcp.json |
Claude Code specific |
plugin/hooks/*.js |
Replaced by TS plugin handlers |
- Rename package in
package.jsontoopencode-mem - Update package description and keywords
- Update README with OpenCode installation instructions
- Remove Claude Code plugin registration files
- Update CI workflows with new package name
Complexity: Medium
Time: 3-4 hours
These files form the core worker service and should remain as-is:
src/services/worker-service.ts # HTTP server (Bun/Express)
src/services/sqlite/ # Database layer (SQLite + FTS5)
src/services/worker/SDKAgent.ts # AI extraction using Claude Agent SDK
src/sdk/prompts.ts # Observation extraction prompts
Create a clean HTTP client layer for the OpenCode plugin to communicate with the worker:
src/mem/
├── client.ts # HTTP client for worker API
├── types.ts # Request/response types
├── worker-url.ts # Base URL resolution from config
└── index.ts # Barrel exports
import type { ObservationInput, SearchResult, ContextResponse } from "./types";
export class MemClient {
private baseUrl: string;
constructor(baseUrl = "http://127.0.0.1:37777") {
this.baseUrl = baseUrl;
}
async injectContext(sessionId: string, project: string): Promise<string> {
const url = `${this.baseUrl}/api/context/inject?project=${encodeURIComponent(project)}`;
const res = await fetch(url, {
signal: AbortSignal.timeout(30000)
});
if (!res.ok) throw new Error(`Context injection failed: ${res.status}`);
return res.text();
}
async createSession(claudeSessionId: string, project: string): Promise<void> {
await fetch(`${this.baseUrl}/api/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claudeSessionId, project }),
});
}
async saveObservation(data: ObservationInput): Promise<void> {
await fetch(`${this.baseUrl}/api/sessions/observations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
async generateSummary(sessionId: string): Promise<void> {
await fetch(`${this.baseUrl}/api/sessions/${sessionId}/summary`, {
method: "POST",
});
}
async search(query: string, options?: { limit?: number; type?: string }): Promise<SearchResult[]> {
const params = new URLSearchParams({ query });
if (options?.limit) params.set("limit", String(options.limit));
if (options?.type) params.set("type", options.type);
const res = await fetch(`${this.baseUrl}/api/search?${params}`);
return res.json();
}
async healthCheck(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(5000),
});
return res.ok;
} catch {
return false;
}
}
}- Create
src/mem/types.tswith request/response interfaces - Create
src/mem/client.tswith HTTP client class - Create
src/mem/worker-url.tsfor config-based URL resolution - Add graceful degradation when worker is unavailable
- Add payload truncation for large tool outputs
Complexity: High
Time: 4-6 hours
| Claude Code Hook | OpenCode Equivalent | Purpose |
|---|---|---|
SessionStart |
event: session.created |
Initialize worker session, prepare context |
UserPromptSubmit |
chat.message |
Inject context on first message |
PostToolUse |
tool.execute.after |
Capture tool observations |
Stop |
event: session.idle |
Generate incremental summary |
SessionEnd |
event: session.deleted |
Finalize summary + cleanup |
src/
├── index.ts # Main plugin export
├── opencode/
│ ├── handlers/
│ │ ├── session.ts # session.created/idle/deleted handlers
│ │ ├── message.ts # chat.message (context injection)
│ │ └── tool.ts # tool.execute.after (observations)
│ ├── injection.ts # Context injection logic
│ └── session-state.ts # Session ID mapping + caching
├── mem/
│ ├── client.ts # Worker HTTP client
│ ├── types.ts # Type definitions
│ └── index.ts # Exports
├── tools/
│ ├── mem-search.ts # Search tool
│ ├── mem-timeline.ts # Timeline tool
│ └── index.ts # Tool exports
└── config/
└── schema.ts # Plugin config schema (Zod)
import type { Plugin } from "@opencode-ai/plugin";
import { MemClient } from "./mem/client";
import { createSessionHandler } from "./opencode/handlers/session";
import { createMessageHandler } from "./opencode/handlers/message";
import { createToolHandler } from "./opencode/handlers/tool";
import { memSearchTool, memTimelineTool } from "./tools";
import { loadConfig } from "./config/schema";
const OpenCodeMem: Plugin = async (ctx) => {
const config = loadConfig();
const client = new MemClient(config.workerUrl);
// Check worker health on startup
const healthy = await client.healthCheck();
if (!healthy) {
console.warn("[opencode-mem] Worker not available at", config.workerUrl);
}
return {
tool: {
mem_search: memSearchTool(client),
mem_timeline: memTimelineTool(client),
},
event: createSessionHandler(client, ctx),
"chat.message": createMessageHandler(client, ctx),
"tool.execute.after": createToolHandler(client, ctx),
config: async (cfg) => {
// Could register MCP here if needed
},
};
};
export default OpenCodeMem;import type { PluginInput } from "@opencode-ai/plugin";
import type { MemClient } from "../../mem/client";
export function createSessionHandler(client: MemClient, ctx: PluginInput) {
return async (input: { event: { type: string; properties?: unknown } }) => {
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.created") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
try {
await client.createSession(sessionInfo.id, ctx.directory);
} catch (err) {
console.warn("[opencode-mem] Failed to create session:", err);
}
}
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined;
if (sessionID) {
try {
await client.generateSummary(sessionID);
} catch (err) {
console.warn("[opencode-mem] Failed to generate summary:", err);
}
}
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
try {
await client.generateSummary(sessionInfo.id);
// Could add cleanup logic here
} catch (err) {
console.warn("[opencode-mem] Failed to finalize session:", err);
}
}
}
};
}import type { PluginInput } from "@opencode-ai/plugin";
import type { MemClient } from "../../mem/client";
// Track which sessions have had context injected
const injectedSessions = new Set<string>();
export function createMessageHandler(client: MemClient, ctx: PluginInput) {
return async (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> }
) => {
// Only inject context on first message per session
if (injectedSessions.has(input.sessionID)) {
return;
}
try {
const context = await client.injectContext(input.sessionID, ctx.directory);
if (context && context.trim()) {
// Inject context into the conversation
// This depends on how oh-my-opencode handles message injection
// Could use hook-message-injector or similar pattern
console.log("[opencode-mem] Context ready for injection:", context.length, "chars");
}
injectedSessions.add(input.sessionID);
} catch (err) {
console.warn("[opencode-mem] Failed to inject context:", err);
injectedSessions.add(input.sessionID); // Don't retry on failure
}
};
}import type { PluginInput } from "@opencode-ai/plugin";
import type { MemClient } from "../../mem/client";
// Tools worth capturing observations for
const OBSERVABLE_TOOLS = new Set([
"read", "write", "edit", "bash", "glob", "grep",
"lsp_hover", "lsp_goto_definition", "lsp_find_references",
]);
export function createToolHandler(client: MemClient, ctx: PluginInput) {
return async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
// Only capture observations for relevant tools
if (!OBSERVABLE_TOOLS.has(input.tool)) {
return;
}
try {
await client.saveObservation({
claudeSessionId: input.sessionID,
tool_name: input.tool,
tool_input: {}, // Could capture from tool.execute.before if needed
tool_response: {
title: output.title,
output: truncateOutput(output.output, 10000),
metadata: output.metadata,
},
cwd: ctx.directory,
});
} catch (err) {
console.warn("[opencode-mem] Failed to save observation:", err);
}
};
}
function truncateOutput(output: string, maxLength: number): string {
if (output.length <= maxLength) return output;
return output.slice(0, maxLength) + "\n...[truncated]";
}Claude Code injects context at SessionStart before any model interaction. OpenCode doesn't have this pre-model injection capability.
Solution: Inject on first chat.message using the hook-message-injector pattern from oh-my-opencode, or prepend context to the system message.
- Create session handler for lifecycle events
- Create message handler with first-message context injection
- Create tool handler for observation capture
- Implement session ID mapping (OpenCode ID → Worker ID)
- Add context injection mechanism (study oh-my-opencode patterns)
- Handle graceful degradation when worker unavailable
Complexity: Medium
Time: 2-3 hours
Convert the MCP-based mem-search to native OpenCode tools for better integration.
import { tool } from "@opencode-ai/plugin/tool";
import { z } from "zod";
import type { MemClient } from "../mem/client";
export const memSearchTool = (client: MemClient) => tool({
description: `Search persistent memory for past observations, decisions, and learnings from previous sessions.
Use when:
- User asks "did we already solve this?"
- User asks "how did we do X last time?"
- User needs context from previous sessions
- Looking for past decisions or implementations`,
args: z.object({
query: z.string().describe("Natural language search query"),
limit: z.number().optional().default(10).describe("Maximum results to return"),
type: z.enum(["bugfix", "feature", "refactor", "change", "discovery", "decision"])
.optional()
.describe("Filter by observation type"),
}),
execute: async ({ query, limit, type }) => {
try {
const results = await client.search(query, { limit, type });
if (results.length === 0) {
return "No memories found matching your query.";
}
return formatSearchResults(results);
} catch (err) {
return `Memory search failed: ${err instanceof Error ? err.message : "Unknown error"}`;
}
},
});
function formatSearchResults(results: SearchResult[]): string {
return results.map((r, i) => `
### ${i + 1}. ${r.title} [${r.type}]
**${r.subtitle}**
- Files: ${r.files?.join(", ") || "none"}
- Date: ${r.timestamp}
- ID: ${r.id}
`).join("\n");
}import { tool } from "@opencode-ai/plugin/tool";
import { z } from "zod";
import type { MemClient } from "../mem/client";
export const memTimelineTool = (client: MemClient) => tool({
description: "Get a timeline of memories around a specific point or search result",
args: z.object({
observationId: z.string().optional().describe("Get context around this observation ID"),
query: z.string().optional().describe("Search and get timeline around best match"),
window: z.number().optional().default(5).describe("Number of items before/after"),
}),
execute: async ({ observationId, query, window }) => {
// Implementation depends on worker API
// ...
},
});| Tool | Purpose |
|---|---|
mem_search |
Search observations by query |
mem_timeline |
Get context around a point in time |
mem_remember |
Force-save a specific memory |
mem_status |
Check worker health and stats |
mem_recent |
Get recent session context |
- Create
mem_searchtool with query, limit, type params - Create
mem_timelinetool for contextual retrieval - Create
mem_statustool for debugging - Format search results for readability
- Add tool descriptions that help the model know when to use them
Complexity: Medium
Time: 2-3 hours
- Worker down → plugin degrades gracefully (no crashes)
- Worker up → session created successfully
- Worker restarts → plugin reconnects
-
session.createdtriggers worker session init -
session.idlegenerates incremental summary -
session.deletedfinalizes and cleans up -
session.errordoesn't wedge future sessions
- First user message gets memory context
- Subsequent messages don't duplicate context
- Large context is truncated appropriately
-
tool.execute.aftersaves observations - Large tool outputs are truncated
- Non-observable tools are skipped
- Returns relevant results
- Empty results handled gracefully
- Type filtering works
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
export const OpenCodeMemConfigSchema = z.object({
// Worker connection
workerUrl: z.string().default("http://127.0.0.1:37777"),
workerTimeout: z.number().default(30000),
// Context injection
contextMaxTokens: z.number().default(50),
injectOnFirstMessage: z.boolean().default(true),
// Observation capture
toolOutputMaxChars: z.number().default(10000),
observableTools: z.array(z.string()).optional(),
// Summary generation
summaryOnIdle: z.boolean().default(true),
summaryOnDelete: z.boolean().default(true),
// Feature flags
enabled: z.boolean().default(true),
debug: z.boolean().default(false),
});
export type OpenCodeMemConfig = z.infer<typeof OpenCodeMemConfigSchema>;
export function loadConfig(): OpenCodeMemConfig {
// Load from ~/.config/opencode/opencode-mem.json or project config
const userConfigPath = path.join(
process.env.HOME || "",
".config/opencode/opencode-mem.json"
);
try {
if (fs.existsSync(userConfigPath)) {
const content = JSON.parse(fs.readFileSync(userConfigPath, "utf-8"));
return OpenCodeMemConfigSchema.parse(content);
}
} catch (err) {
console.warn("[opencode-mem] Failed to load config:", err);
}
return OpenCodeMemConfigSchema.parse({});
}- Installation (OpenCode plugin + worker)
- Quick start guide
- Configuration options table
- Troubleshooting (port conflicts, worker not running)
- Migration from claude-mem (if applicable)
- Update package name to
opencode-mem - Update all entrypoints in package.json
- Ensure
bun run buildproduces clean output - Update CI to publish under new name
- Add OpenCode-specific keywords for discoverability
| Phase | Complexity | Estimated Time |
|---|---|---|
| Phase 0: Project Setup | Low | 1-2 hours |
| Phase 1: Core Infrastructure | Medium | 3-4 hours |
| Phase 2: Hook Conversion | High | 4-6 hours |
| Phase 3: Tools Integration | Medium | 2-3 hours |
| Phase 4: Testing & Polish | Medium | 2-3 hours |
| Total | 12-18 hours |
Risk: OpenCode may not support pre-model context injection like Claude Code's SessionStart.
Mitigation: Inject on first chat.message. Test different injection mechanisms (hook-message-injector, system message prepend).
Risk: Users need to run worker separately. Mitigation: Document clearly. Consider auto-start option in future version.
Risk: Large tool outputs could overwhelm the worker/database. Mitigation: Implement truncation in tool handler. Make limits configurable.
Risk: OpenCode session IDs may differ from what worker expects. Mitigation: Use OpenCode session ID directly, or maintain a mapping layer.
After initial release, consider:
- Auto-start worker - Spawn worker process from plugin if not running
- Web UI integration - Link to localhost:37777 viewer from OpenCode
- Cross-project memory - Share memories across projects (opt-in)
- Memory pruning - Auto-cleanup old observations
- Export/import - Backup and restore memories
- oh-my-opencode integration - Optional tight integration as a hook
Plan created: December 2024 Last updated: December 2024