diff --git a/README.md b/README.md index a0af443..cb877a2 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,7 @@ bunx opencode-supermemory@latest status ``` The installer also enables a persistent `◪ supermemory` footer in OpenCode's -TUI. It turns blue while OpenCode is running a turn and keeps the latest recall -or save activity visible. Native toasts show the same recall, save, failure, and -update events as they happen. +TUI. Native toasts show recall, save, failure, and update events as they happen. **Or let your agent do it** - paste this into OpenCode: @@ -51,7 +49,9 @@ bunx opencode-supermemory@latest install --no-tui This will: - Register the plugin in `~/.config/opencode/opencode.jsonc` -- Create the `/supermemory-init` command +- Create `/supermemory-login` and `/supermemory-status` +- Remove legacy `/supermemory-init`, `/supermemory-logout`, and + `/supermemory-switch-organization` commands #### Step 2: Verify the config @@ -118,10 +118,6 @@ If it is not connected, check: 2. Is the plugin in `opencode.jsonc`? 3. Check logs: `tail ~/.opencode-supermemory.log` -#### Step 5: Initialize codebase memory (optional) - -Run `/supermemory-init` to have the agent explore and memorize the codebase. - ## Features @@ -186,9 +182,11 @@ Agent: [saves to project memory] Add custom triggers via `keywordPatterns` config. -### Codebase Indexing +### Hosted MCP Tools -Run `/supermemory-init` to explore and memorize your codebase structure, patterns, and conventions. +Agent-facing search, add, list, profile, graph, and forget operations come from +the hosted Supermemory MCP server. Read-only recall tools are approved +automatically; memory writes still follow OpenCode's normal permission flow. ### Preemptive Compaction @@ -210,19 +208,9 @@ Content in `` tags is never stored. ## Tool Usage -The `supermemory` tool is available to the agent: - -| Mode | Args | Description | -| --------- | ---------------------------- | ----------------- | -| `add` | `content`, `type?`, `scope?` | Store memory | -| `search` | `query`, `scope?` | Search memories | -| `profile` | `query?` | View user profile | -| `list` | `scope?`, `limit?` | List memories | -| `forget` | `memoryId`, `scope?` | Delete memory | - -**Scopes:** `user` (personal memories for the current project), `project` (default) - -**Types:** `project-config`, `architecture`, `error-solution`, `preference`, `learned-pattern`, `conversation` +Hosted MCP tools are registered with the `supermemory_` prefix, including +`supermemory_search_memory`, `supermemory_add_memory`, and +`supermemory_whoAmI`. OpenCode sends the same shared coding-agent entity context as Claude Code and Codex. Personal and project memories are distinguished with `sm_scope` diff --git a/package.json b/package.json index 12655d0..0f95cb0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "scripts": { "generate:version": "node scripts/sync-version.mjs", - "build": "node scripts/sync-version.mjs && bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/tui.tsx --outfile ./dist/tui.js --target bun --external @opentui/core --external @opentui/keymap --external @opentui/solid --external solid-js && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", + "build": "node scripts/sync-version.mjs && bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/tui.tsx --outfile ./dist/tui.js --target bun --external @opentui/core --external @opentui/keymap --external @opentui/solid --external solid-js && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && bun build ./src/mcp-proxy.ts --outfile ./dist/mcp-proxy.js --target node && tsc --emitDeclarationOnly", "dev": "tsc --watch", "typecheck": "node scripts/sync-version.mjs && tsc --noEmit", "test": "node scripts/sync-version.mjs && bun test" @@ -59,6 +59,7 @@ "type": "plugin", "hooks": [ "chat.message", + "config", "permission.ask", "tool.execute.before", "tool.execute.after", diff --git a/src/cli.ts b/src/cli.ts index 13b6ef1..3134056 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,12 @@ #!/usr/bin/env node -import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; import * as readline from "node:readline"; import { stripJsoncComments } from "./services/jsonc.js"; import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; -import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); @@ -16,195 +16,24 @@ const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); const PLUGIN_NAME = "opencode-supermemory@latest"; const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); -const SUPERMEMORY_INIT_COMMAND = `--- -description: Initialize Supermemory with comprehensive codebase knowledge ---- - -# Initializing Supermemory - -You are initializing persistent memory for this codebase. This is not just data collection - you're building context that will make you significantly more effective across all future sessions. - -## Understanding Context - -You are a **stateful** coding agent. Users expect to work with you over extended periods - potentially the entire lifecycle of a project. Your memory is how you get better over time and maintain continuity. - -## What to Remember - -### 1. Procedures (Rules & Workflows) -Explicit rules that should always be followed: -- "Never commit directly to main - always use feature branches" -- "Always run lint before tests" -- "Use conventional commits format" - -### 2. Preferences (Style & Conventions) -Project and user coding style: -- "Prefer functional components over class components" -- "Use early returns instead of nested conditionals" -- "Always add JSDoc to exported functions" - -### 3. Architecture & Context -How the codebase works and why: -- "Auth system was refactored in v2.0 - old patterns deprecated" -- "The monorepo used to have 3 modules before consolidation" -- "This pagination bug was fixed before - similar to PR #234" - -## Memory Scopes - -**Project-scoped** (\`scope: "project"\`): -- Build/test/lint commands -- Architecture and key directories -- Team conventions specific to this codebase -- Technology stack and framework choices -- Known issues and their solutions - -**User-scoped** (\`scope: "user"\`): -- Personal coding preferences relevant to this project -- Communication style preferences -- General workflow habits - -## Research Approach - -This is a **deep research** initialization. Take your time and be thorough (~50+ tool calls). The goal is to genuinely understand the project, not just collect surface-level facts. - -**What to uncover:** -- Tech stack and dependencies (explicit and implicit) -- Project structure and architecture -- Build/test/deploy commands and workflows -- Contributors & team dynamics (who works on what?) -- Commit conventions and branching strategy -- Code evolution (major refactors, architecture changes) -- Pain points (areas with lots of bug fixes) -- Implicit conventions not documented anywhere - -## Research Techniques - -### File-based -- README.md, CONTRIBUTING.md, AGENTS.md, CLAUDE.md -- Package manifests (package.json, Cargo.toml, pyproject.toml, go.mod) -- Config files (.eslintrc, tsconfig.json, .prettierrc) -- CI/CD configs (.github/workflows/) - -### Git-based -- \`git log --oneline -20\` - Recent history -- \`git branch -a\` - Branching strategy -- \`git log --format="%s" -50\` - Commit conventions -- \`git shortlog -sn --all | head -10\` - Main contributors - -### Explore Agent -Fire parallel explore queries for broad understanding: -\`\`\` -Task(explore, "What is the tech stack and key dependencies?") -Task(explore, "What is the project structure? Key directories?") -Task(explore, "How do you build, test, and run this project?") -Task(explore, "What are the main architectural patterns?") -Task(explore, "What conventions or patterns are used?") -\`\`\` - -## How to Do Thorough Research - -**Don't just collect data - analyze and cross-reference.** - -Bad (shallow): -- Run commands, copy output -- List facts without understanding - -Good (thorough): -- Cross-reference findings (if inconsistent, dig deeper) -- Resolve ambiguities (don't leave questions unanswered) -- Read actual file content, not just names -- Look for patterns (what do commits tell you about workflow?) -- Think like a new team member - what would you want to know? - -## Saving Memories - -Use the \`supermemory\` tool for each distinct insight: - -\`\`\` -supermemory(mode: "add", content: "...", type: "...", scope: "project") -\`\`\` - -**Types:** -- \`project-config\` - tech stack, commands, tooling -- \`architecture\` - codebase structure, key components, data flow -- \`learned-pattern\` - conventions specific to this codebase -- \`error-solution\` - known issues and their fixes -- \`preference\` - coding style preferences (use with user scope) - -**Guidelines:** -- Save each distinct insight as a separate memory -- Be concise but include enough context to be useful -- Include the "why" not just the "what" when relevant -- Update memories incrementally as you research (don't wait until the end) - -**Good memories:** -- "Uses Bun runtime and package manager. Commands: bun install, bun run dev, bun test" -- "API routes in src/routes/, handlers in src/handlers/. Hono framework." -- "Auth uses Redis sessions, not JWT. Implementation in src/lib/auth.ts" -- "Never use \`any\` type - strict TypeScript. Use \`unknown\` and narrow." -- "Database migrations must be backward compatible - we do rolling deploys" - -## Upfront Questions - -Before diving in, ask: -1. "Any specific rules I should always follow?" -2. "Preferences for how I communicate? (terse/detailed)" - -## Reflection Phase - -Before finishing, reflect: -1. **Completeness**: Did you cover commands, architecture, conventions, gotchas? -2. **Quality**: Are memories concise and searchable? -3. **Scope**: Did you correctly separate project vs user knowledge? - -Then ask: "I've initialized memory with X insights. Want me to continue refining, or is this good?" - -## Your Task - -1. Ask upfront questions (research depth, rules, preferences) -2. Check existing memories: \`supermemory(mode: "list", scope: "project")\` -3. Research based on chosen depth -4. Save memories incrementally as you discover insights -5. Reflect and verify completeness -6. Summarize what was learned and ask if user wants refinement -`; - const SUPERMEMORY_LOGIN_COMMAND = `--- -description: Authenticate with Supermemory via browser +description: Connect OpenCode to Supermemory --- # Supermemory Login -Run this command to authenticate the user with Supermemory: +Run the browser authentication flow: \`\`\`bash bunx opencode-supermemory@latest login \`\`\` -This will: -1. Start a local server on port 19877 -2. Open the browser to Supermemory's authentication page -3. After the user logs in, save credentials to ~/.supermemory-opencode/credentials.json - -Wait for the command to complete, then inform the user whether authentication succeeded or failed. - -If the user wants to log out instead, tell them to use the /supermemory-logout command. -`; - -const SUPERMEMORY_LOGOUT_COMMAND = `--- -description: Log out from Supermemory and clear credentials ---- - -# Supermemory Logout - -Run this command to log out and clear Supermemory credentials: - -\`\`\`bash -bunx opencode-supermemory@latest logout -\`\`\` +Wait for authentication to finish, then tell the user to restart OpenCode so the plugin and hosted MCP connection load the new credentials. -This will remove the saved credentials from ~/.supermemory-opencode/credentials.json. +If the command says the user is already authenticated, run \`bunx opencode-supermemory@latest status\` and report the result instead of clearing credentials automatically. -Inform the user whether logout succeeded and that they'll need to run /supermemory-login to re-authenticate. +Never print the full API key. +Never recommend disabling TLS verification. `; const SUPERMEMORY_STATUS_COMMAND = `--- @@ -219,9 +48,12 @@ Run this command to check whether OpenCode is connected to Supermemory: bunx opencode-supermemory@latest status \`\`\` -Report the connection status, credential source, API URL, and account information if available. +Then call the \`supermemory_whoAmI\` MCP tool. + +Report API reachability and MCP reachability separately. If \`whoAmI\` is unavailable, say that the MCP tool is unavailable in this OpenCode session and recommend restarting OpenCode. Do not describe that as an API failure. Never print the full API key. +Never recommend disabling TLS verification. `; function createReadline(): readline.Interface { @@ -254,14 +86,33 @@ function findOpencodeConfig(): string | null { return null; } +function isSupermemoryPluginSpecifier(value: unknown): boolean { + if (typeof value !== "string") return false; + if (/^opencode-supermemory(?:@|$)/.test(value)) return true; + if (!value.startsWith("file://")) return false; + + try { + const pluginPath = fileURLToPath(value); + const candidates = [ + join(pluginPath, "package.json"), + join(pluginPath, "..", "package.json"), + join(pluginPath, "..", "..", "package.json"), + ]; + return candidates.some((packagePath) => { + if (!existsSync(packagePath)) return false; + const packageJson = JSON.parse(readFileSync(packagePath, "utf-8")) as { + name?: unknown; + }; + return packageJson.name === "opencode-supermemory"; + }); + } catch { + return false; + } +} + function addPluginToConfig(configPath: string): boolean { try { const content = readFileSync(configPath, "utf-8"); - - if (content.includes("opencode-supermemory")) { - console.log("✓ Plugin already registered in config"); - return true; - } const jsonContent = stripJsoncComments(content); let config: Record; @@ -273,7 +124,11 @@ function addPluginToConfig(configPath: string): boolean { return false; } - const plugins = (config.plugin as string[]) || []; + const plugins = Array.isArray(config.plugin) ? config.plugin : []; + if (plugins.some(isSupermemoryPluginSpecifier)) { + console.log("✓ Plugin already registered in config"); + return true; + } plugins.push(PLUGIN_NAME); config.plugin = plugins; @@ -343,21 +198,22 @@ function configureTuiPlugin(): boolean { return true; } -function createCommands(): boolean { +function configureCommands(): boolean { mkdirSync(OPENCODE_COMMAND_DIR, { recursive: true }); - const initPath = join(OPENCODE_COMMAND_DIR, "supermemory-init.md"); - writeFileSync(initPath, SUPERMEMORY_INIT_COMMAND); - console.log(`✓ Created /supermemory-init command`); + for (const name of [ + "supermemory-init.md", + "supermemory-logout.md", + "supermemory-switch-organization.md", + ]) { + rmSync(join(OPENCODE_COMMAND_DIR, name), { force: true }); + } + console.log("✓ Removed legacy Supermemory commands"); const loginPath = join(OPENCODE_COMMAND_DIR, "supermemory-login.md"); writeFileSync(loginPath, SUPERMEMORY_LOGIN_COMMAND); console.log(`✓ Created /supermemory-login command`); - const logoutPath = join(OPENCODE_COMMAND_DIR, "supermemory-logout.md"); - writeFileSync(logoutPath, SUPERMEMORY_LOGOUT_COMMAND); - console.log(`✓ Created /supermemory-logout command`); - const statusPath = join(OPENCODE_COMMAND_DIR, "supermemory-status.md"); writeFileSync(statusPath, SUPERMEMORY_STATUS_COMMAND); console.log(`✓ Created /supermemory-status command`); @@ -456,18 +312,9 @@ async function install(options: InstallOptions): Promise { configureTuiPlugin(); - // Step 2: Create commands - console.log("\nStep 2: Create /supermemory-init, /supermemory-login, /supermemory-logout, and /supermemory-status commands"); - if (options.tui) { - const shouldCreate = await confirm(rl!, "Add supermemory commands?"); - if (!shouldCreate) { - console.log("Skipped."); - } else { - createCommands(); - } - } else { - createCommands(); - } + // Step 2: Keep authentication and diagnostics discoverable. Memory tools come from MCP. + console.log("\nStep 2: Configure /supermemory-login and /supermemory-status"); + configureCommands(); // Step 3: Configure Oh My OpenCode (if installed) if (isOhMyOpencodeInstalled()) { @@ -557,8 +404,7 @@ function getKeySource(): string { function getDevTlsHint(apiUrl: string): string | null { if (!apiUrl.includes(".dev.supermemory.ai")) return null; - if (process.env.NODE_EXTRA_CA_CERTS) return null; - return "Dev API TLS: set NODE_EXTRA_CA_CERTS to your Portless CA before starting OpenCode."; + return "The saved credential points to a development API endpoint. Do not disable TLS verification; run `bunx opencode-supermemory@latest logout` followed by `bunx opencode-supermemory@latest login` to obtain fresh production credentials."; } async function fetchJson(apiUrl: string, path: string): Promise { @@ -569,6 +415,7 @@ async function fetchJson(apiUrl: string, path: string): Promise Authorization: `Bearer ${SUPERMEMORY_API_KEY}`, "x-sm-source": "opencode", }, + signal: AbortSignal.timeout(8_000), }); if (!response.ok) return null; return await response.json(); @@ -577,6 +424,59 @@ async function fetchJson(apiUrl: string, path: string): Promise } } +interface ApiProbe { + connected: boolean; + status: number | null; + detail: string; +} + +async function probeApi(apiUrl: string, containerTag: string): Promise { + if (!SUPERMEMORY_API_KEY) { + return { connected: false, status: null, detail: "not attempted; no API key" }; + } + + try { + const response = await fetch(`${apiUrl}/v4/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${SUPERMEMORY_API_KEY}`, + "Content-Type": "application/json", + "x-sm-source": "opencode", + }, + body: JSON.stringify({ + containerTag, + q: "connectivity probe", + }), + signal: AbortSignal.timeout(8_000), + }); + + if (response.status === 200 || response.status === 404) { + return { + connected: true, + status: response.status, + detail: response.status === 200 + ? "reachable, key valid" + : "reachable, key valid; no profile data yet", + }; + } + if (response.status === 401 || response.status === 403) { + return { + connected: false, + status: response.status, + detail: "reachable, key rejected", + }; + } + return { + connected: false, + status: response.status, + detail: "reachable, unexpected response", + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { connected: false, status: null, detail: `network error: ${detail}` }; + } +} + async function getAccountInfo(apiUrl: string): Promise<{ email?: string; name?: string; userId?: string; orgName?: string }> { const data = await fetchJson(apiUrl, "/v3/session"); if (!data || typeof data !== "object") return {}; @@ -600,35 +500,31 @@ async function status(): Promise { lines.push("supermemory status"); lines.push(""); + lines.push(`Authenticated: ${isConfigured() ? "yes" : "no"}`); lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); lines.push(`API key: ${maskKey(SUPERMEMORY_API_KEY)} (${getKeySource()})`); lines.push(`API URL: ${apiUrl}`); - lines.push("Memory scope: unified project container with personal/project metadata"); - lines.push(`Recall mode: ${CONFIG.recallMode}`); - lines.push(`Recall directive: ${CONFIG.recallMode === "advisory" && CONFIG.recallDirective ? "custom" : "default"}`); - lines.push(`Capture cadence: ${CONFIG.captureEveryNTurns > 0 ? `every ${CONFIG.captureEveryNTurns} turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"} + session end` : "session end only"}`); + lines.push("Memory scope: one project container with metadata scopes"); + lines.push(`Auto-recall: ${CONFIG.recallMode === "direct" ? "on" : CONFIG.recallMode}`); + lines.push(`Auto-capture: ${CONFIG.captureEveryNTurns > 0 ? `every ${CONFIG.captureEveryNTurns} completed turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"}` : "at session end"}`); lines.push(`Project container: ${tags.canonical}`); - lines.push(`Personal reads: ${tags.personalReads.join(", ")}`); - lines.push(`Project reads: ${tags.projectReads.join(", ")}`); + lines.push(`Reads (including legacy): ${tags.allReads.join(", ")}`); + lines.push("MCP registration: enabled by the plugin"); if (!isConfigured()) { lines.push(""); - lines.push("Run /supermemory-login to connect, or set SUPERMEMORY_API_KEY."); + lines.push("Run `bunx opencode-supermemory@latest login` to connect, then restart OpenCode."); console.log(lines.join("\n")); return 0; } - const client = new SupermemoryClient(); - const [profileResult, accountInfo] = await Promise.all([ - client.getProfileScoped( - tags.canonical, - tags.personalReads, - "personal", - ), + const [apiProbe, accountInfo] = await Promise.all([ + probeApi(apiUrl, tags.canonical), getAccountInfo(apiUrl), ]); - lines[2] = profileResult.success ? "Connected: yes" : "Connected: no"; + lines[3] = `Connected: ${apiProbe.connected ? "yes" : "no"}`; + lines.push(`API reachability: ${apiProbe.status ?? "unavailable"} — ${apiProbe.detail}`); if (accountInfo.email || accountInfo.name || accountInfo.userId || accountInfo.orgName) { lines.push(""); @@ -642,11 +538,12 @@ async function status(): Promise { lines.push("Account: authenticated API key (account details unavailable from API key)"); } - if (!profileResult.success) { - lines.push(""); - lines.push(`Connection check failed: ${profileResult.error}`); + if (!apiProbe.connected) { const devTlsHint = getDevTlsHint(apiUrl); - if (devTlsHint) lines.push(devTlsHint); + if (devTlsHint) { + lines.push(""); + lines.push(devTlsHint); + } } console.log(lines.join("\n")); diff --git a/src/config.ts b/src/config.ts index b9f401b..db32961 100644 --- a/src/config.ts +++ b/src/config.ts @@ -132,13 +132,13 @@ function loadRawConfig(): { config: SupermemoryConfig; existed: boolean } { const { config: fileConfig, existed: configExisted } = loadRawConfig(); -function getApiKey(): string | undefined { +export function getApiKeyValue(): string | undefined { if (process.env.SUPERMEMORY_API_KEY) return process.env.SUPERMEMORY_API_KEY; if (fileConfig.apiKey) return fileConfig.apiKey; return loadCredentials()?.apiKey; } -export const SUPERMEMORY_API_KEY = getApiKey(); +export const SUPERMEMORY_API_KEY = getApiKeyValue(); function normalizeBaseUrl(baseUrl: unknown): string | null { if (typeof baseUrl !== "string" || !baseUrl.trim()) return null; diff --git a/src/index.ts b/src/index.ts index 9a80e97..2f1139f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,7 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Part, Permission } from "@opencode-ai/sdk"; -import { tool } from "@opencode-ai/plugin"; +import { fileURLToPath } from "node:url"; -import { AGENT_ENTITY_CONTEXT } from "./services/entity-context.js"; import { supermemoryClient } from "./services/client.js"; import { formatContextForPrompt, @@ -16,18 +15,12 @@ import { RecallSessionCache, } from "./services/recall.js"; import { createMemoryActivityReporter } from "./services/activity.js"; -import { - formatRecallHit, - normalizeRecallResult, -} from "./services/recall-results.js"; import { getTags } from "./services/tags.js"; -import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; import { isConfigured, CONFIG, PLUGIN_VERSION } from "./config.js"; import { log } from "./services/logger.js"; import { checkNpmUpdate } from "./services/version-check.js"; -import type { MemoryScope, MemoryType } from "./types/index.js"; const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; const INLINE_CODE_PATTERN = /`[^`]+`/g; @@ -35,15 +28,26 @@ const INLINE_CODE_PATTERN = /`[^`]+`/g; const MEMORY_KEYWORD_PATTERN = new RegExp(`\\b(${CONFIG.keywordPatterns.join("|")})\\b`, "i"); const MEMORY_NUDGE_MESSAGE = `[MEMORY TRIGGER DETECTED] -The user wants you to remember something. You MUST use the \`supermemory\` tool with \`mode: "add"\` to save this information. +The user wants you to remember something. You MUST use the \`supermemory_add_memory\` MCP tool to save this information. Extract the key information the user wants remembered and save it as a concise, searchable memory. -- Use \`scope: "project"\` for project-specific preferences (e.g., "run lint with tests") -- Use \`scope: "user"\` for personal preferences in this project (e.g., "prefers concise responses") -- Choose an appropriate \`type\`: "preference", "project-config", "learned-pattern", etc. +- Pass this project's container tag when the information is project-specific. +- Omit the container tag for account-level personal preferences unless the user named a space. DO NOT skip this step. The user explicitly asked you to remember.`; const UPDATE_COMMAND = "bunx opencode-supermemory@latest install"; +const MCP_PROXY_PATH = fileURLToPath(new URL("./mcp-proxy.js", import.meta.url)); +const SUPERMEMORY_MCP_PREFIX = "supermemory_"; +const READ_ONLY_MCP_TOOLS = new Set([ + "search_memory", + "listSpaces", + "listMemories", + "listDocuments", + "getDocument", + "whoAmI", + "memory-graph", + "fetch-graph-data", +]); function removeCodeBlocks(text: string): string { return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, ""); @@ -54,22 +58,27 @@ function detectMemoryKeyword(text: string): boolean { return MEMORY_KEYWORD_PATTERN.test(textWithoutCode); } -function isSupermemoryRecallSearch(input: Permission): boolean { +function getPermissionToolName(input: Permission): string { const type = String((input as { type?: unknown }).type ?? ""); const title = String((input as { title?: unknown }).title ?? "").toLowerCase(); const metadata = ((input as { metadata?: Record }).metadata ?? {}) as Record; const toolName = String(metadata.tool ?? metadata.toolName ?? type); - const isSupermemory = - type === "supermemory" || toolName === "supermemory" || title.includes("supermemory"); - if (!isSupermemory) return false; - - const args = (metadata.args ?? metadata.input ?? metadata.arguments ?? metadata) as Record< - string, - unknown - >; - return String(args.mode ?? "") === "search"; + if (toolName.startsWith(SUPERMEMORY_MCP_PREFIX)) return toolName; + if (!title.includes("supermemory")) return ""; + return toolName; +} + +function getSupermemoryMcpTool(toolName: string): string | null { + return toolName.startsWith(SUPERMEMORY_MCP_PREFIX) + ? toolName.slice(SUPERMEMORY_MCP_PREFIX.length) + : null; +} + +function isReadOnlySupermemoryPermission(input: Permission): boolean { + const tool = getSupermemoryMcpTool(getPermissionToolName(input)); + return tool !== null && READ_ONLY_MCP_TOOLS.has(tool); } export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { @@ -122,6 +131,17 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { : null; return { + config: async (config) => { + config.mcp = { + ...(config.mcp ?? {}), + supermemory: { + type: "local", + command: ["node", MCP_PROXY_PATH], + enabled: true, + }, + }; + }, + "chat.message": async (input, output) => { if (!isConfigured()) return; @@ -247,7 +267,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { if (directRecall.status === "recalled") { activity.recalled(directRecall.count, directRecall.tokens); } else if (directRecall.status === "unavailable") { - activity.recallUnavailable(); + activity.recallUnavailable(directRecall.error); } if (updateInfo) activity.updateAvailable(updateInfo); @@ -291,324 +311,13 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { } }, - tool: { - supermemory: tool({ - description: - "Manage and query the Supermemory persistent memory system. Use 'search' to find relevant memories, 'add' to store new knowledge, 'profile' to view user profile, 'list' to see recent memories, 'forget' to remove a memory.", - args: { - mode: tool.schema - .enum(["add", "search", "profile", "list", "forget", "help"]) - .optional(), - content: tool.schema.string().optional(), - query: tool.schema.string().optional(), - type: tool.schema - .enum([ - "project-config", - "architecture", - "error-solution", - "preference", - "learned-pattern", - "conversation", - ]) - .optional(), - scope: tool.schema.enum(["user", "project"]).optional(), - memoryId: tool.schema.string().optional(), - limit: tool.schema.number().optional(), - }, - async execute(args: { - mode?: string; - content?: string; - query?: string; - type?: MemoryType; - scope?: MemoryScope; - memoryId?: string; - limit?: number; - }) { - if (!isConfigured()) { - return JSON.stringify({ - success: false, - error: - "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", - }); - } - - const mode = args.mode || "help"; - - try { - switch (mode) { - case "help": { - return JSON.stringify({ - success: true, - message: "Supermemory Usage Guide", - commands: [ - { - command: "add", - description: "Store a new memory", - args: ["content", "type?", "scope?"], - }, - { - command: "search", - description: "Search memories", - args: ["query", "scope?"], - }, - { - command: "profile", - description: "View user profile", - args: ["query?"], - }, - { - command: "list", - description: "List recent memories", - args: ["scope?", "limit?"], - }, - { - command: "forget", - description: "Remove a memory", - args: ["memoryId", "scope?"], - }, - ], - scopes: { - user: "Personal preferences and knowledge for this project", - project: "Project-specific knowledge (default)", - }, - types: [ - "project-config", - "architecture", - "error-solution", - "preference", - "learned-pattern", - "conversation", - ], - }); - } - - case "add": { - if (!args.content) { - return JSON.stringify({ - success: false, - error: "content parameter is required for add mode", - }); - } - - const sanitizedContent = stripPrivateContent(args.content); - if (isFullyPrivate(args.content)) { - return JSON.stringify({ - success: false, - error: "Cannot store fully private content", - }); - } - - const scope = args.scope || "project"; - const internalScope = - scope === "user" ? "personal" : "project"; - - const result = await supermemoryClient.addMemory( - sanitizedContent, - tags.canonical, - { - type: args.type, - project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: internalScope, - sm_capture_mode: "tool", - }, - { entityContext: AGENT_ENTITY_CONTEXT } - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to add memory", - }); - } - - activity.saved(); - - return JSON.stringify({ - success: true, - message: `Memory added to ${scope} scope`, - id: result.id, - scope, - type: args.type, - }); - } - - case "search": { - if (!args.query) { - return JSON.stringify({ - success: false, - error: "query parameter is required for search mode", - }); - } - - const scope = args.scope; - - if (scope === "user") { - const result = await supermemoryClient.searchMemoriesScoped( - args.query, - tags.canonical, - tags.personalReads, - "personal", - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults(args.query, scope, result, args.limit); - } - - if (scope === "project") { - const result = await supermemoryClient.searchMemoriesScoped( - args.query, - tags.canonical, - tags.projectReads, - "project", - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults(args.query, scope, result, args.limit); - } - - const result = await supermemoryClient.searchMemoriesMany( - args.query, - tags.allReads, - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults( - args.query, - undefined, - result, - args.limit, - ); - } - - case "profile": { - const result = await supermemoryClient.getProfileScoped( - tags.canonical, - tags.personalReads, - "personal", - args.query, - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to fetch profile", - }); - } - - return JSON.stringify({ - success: true, - profile: { - static: result.profile?.static || [], - dynamic: result.profile?.dynamic || [], - }, - }); - } - - case "list": { - const scope = args.scope || "project"; - const limit = args.limit || 20; - const internalScope = - scope === "user" ? "personal" : "project"; - const readTags = - scope === "user" ? tags.personalReads : tags.projectReads; - - const result = await supermemoryClient.listMemoriesScoped( - tags.canonical, - readTags, - internalScope, - limit, - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to list memories", - }); - } - - const memories = result.memories || []; - return JSON.stringify({ - success: true, - scope, - count: memories.length, - memories: memories.map((m) => ({ - id: m.id, - content: m.summary, - createdAt: m.createdAt, - metadata: m.metadata, - })), - }); - } - - case "forget": { - if (!args.memoryId) { - return JSON.stringify({ - success: false, - error: "memoryId parameter is required for forget mode", - }); - } - - const scope = args.scope || "project"; - const readTags = - scope === "user" - ? tags.personalReads - : scope === "project" - ? tags.projectReads - : tags.allReads; - - const result = await supermemoryClient.deleteMemory( - args.memoryId, - [tags.canonical, ...readTags], - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to delete memory", - }); - } - - return JSON.stringify({ - success: true, - message: `Memory ${args.memoryId} removed from ${scope} scope`, - }); - } - - default: - return JSON.stringify({ - success: false, - error: `Unknown mode: ${mode}`, - }); - } - } catch (error) { - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - }), - }, "permission.ask": async (input, output) => { if (!isConfigured()) return; try { - if (isSupermemoryRecallSearch(input)) { + if (isReadOnlySupermemoryPermission(input)) { output.status = "allow"; - log("permission.ask: auto-allowing supermemory recall search"); + log("permission.ask: auto-allowing read-only supermemory MCP tool"); } } catch (error) { log("permission.ask: ERROR", { error: String(error) }); @@ -616,17 +325,20 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }, "tool.execute.before": async (input, output) => { - if (input.tool !== "supermemory") return; - const args = output.args as { mode?: unknown; query?: unknown }; - if (args.mode === "search") { + const toolName = getSupermemoryMcpTool(input.tool); + if (!toolName || !READ_ONLY_MCP_TOOLS.has(toolName)) return; + const args = output.args as { query?: unknown }; + if (toolName === "search_memory") { activity.recalling( typeof args.query === "string" ? args.query : undefined, ); + } else { + activity.recalling(); } }, "tool.execute.after": async (input, output) => { - if (input.tool !== "supermemory") return; + if (getSupermemoryMcpTool(input.tool) !== "search_memory") return; try { const result = JSON.parse(output.output) as { success?: boolean; @@ -673,50 +385,3 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }, }; }; - -function formatSearchResults( - query: string, - scope: string | undefined, - results: { - results?: Array<{ - id?: string; - memory?: string; - chunk?: string; - content?: string; - text?: string; - context?: unknown; - similarity?: number; - score?: number; - title?: string; - filepath?: string; - metadata?: Record | null; - }>; - }, - limit?: number -): string { - const memoryResults = (results.results || []) - .map((result) => normalizeRecallResult(result)) - .filter((hit): hit is NonNullable => hit !== null) - .slice(0, limit ?? 10); - return JSON.stringify({ - success: true, - query, - scope, - count: memoryResults.length, - results: memoryResults.map((hit) => { - const r = hit.result; - const result = { - content: formatRecallHit(hit), - ...(hit.similarity === undefined - ? {} - : { similarity: Math.round(hit.similarity * 100) }), - ...(hit.title ? { title: hit.title } : {}), - ...(hit.filepath ? { filepath: hit.filepath } : {}), - }; - - return r.memory === undefined - ? { ...result, forgettable: false } - : { id: r.id, ...result, forgettable: true }; - }), - }); -} diff --git a/src/mcp-proxy.ts b/src/mcp-proxy.ts new file mode 100644 index 0000000..f4e8009 --- /dev/null +++ b/src/mcp-proxy.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env node +import { createInterface } from "node:readline"; +import { getApiKeyValue } from "./config.js"; + +const MCP_URL = + process.env.SUPERMEMORY_MCP_URL || "https://mcp.supermemory.ai/mcp"; +const REQUEST_TIMEOUT_MS = 30_000; + +let sessionId: string | null = null; + +interface JsonRpcMessage { + id?: string | number | null; + [key: string]: unknown; +} + +function send(message: unknown): void { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function sendError( + id: JsonRpcMessage["id"], + code: number, + message: string, +): void { + if (id === undefined || id === null) return; + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function emitSseData(body: string): void { + for (const event of body.split("\n\n")) { + for (const line of event.split("\n")) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data) process.stdout.write(`${data}\n`); + } + } +} + +async function forward(message: JsonRpcMessage, apiKey: string): Promise { + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (sessionId) headers["Mcp-Session-Id"] = sessionId; + + const response = await fetch(MCP_URL, { + method: "POST", + headers, + body: JSON.stringify(message), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + const nextSessionId = response.headers.get("mcp-session-id"); + if (nextSessionId) sessionId = nextSessionId; + + if (response.status === 202) return; + if (!response.ok) { + const body = await response.text().catch(() => ""); + sendError( + message.id, + -32000, + `Supermemory MCP ${response.status}: ${body.slice(0, 200) || "request failed"}`, + ); + return; + } + + const contentType = response.headers.get("content-type") || ""; + const body = await response.text(); + if (!body.trim()) return; + + if (contentType.includes("text/event-stream")) emitSseData(body); + else process.stdout.write(`${body.trim()}\n`); +} + +function main(): void { + const apiKey = getApiKeyValue(); + let queue = Promise.resolve(); + const lines = createInterface({ input: process.stdin }); + + lines.on("line", (line) => { + if (!line.trim()) return; + + let message: JsonRpcMessage; + try { + message = JSON.parse(line) as JsonRpcMessage; + } catch { + return; + } + + queue = queue.then(async () => { + if (!apiKey) { + sendError( + message.id, + -32001, + "Supermemory is not authenticated. Run `bunx opencode-supermemory@latest login`, restart OpenCode, or set SUPERMEMORY_API_KEY.", + ); + return; + } + + try { + await forward(message, apiKey); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + sendError(message.id, -32000, `Supermemory MCP proxy error: ${detail}`); + } + }); + }); + + lines.on("close", () => { + queue.finally(() => process.exit(0)); + }); +} + +main(); diff --git a/src/services/activity.ts b/src/services/activity.ts index df5bb00..79aee60 100644 --- a/src/services/activity.ts +++ b/src/services/activity.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { log } from "./logger.js"; +import { getUserFriendlyError } from "./error-helpers.js"; import type { UpdateInfo } from "./version-check.js"; type ToastVariant = "info" | "success" | "warning" | "error"; @@ -14,7 +15,7 @@ interface ToastClient { export interface MemoryActivityReporter { recalling(query?: string): void; recalled(count: number, tokens: number): void; - recallUnavailable(): void; + recallUnavailable(error?: string): void; saved(): void; updateAvailable(info: UpdateInfo): void; } @@ -55,9 +56,9 @@ export function createMemoryActivityReporter( 3_000, ); }, - recallUnavailable() { + recallUnavailable(error) { show( - "recall unavailable; continuing without recalled context", + `recall failed: ${getUserFriendlyError(error).slice(0, 100)}`, "warning", 3_000, ); diff --git a/src/services/error-helpers.ts b/src/services/error-helpers.ts new file mode 100644 index 0000000..a386945 --- /dev/null +++ b/src/services/error-helpers.ts @@ -0,0 +1,27 @@ +const NETWORK_ERROR_PATTERN = + /abort|cert|connect|econn|enotfound|fetch failed|network|self.signed|timeout|tls/i; + +export function getUserFriendlyError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error ?? ""); + + if (NETWORK_ERROR_PATTERN.test(message)) { + return "Supermemory unreachable (network) — continuing without memory."; + } + if (/\b400\b/.test(message)) { + return "Bad request — your API key or request format may be invalid. Check your key at https://console.supermemory.ai"; + } + if (/\b401\b/.test(message)) { + return "Authentication failed — your API key may be expired or revoked. Re-authenticate or check https://console.supermemory.ai"; + } + if (/\b403\b/.test(message)) { + return "Permission denied — this feature may require a different Supermemory plan. Check https://supermemory.ai/pricing"; + } + if (/\b429\b/.test(message)) { + return "Rate limited — too many requests. Will retry on the next prompt."; + } + if (/\b5\d\d\b/.test(message)) { + return "Supermemory service is temporarily unavailable. Will retry on the next prompt."; + } + + return message || "Unknown error"; +} diff --git a/src/services/recall.test.ts b/src/services/recall.test.ts index 3a74ccc..8b793b8 100644 --- a/src/services/recall.test.ts +++ b/src/services/recall.test.ts @@ -139,5 +139,17 @@ describe("direct recall", () => { }); expect(unavailable.status).toBe("unavailable"); expect(unavailable.context).toBe(""); + expect(unavailable.error).toBe("offline"); + + const thrown = await buildDirectRecallResult({ + prompt: "what did we decide about the build system", + sessionID: "session-thrown", + cache: new RecallSessionCache(), + search: async () => { + throw new Error("network failed"); + }, + }); + expect(thrown.status).toBe("unavailable"); + expect(thrown.error).toBe("network failed"); }); }); diff --git a/src/services/recall.ts b/src/services/recall.ts index 37ec1f6..2813828 100644 --- a/src/services/recall.ts +++ b/src/services/recall.ts @@ -128,6 +128,7 @@ export interface DirectRecallResult { status: "skipped" | "empty" | "recalled" | "unavailable"; count: number; tokens: number; + error?: string; } export async function buildDirectRecallResult(options: { @@ -144,7 +145,10 @@ export async function buildDirectRecallResult(options: { const searchPromise = query ? Promise.resolve() .then(() => options.search(query)) - .catch(() => null) + .catch((error) => ({ + success: false as const, + error: error instanceof Error ? error.message : String(error), + })) : null; if (options.suppressTexts) { @@ -159,7 +163,13 @@ export async function buildDirectRecallResult(options: { const response = await searchPromise; if (!response?.success) { - return { context: "", status: "unavailable", count: 0, tokens: 0 }; + return { + context: "", + status: "unavailable", + count: 0, + tokens: 0, + error: response?.error, + }; } const hits = normalizeRecallResults(response.results ?? []); const freshHits = options.cache.takeFresh(options.sessionID, hits); @@ -174,8 +184,14 @@ export async function buildDirectRecallResult(options: { count: freshHits.length, tokens: Math.round(context.length / 4), }; - } catch { - return { context: "", status: "unavailable", count: 0, tokens: 0 }; + } catch (error) { + return { + context: "", + status: "unavailable", + count: 0, + tokens: 0, + error: error instanceof Error ? error.message : String(error), + }; } } diff --git a/src/tui.tsx b/src/tui.tsx index 16e465b..e6a537f 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -3,85 +3,15 @@ import type { TuiPlugin, TuiPluginModule, } from "@opencode-ai/plugin/tui"; -import { createSignal } from "solid-js"; -const DEFAULT_ACTIVITY = "ready"; - -interface RecallActivity { - count: number; - tokens: number; -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" - ? (value as Record) - : null; -} - -export function getRecallActivity(part: unknown): RecallActivity | null { - const value = asRecord(part); - if (!value || value.type !== "text") return null; - - const metadata = asRecord(value.metadata); - const supermemory = asRecord(metadata?.supermemory); - if (supermemory?.activity === "recalled") { - const count = supermemory.count; - const tokens = supermemory.tokens; - if (typeof count === "number" && typeof tokens === "number") { - return { count, tokens }; - } - } - - const text = typeof value.text === "string" ? value.text : ""; - if (!text.includes("")) return null; - const count = text.split("\n").filter((line) => line.startsWith("- ◪ ")).length; - return count > 0 - ? { count, tokens: Math.round(text.length / 4) } - : null; -} - -function recallLabel(activity: RecallActivity): string { - return `recalled ${activity.count} ${activity.count === 1 ? "memory" : "memories"} (${activity.tokens} tok)`; -} +const SUPERMEMORY_PURPLE = "#a78bfa"; const tui: TuiPlugin = async (api) => { - const busySessions = new Set(); - const [running, setRunning] = createSignal(false); - const [activity, setActivity] = createSignal(DEFAULT_ACTIVITY); - - api.event.on("session.status", (event) => { - const { sessionID, status } = event.properties; - if (status.type === "busy" || status.type === "retry") { - busySessions.add(sessionID); - } else { - busySessions.delete(sessionID); - } - setRunning(busySessions.size > 0); - }); - - api.event.on("session.idle", (event) => { - busySessions.delete(event.properties.sessionID); - setRunning(busySessions.size > 0); - }); - - api.event.on("message.part.updated", (event) => { - const recalled = getRecallActivity(event.properties.part); - if (recalled) setActivity(recallLabel(recalled)); - }); - - api.event.on("tui.toast.show", (event) => { - const message = event.properties.message; - if (!message.startsWith("◪ supermemory · ")) return; - setActivity(message.slice("◪ supermemory · ".length)); - }); - api.slots.register({ slots: { app_bottom: () => ( - - - {`◪ supermemory · ${running() ? "running" : activity()}`} - + + ◪ supermemory ), },