diff --git a/README.md b/README.md index 0765e61..4ea23e8 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,17 @@ Kimi Code deploys the mnemon skill, prompt files, and native lifecycle hooks to `~/.kimi-code/` or `$KIMI_CODE_HOME/`. The integration registers `SessionStart`, `UserPromptSubmit`, and `Stop` hooks in `config.toml`. +### [OpenCode](https://opencode.ai/) + +```bash +mnemon setup --target opencode --yes +``` + +OpenCode deploys the mnemon skill to `.opencode/skills/`, registers the +generated guide through `opencode.json` instructions, and installs a native +plugin in `.opencode/plugins/`. The plugin injects recall context before chat +requests and adds Mnemon guidance to session compaction. + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash @@ -263,7 +274,7 @@ memory is useful. - **Zero user-side operation** — install once; supported runtimes can use hooks, minimal runtimes can use persistent rules - **LLM-supervised** — the host LLM decides what to remember, update, and forget; no embedded LLM, no API keys -- **Multi-framework support** — Claude Code, Codex, Cursor, TRAE/TRAE Work, Qoder/QoderWork, CodeBuddy, WorkBuddy, Kimi Code, and Hermes Agent (hooks), OpenClaw (plugins), Pi (extensions), Nanobot (skills), and more +- **Multi-framework support** — Claude Code, Codex, Cursor, TRAE/TRAE Work, Qoder/QoderWork, CodeBuddy, WorkBuddy, Kimi Code, OpenCode, and Hermes Agent (hooks/plugins), OpenClaw (plugins), Pi (extensions), Nanobot (skills), and more - **Markdown-installable harness** — `SKILL.md`, `INSTALL.md`, `GUIDELINE.md`, and four lifecycle reminders - **Four-graph architecture** — temporal, entity, causal, and semantic edges, not just vector similarity - **Intent-native protocol** — three primitives (`remember`, `link`, `recall`) map to the LLM's cognitive vocabulary, not database syntax; structured JSON output with signal transparency @@ -315,7 +326,7 @@ All your local agentic AIs — across sessions and frameworks — sharing one po The foundation is in place: a single `~/.mnemon` database that any agent can read and write. Claude Code, Codex, Cursor, TRAE/TRAE Work, Qoder/QoderWork, -CodeBuddy, WorkBuddy, Kimi Code, and Hermes Agent setup automate hook installation; +CodeBuddy, WorkBuddy, Kimi Code, OpenCode, and Hermes Agent setup automate hook/plugin installation; OpenClaw can use plugin hooks; Pi integrates via native skills and TypeScript lifecycle extensions; Nanobot integrates via skill files; NanoClaw integrates via container skills and volume mounts. The same harness can be installed in any diff --git a/cmd/setup.go b/cmd/setup.go index f0c0f05..1cf8457 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -22,11 +22,11 @@ var setupCmd = &cobra.Command{ Short: "Deploy mnemon into LLM CLI environments", Long: `Detect installed LLM CLIs and deploy mnemon integration. -By default, installs to project-local config (.claude/, .codex/, .cursor/, .trae/, .qoder/, .codebuddy/, .workbuddy/, .openclaw/, .nanobot/, .pi/). -Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.trae/, ~/.qoder/, ~/.codebuddy/, ~/.workbuddy/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). +By default, installs to project-local config (.claude/, .codex/, .cursor/, .trae/, .qoder/, .codebuddy/, .workbuddy/, .opencode/, .openclaw/, .nanobot/, .pi/). +Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.trae/, ~/.qoder/, ~/.codebuddy/, ~/.workbuddy/, ~/.config/opencode/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). Hermes Agent, QoderWork, and Kimi Code use native user config at ~/.hermes/, ~/.qoderwork/, and ~/.kimi-code/. -Supported environments: Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, WorkBuddy, Kimi Code, OpenClaw, Nanobot, Pi, Hermes Agent. +Supported environments: Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, WorkBuddy, Kimi Code, OpenCode, OpenClaw, Nanobot, Pi, Hermes Agent. Examples: mnemon setup # Interactive: project-local install @@ -39,6 +39,7 @@ Examples: mnemon setup --target codebuddy # Non-interactive: CodeBuddy skill and hooks mnemon setup --target workbuddy # Non-interactive: WorkBuddy skill and hooks mnemon setup --target kimi # Non-interactive: Kimi Code skill and hooks + mnemon setup --target opencode # Non-interactive: OpenCode skill and plugin mnemon setup --target hermes # Non-interactive: Hermes Agent only mnemon setup --eject # Interactive: remove integrations mnemon setup --eject --target claude-code # Non-interactive: remove Claude Code only @@ -47,7 +48,7 @@ Examples: } func init() { - setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, workbuddy, kimi, openclaw, nanobot, pi, hermes)") + setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, workbuddy, kimi, opencode, openclaw, nanobot, pi, hermes)") setupCmd.Flags().BoolVar(&setupEject, "eject", false, "remove mnemon integrations") setupCmd.Flags().BoolVar(&setupYes, "yes", false, "auto-confirm all prompts (CI-friendly)") setupCmd.Flags().BoolVar(&setupGlobal, "global", false, "install to user-wide config instead of project-local config") @@ -55,8 +56,8 @@ func init() { } func runSetup(cmd *cobra.Command, args []string) error { - if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "trae" && setupTarget != "qoder" && setupTarget != "qoderwork" && setupTarget != "codebuddy" && setupTarget != "workbuddy" && setupTarget != "kimi" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { - return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, workbuddy, kimi, openclaw, nanobot, pi, or hermes)", setupTarget) + if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "trae" && setupTarget != "qoder" && setupTarget != "qoderwork" && setupTarget != "codebuddy" && setupTarget != "workbuddy" && setupTarget != "kimi" && setupTarget != "opencode" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { + return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, workbuddy, kimi, opencode, openclaw, nanobot, pi, or hermes)", setupTarget) } envs := setup.DetectEnvironments(setupGlobal) @@ -92,7 +93,7 @@ func runInstallFlow(envs []setup.Environment) error { if len(detected) == 0 { fmt.Println("\nNo supported LLM CLI environments detected.") - fmt.Println("Install Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, WorkBuddy, Kimi Code, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") + fmt.Println("Install Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, WorkBuddy, Kimi Code, OpenCode, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") return nil } @@ -150,6 +151,8 @@ func installEnv(env *setup.Environment) error { err = installWorkBuddy(env) case "kimi": err = installKimi(env) + case "opencode": + err = installOpenCode(env) case "openclaw": err = installOpenClaw(env) case "nanobot": @@ -902,6 +905,74 @@ func installKimi(env *setup.Environment) error { return nil } +// ─── OpenCode ─────────────────────────────────────────────────────── + +func installOpenCode(env *setup.Environment) error { + configDir := env.ConfigDir + + if !setupGlobal && !setupYes && setup.IsInteractive() { + home := setup.HomeDir() + localDir := ".opencode" + globalDir := home + "/.config/opencode" + idx := setup.SelectOne("Install scope", + []string{ + fmt.Sprintf("Local — this project only (%s/ + opencode.json)", localDir), + fmt.Sprintf("Global — all projects (%s/)", globalDir), + }, 0) + if idx == 1 { + configDir = globalDir + } else { + configDir = localDir + } + } + + fmt.Printf("\nSetting up OpenCode (%s)...\n", configDir) + + fmt.Println("\n[1/3] Skill") + if path, err := setup.OpenCodeWriteSkill(configDir); err != nil { + setup.StatusError(0, 0, "Skill", err) + return err + } else { + setup.StatusOK(0, 0, "Skill", path) + } + + fmt.Println("\n[2/3] Prompts") + var promptPath string + if path, err := setup.WritePromptFiles(); err != nil { + setup.StatusError(0, 0, "Prompts", err) + return err + } else { + setup.StatusOK(0, 0, "Prompts", path) + promptPath = path + } + if path, err := setup.OpenCodeRegisterInstructions(configDir, promptPath); err != nil { + setup.StatusError(0, 0, "Instructions", err) + return err + } else { + setup.StatusUpdated(0, 0, "Instructions", path) + } + + fmt.Println("\n[3/3] Plugin") + if path, err := setup.OpenCodeWritePlugin(configDir); err != nil { + setup.StatusError(0, 0, "Plugin", err) + return err + } else { + setup.StatusOK(0, 0, "Plugin", path) + } + + fmt.Println() + fmt.Println("Setup complete!") + fmt.Printf(" Skill %s/skills/mnemon/SKILL.md\n", configDir) + fmt.Printf(" Plugin %s/plugins/mnemon.js\n", configDir) + fmt.Printf(" Instructions %s\n", setup.OpenCodeConfigPath(configDir)) + fmt.Printf(" Prompts %s/ (guide.md, skill.md)\n", promptPath) + fmt.Println() + fmt.Println("Restart OpenCode to activate the mnemon skill, instructions, and plugin.") + fmt.Println("Run 'mnemon setup --eject --target opencode' to remove.") + + return nil +} + // ─── OpenClaw ─────────────────────────────────────────────────────── func installOpenClaw(env *setup.Environment) error { @@ -1378,6 +1449,13 @@ func ejectEnv(env *setup.Environment) error { return errs[0] } + case "opencode": + errs := setup.OpenCodeEject(env.ConfigDir) + ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") + if len(errs) > 0 { + return errs[0] + } + case "openclaw": errs := setup.OpenClawEject(env.ConfigDir) ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") diff --git a/docs/USAGE.md b/docs/USAGE.md index dcc80ea..b7b0e5d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -37,6 +37,7 @@ mnemon setup --target qoderwork mnemon setup --target codebuddy mnemon setup --target workbuddy mnemon setup --target kimi +mnemon setup --target opencode mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -52,8 +53,8 @@ mnemon setup --eject --target claude-code | Flag | Default | Description | |---|---|---| -| `--global` | `false` | Install to user-wide config instead of project-local (recommended for Nanobot: installs to `~/.nanobot/workspace/`; Pi installs to `~/.pi/agent/`; Hermes installs to `~/.hermes/`; QoderWork installs to `~/.qoderwork/`; Kimi Code installs to `~/.kimi-code/` or `$KIMI_CODE_HOME/`) | -| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `cursor`, `trae`, `qoder`, `qoderwork`, `codebuddy`, `workbuddy`, `kimi`, `openclaw`, `nanobot`, `pi`, or `hermes` | +| `--global` | `false` | Install to user-wide config instead of project-local (recommended for Nanobot: installs to `~/.nanobot/workspace/`; Pi installs to `~/.pi/agent/`; Hermes installs to `~/.hermes/`; QoderWork installs to `~/.qoderwork/`; Kimi Code installs to `~/.kimi-code/` or `$KIMI_CODE_HOME/`; OpenCode installs to `~/.config/opencode/`) | +| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `cursor`, `trae`, `qoder`, `qoderwork`, `codebuddy`, `workbuddy`, `kimi`, `opencode`, `openclaw`, `nanobot`, `pi`, or `hermes` | | `--eject` | `false` | Remove mnemon integrations | | `--yes` | `false` | Auto-confirm all prompts | diff --git a/docs/zh/README.md b/docs/zh/README.md index 112ceb3..57bb238 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -143,6 +143,17 @@ Kimi Code 会将 mnemon skill、prompt 文件和原生生命周期 hooks 部署 `~/.kimi-code/` 或 `$KIMI_CODE_HOME/`。该集成会在 `config.toml` 中注册 `SessionStart`、`UserPromptSubmit` 和 `Stop` hooks。 +### [OpenCode](https://opencode.ai/) + +```bash +mnemon setup --target opencode --yes +``` + +OpenCode 会将 mnemon skill 部署到 `.opencode/skills/`,通过 +`opencode.json` 的 `instructions` 注册生成的 guide,并在 +`.opencode/plugins/` 安装原生 plugin。该 plugin 会在聊天请求前注入 +recall context,并在 session compaction 中加入 Mnemon guidance。 + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash @@ -226,7 +237,7 @@ Agent 工作,并且只在有用时调用 Mnemon - **零用户操作** — 安装一次;支持 hook 的 runtime 可用 hook,minimal runtime 可用持久规则 - **LLM 监督式** — 宿主 LLM 主动决定记什么、更新什么、遗忘什么;无内嵌 LLM,无 API 密钥 -- **多框架支持** — Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy、Kimi Code 和 Hermes Agent(hooks)、OpenClaw(plugins)、Pi(extensions)、Nanobot(skills)等 +- **多框架支持** — Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy、Kimi Code、OpenCode 和 Hermes Agent(hooks/plugins)、OpenClaw(plugins)、Pi(extensions)、Nanobot(skills)等 - **Markdown 可安装 harness** — `SKILL.md`、`INSTALL.md`、`GUIDELINE.md` 和四个生命周期提醒 - **四图架构** — 时序、实体、因果、语义四种边,不仅仅是向量相似度 - **意图原生协议** — 三个原语(`remember`、`link`、`recall`)映射到 LLM 的认知词汇而非数据库语法;结构化 JSON 输出,带信号透明度 @@ -275,7 +286,7 @@ Agent 工作,并且只在有用时调用 Mnemon Gemini CLI ───┘ ``` -基础已就绪:一个 `~/.mnemon` 数据库,任何 agent 都可以读写。Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy、Kimi Code 和 Hermes Agent setup 可自动安装 hook;OpenClaw 可以使用 plugin hooks;Pi 通过原生 skill 和 TypeScript lifecycle extension 集成;Nanobot 通过 skill 文件集成;NanoClaw 通过容器技能和卷挂载集成。同一个 harness 可以安装到任何支持 skill、rule、system prompt 或 event hook 的 LLM CLI。 +基础已就绪:一个 `~/.mnemon` 数据库,任何 agent 都可以读写。Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy、Kimi Code、OpenCode 和 Hermes Agent setup 可自动安装 hook/plugin;OpenClaw 可以使用 plugin hooks;Pi 通过原生 skill 和 TypeScript lifecycle extension 集成;Nanobot 通过 skill 文件集成;NanoClaw 通过容器技能和卷挂载集成。同一个 harness 可以安装到任何支持 skill、rule、system prompt 或 event hook 的 LLM CLI。 更长远的方向是**记忆网关**:协议层与存储引擎解耦。当前 SQLite 后端是第一个适配器;协议面(`remember / link / recall`)可运行在 PostgreSQL、Neo4j 或任何图数据库之上。Agent 侧优化(何时召回、记什么)与存储侧优化(索引、图算法)独立演进。详见[未来方向](design/08-decisions.md#82-未来方向)。 diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 9fa4f4d..72708d1 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -37,6 +37,7 @@ mnemon setup --target qoderwork mnemon setup --target codebuddy mnemon setup --target workbuddy mnemon setup --target kimi +mnemon setup --target opencode mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -52,8 +53,8 @@ mnemon setup --eject --target claude-code | 标志 | 默认值 | 说明 | |---|---|---| -| `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`;Hermes 安装到 `~/.hermes/`;QoderWork 安装到 `~/.qoderwork/`;Kimi Code 安装到 `~/.kimi-code/` 或 `$KIMI_CODE_HOME/`) | -| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`cursor`、`trae`、`qoder`、`qoderwork`、`codebuddy`、`workbuddy`、`kimi`、`openclaw`、`nanobot`、`pi` 或 `hermes` | +| `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`;Hermes 安装到 `~/.hermes/`;QoderWork 安装到 `~/.qoderwork/`;Kimi Code 安装到 `~/.kimi-code/` 或 `$KIMI_CODE_HOME/`;OpenCode 安装到 `~/.config/opencode/`) | +| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`cursor`、`trae`、`qoder`、`qoderwork`、`codebuddy`、`workbuddy`、`kimi`、`opencode`、`openclaw`、`nanobot`、`pi` 或 `hermes` | | `--eject` | `false` | 移除 mnemon 集成 | | `--yes` | `false` | 自动确认所有提示 | diff --git a/internal/setup/assets/assets.go b/internal/setup/assets/assets.go index caacd33..2aa6d2a 100644 --- a/internal/setup/assets/assets.go +++ b/internal/setup/assets/assets.go @@ -107,6 +107,12 @@ var KimiUserPromptHook []byte //go:embed kimi/stop.sh var KimiStopHook []byte +//go:embed opencode/SKILL.md +var OpenCodeSkill []byte + +//go:embed opencode/mnemon.js +var OpenCodePlugin []byte + //go:embed openclaw/SKILL.md var OpenClawSkill []byte @@ -157,5 +163,5 @@ var HermesCompactHook []byte // All returns the embedded filesystem for inspection. // -//go:embed claude codex cursor trae qoder qoderwork codebuddy workbuddy kimi openclaw nanoclaw nanobot pi hermes +//go:embed claude codex cursor trae qoder qoderwork codebuddy workbuddy kimi opencode openclaw nanoclaw nanobot pi hermes var All embed.FS diff --git a/internal/setup/assets/opencode/SKILL.md b/internal/setup/assets/opencode/SKILL.md new file mode 100644 index 0000000..f32ac2a --- /dev/null +++ b/internal/setup/assets/opencode/SKILL.md @@ -0,0 +1,46 @@ +--- +name: mnemon +description: Persistent memory CLI for OpenCode. Store facts, recall past knowledge, link related memories, manage lifecycle. +--- + +# mnemon + +## Workflow + +1. **Remember**: `mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent` + - Diff is built in: duplicates are skipped, conflicts are auto-replaced. + - Output includes `action` (added/updated/skipped), `semantic_candidates`, and `causal_candidates`. +2. **Link** (evaluate candidates from step 1 using judgment): + - Review `causal_candidates`: link only when the memories are genuinely causally related. + - Review `semantic_candidates`: high `similarity` alone is not enough; skip unrelated keyword matches. + - Syntax: `mnemon link --type --weight <0-1> [--meta '']` +3. **Recall**: `mnemon recall "" --limit 10` + +## Commands + +```bash +mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent +mnemon link --type --weight <0-1> [--meta ''] +mnemon recall "" --limit 10 +mnemon search "" --limit 10 +mnemon import --dry-run +mnemon import +mnemon forget +mnemon related --edge causal +mnemon gc --threshold 0.4 +mnemon gc --keep +mnemon status +mnemon log +mnemon store list +mnemon store create +mnemon store set +mnemon store remove +``` + +## Guardrails + +- Use memory only when it can materially improve continuity or task quality. +- Do not store secrets, passwords, tokens, private keys, or short-lived operational noise. +- Categories: `preference`, `decision`, `insight`, `fact`, `context` +- Edge types: `temporal`, `semantic`, `causal`, `entity` +- Max 8,000 chars per insight. diff --git a/internal/setup/assets/opencode/mnemon.js b/internal/setup/assets/opencode/mnemon.js new file mode 100644 index 0000000..37c3390 --- /dev/null +++ b/internal/setup/assets/opencode/mnemon.js @@ -0,0 +1,93 @@ +const MAX_RECALL_CHARS = 4000 + +function runMnemon(args, options = {}) { + try { + const proc = Bun.spawnSync(["mnemon", ...args], { + stdout: "pipe", + stderr: "pipe", + env: process.env, + cwd: options.cwd || process.cwd(), + }) + if (!proc.success) return "" + return new TextDecoder().decode(proc.stdout).trim() + } catch { + return "" + } +} + +function textFromPart(part) { + if (!part || typeof part !== "object") return "" + if (part.type === "text" && typeof part.text === "string") return part.text + if (typeof part.content === "string") return part.content + return "" +} + +function lastUserMessage(output) { + const messages = Array.isArray(output?.messages) ? output.messages : [] + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + const role = msg?.info?.role || msg?.role + if (role !== "user") continue + const parts = Array.isArray(msg.parts) ? msg.parts : [] + return { msg, parts, text: parts.map(textFromPart).filter(Boolean).join("\n") } + } + return null +} + +function prependText(parts, text) { + if (!Array.isArray(parts) || text.trim() === "") return + parts.unshift({ type: "text", text }) +} + +function buildRecallContext(query, cwd) { + const status = runMnemon(["status"], { cwd }) + const recall = query.trim() === "" ? "" : runMnemon(["recall", query, "--limit", "5"], { cwd }) + const sections = [] + if (status) sections.push(`Status:\n${status}`) + if (recall) sections.push(`Relevant recall:\n${recall.slice(0, MAX_RECALL_CHARS)}`) + sections.push("Use mnemon when it materially improves continuity. After responding, decide whether durable preferences, decisions, insights, facts, or context should be stored with mnemon remember/link.") + return `\n\n\n${sections.join("\n\n")}\n\n\n` +} + +export const MnemonPlugin = async ({ directory, client }) => { + await client?.app?.log?.({ + body: { + service: "mnemon", + level: "info", + message: "Mnemon OpenCode plugin loaded", + }, + }) + + return { + "shell.env": async (_input, output) => { + if (!output.env) output.env = {} + output.env.MNEMON_OPENCODE = "1" + }, + + "experimental.chat.messages.transform": async (_input, output) => { + const current = lastUserMessage(output) + if (!current) return + const marker = "" + if (current.text.includes(marker)) return + prependText(current.parts, buildRecallContext(current.text, directory)) + }, + + "experimental.session.compacting": async (_input, output) => { + if (!Array.isArray(output.context)) output.context = [] + output.context.push(`## Mnemon Memory + +Before compaction completes, preserve durable preferences, decisions, insights, facts, or context with mnemon remember/link when they will improve future continuity. Do not store secrets, credentials, or short-lived operational noise.`) + }, + + event: async ({ event }) => { + if (event?.type !== "session.idle") return + await client?.app?.log?.({ + body: { + service: "mnemon", + level: "info", + message: "OpenCode session idle; evaluate whether durable memory should be written with mnemon", + }, + }) + }, + } +} diff --git a/internal/setup/detect.go b/internal/setup/detect.go index 05c7380..abfe187 100644 --- a/internal/setup/detect.go +++ b/internal/setup/detect.go @@ -9,8 +9,8 @@ import ( // Environment describes a detected LLM CLI environment. type Environment struct { - Name string // "claude-code", "codex", "cursor", "trae", "qoder", "qoderwork", "codebuddy", "workbuddy", "kimi", "openclaw", "nanobot", "pi", "hermes" - Display string // "Claude Code", "Codex", "Cursor", "Trae", "Qoder", "QoderWork", "CodeBuddy", "WorkBuddy", "Kimi Code", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" + Name string // "claude-code", "codex", "cursor", "trae", "qoder", "qoderwork", "codebuddy", "workbuddy", "kimi", "opencode", "openclaw", "nanobot", "pi", "hermes" + Display string // "Claude Code", "Codex", "Cursor", "Trae", "Qoder", "QoderWork", "CodeBuddy", "WorkBuddy", "Kimi Code", "OpenCode", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" Detected bool // CLI binary or global config dir found BinPath string // exec.LookPath result Installed bool // mnemon integration present at ConfigDir @@ -38,6 +38,7 @@ func DetectEnvironments(global bool) []Environment { detectCodeBuddy(global), detectWorkBuddy(global), detectKimi(), + detectOpenCode(global), detectOpenClaw(global), detectNanobot(global), detectPi(global), @@ -400,6 +401,53 @@ func detectKimi() Environment { return env } +func detectOpenCode(global bool) Environment { + home := HomeDir() + globalDir := filepath.Join(home, ".config", "opencode") + localDir := ".opencode" + + configDir := localDir + if global { + configDir = globalDir + } + if envDir := strings.TrimSpace(os.Getenv("OPENCODE_CONFIG_DIR")); envDir != "" && global { + configDir = envDir + } + + env := Environment{ + Name: "opencode", + Display: "OpenCode", + ConfigDir: configDir, + } + + if binPath, err := exec.LookPath("opencode"); err == nil { + env.Detected = true + env.BinPath = binPath + } + if _, err := os.Stat(globalDir); err == nil { + env.Detected = true + } + + skillPath := filepath.Join(configDir, "skills", "mnemon", "SKILL.md") + pluginPath := filepath.Join(configDir, "plugins", "mnemon.js") + configPath := OpenCodeConfigPath(configDir) + if _, err := os.Stat(skillPath); err == nil { + env.Installed = true + } else if _, err := os.Stat(pluginPath); err == nil { + env.Installed = true + } else if data, err := ReadJSONFile(configPath); err == nil && containsMnemon(data) { + env.Installed = true + } + + if env.BinPath != "" { + if out, err := exec.Command(env.BinPath, "--version").Output(); err == nil { + env.Version = cleanVersion(strings.TrimSpace(string(out))) + } + } + + return env +} + func detectOpenClaw(global bool) Environment { home := HomeDir() globalDir := filepath.Join(home, ".openclaw") diff --git a/internal/setup/opencode.go b/internal/setup/opencode.go new file mode 100644 index 0000000..2518f88 --- /dev/null +++ b/internal/setup/opencode.go @@ -0,0 +1,159 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mnemon-dev/mnemon/internal/setup/assets" +) + +// OpenCodeWriteSkill writes the mnemon skill to the OpenCode skills directory. +func OpenCodeWriteSkill(configDir string) (string, error) { + skillDir := filepath.Join(configDir, "skills", "mnemon") + if err := os.MkdirAll(skillDir, 0755); err != nil { + return "", err + } + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, assets.OpenCodeSkill, 0644); err != nil { + return "", err + } + return skillPath, nil +} + +// OpenCodeWritePlugin writes the mnemon plugin to the OpenCode plugins directory. +func OpenCodeWritePlugin(configDir string) (string, error) { + pluginDir := filepath.Join(configDir, "plugins") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + return "", err + } + pluginPath := filepath.Join(pluginDir, "mnemon.js") + if err := os.WriteFile(pluginPath, assets.OpenCodePlugin, 0644); err != nil { + return "", err + } + return pluginPath, nil +} + +// OpenCodeRegisterInstructions registers the generated mnemon guide in opencode.json. +func OpenCodeRegisterInstructions(configDir, promptDir string) (string, error) { + configPath := OpenCodeConfigPath(configDir) + data, err := ReadJSONFile(configPath) + if err != nil { + return "", err + } + if _, ok := data["$schema"]; !ok { + data["$schema"] = "https://opencode.ai/config.json" + } + guidePath, err := filepath.Abs(filepath.Join(promptDir, "guide.md")) + if err != nil { + return "", err + } + addOpenCodeInstruction(data, guidePath) + if err := WriteJSONFile(configPath, data); err != nil { + return "", err + } + return configPath, nil +} + +// OpenCodeConfigPath returns the OpenCode config file for a config directory. +func OpenCodeConfigPath(configDir string) string { + if filepath.Base(filepath.Clean(configDir)) == ".opencode" { + parent := filepath.Dir(configDir) + if parent == "." { + return "opencode.json" + } + return filepath.Join(parent, "opencode.json") + } + return filepath.Join(configDir, "opencode.json") +} + +// OpenCodeEject removes mnemon skill, plugin, and instruction registration. +func OpenCodeEject(configDir string) []error { + var errs []error + + fmt.Printf("\nRemoving OpenCode integration (%s)...\n", configDir) + + targets := []struct { + label string + path string + }{ + {"Skill", filepath.Join(configDir, "skills", "mnemon")}, + {"Plugin", filepath.Join(configDir, "plugins", "mnemon.js")}, + } + + for i, target := range targets { + if err := os.RemoveAll(target.path); err != nil { + StatusError(i+1, 3, target.label, err) + errs = append(errs, err) + } else { + StatusOK(i+1, 3, target.label, target.path+" removed") + } + } + removeIfEmpty(filepath.Join(configDir, "skills")) + removeIfEmpty(filepath.Join(configDir, "plugins")) + + configPath := OpenCodeConfigPath(configDir) + data, err := ReadJSONFile(configPath) + if err != nil { + StatusError(3, 3, "Config", err) + errs = append(errs, err) + } else { + removeOpenCodeInstructions(data) + removeOpenCodeEmptySchema(data) + if err := WriteOrRemoveJSONFile(configPath, data); err != nil { + StatusError(3, 3, "Config", err) + errs = append(errs, err) + } else { + StatusOK(3, 3, "Config", configPath+" cleaned") + } + } + + removeIfEmpty(configDir) + return errs +} + +func addOpenCodeInstruction(data map[string]any, guidePath string) { + removeOpenCodeInstructions(data) + var instructions []any + if existing, ok := data["instructions"].([]any); ok { + instructions = append(instructions, existing...) + } + instructions = append(instructions, guidePath) + data["instructions"] = instructions +} + +func removeOpenCodeInstructions(data map[string]any) { + existing, ok := data["instructions"].([]any) + if !ok { + return + } + filtered := make([]any, 0, len(existing)) + for _, value := range existing { + s, ok := value.(string) + if ok && isOpenCodeMnemonInstruction(s) { + continue + } + filtered = append(filtered, value) + } + if len(filtered) == 0 { + delete(data, "instructions") + } else { + data["instructions"] = filtered + } +} + +func removeOpenCodeEmptySchema(data map[string]any) { + if len(data) != 1 { + return + } + if data["$schema"] == "https://opencode.ai/config.json" { + delete(data, "$schema") + } +} + +func isOpenCodeMnemonInstruction(s string) bool { + normalized := filepath.ToSlash(s) + return strings.Contains(normalized, "mnemon/prompt/guide.md") || + strings.Contains(normalized, ".mnemon/prompt/guide.md") +} diff --git a/internal/setup/opencode_test.go b/internal/setup/opencode_test.go new file mode 100644 index 0000000..b3f797c --- /dev/null +++ b/internal/setup/opencode_test.go @@ -0,0 +1,182 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOpenCodeWriteSkillAndPlugin(t *testing.T) { + dir := t.TempDir() + + skillPath, err := OpenCodeWriteSkill(dir) + if err != nil { + t.Fatalf("write skill: %v", err) + } + if skillPath != filepath.Join(dir, "skills", "mnemon", "SKILL.md") { + t.Fatalf("skill path = %q", skillPath) + } + skill, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("read skill: %v", err) + } + if !strings.Contains(string(skill), "OpenCode") { + t.Fatalf("skill should mention OpenCode: %s", string(skill)) + } + + pluginPath, err := OpenCodeWritePlugin(dir) + if err != nil { + t.Fatalf("write plugin: %v", err) + } + if pluginPath != filepath.Join(dir, "plugins", "mnemon.js") { + t.Fatalf("plugin path = %q", pluginPath) + } + plugin, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("read plugin: %v", err) + } + for _, want := range []string{"experimental.chat.messages.transform", "experimental.session.compacting", "shell.env"} { + if !strings.Contains(string(plugin), want) { + t.Fatalf("plugin missing %q: %s", want, string(plugin)) + } + } +} + +func TestOpenCodeConfigPath(t *testing.T) { + if got := OpenCodeConfigPath(".opencode"); got != "opencode.json" { + t.Fatalf("local config path = %q", got) + } + root := t.TempDir() + if got := OpenCodeConfigPath(filepath.Join(root, ".opencode")); got != filepath.Join(root, "opencode.json") { + t.Fatalf("temp local config path = %q", got) + } + if got := OpenCodeConfigPath(filepath.Join(root, ".config", "opencode")); got != filepath.Join(root, ".config", "opencode", "opencode.json") { + t.Fatalf("global config path = %q", got) + } +} + +func TestOpenCodeRegisterInstructionsPreservesUnrelatedConfig(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, ".opencode") + configPath := filepath.Join(root, "opencode.json") + if err := os.WriteFile(configPath, []byte(`{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "CONTRIBUTING.md", + "/old/.mnemon/prompt/guide.md" + ], + "theme": "dark" +}`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + promptDir := filepath.Join(root, "mnemon", "prompt") + + if _, err := OpenCodeRegisterInstructions(configDir, promptDir); err != nil { + t.Fatalf("register instructions: %v", err) + } + + data, err := ReadJSONFile(configPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + if data["theme"] != "dark" { + t.Fatalf("unrelated config should be preserved: %#v", data) + } + instructions := data["instructions"].([]any) + if len(instructions) != 2 { + t.Fatalf("expected existing instruction plus new guide: %#v", instructions) + } + if instructions[0] != "CONTRIBUTING.md" { + t.Fatalf("existing instruction should be preserved: %#v", instructions) + } + gotGuide, _ := instructions[1].(string) + if !strings.HasSuffix(filepath.ToSlash(gotGuide), "/mnemon/prompt/guide.md") { + t.Fatalf("new guide instruction not registered: %#v", instructions) + } + + if _, err := OpenCodeRegisterInstructions(configDir, promptDir); err != nil { + t.Fatalf("register instructions again: %v", err) + } + data, err = ReadJSONFile(configPath) + if err != nil { + t.Fatalf("read config after second register: %v", err) + } + if got := len(data["instructions"].([]any)); got != 2 { + t.Fatalf("register should be idempotent, got %d instructions: %#v", got, data["instructions"]) + } +} + +func TestOpenCodeEjectRemovesOnlyMnemonFilesAndInstructions(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, ".opencode") + if _, err := OpenCodeWriteSkill(configDir); err != nil { + t.Fatalf("write skill: %v", err) + } + if _, err := OpenCodeWritePlugin(configDir); err != nil { + t.Fatalf("write plugin: %v", err) + } + promptDir := filepath.Join(root, "mnemon", "prompt") + if _, err := OpenCodeRegisterInstructions(configDir, promptDir); err != nil { + t.Fatalf("register instructions: %v", err) + } + customSkillDir := filepath.Join(configDir, "skills", "custom") + if err := os.MkdirAll(customSkillDir, 0755); err != nil { + t.Fatalf("create custom skill: %v", err) + } + customPlugin := filepath.Join(configDir, "plugins", "custom.js") + if err := os.WriteFile(customPlugin, []byte("export const Custom = async () => ({})\n"), 0644); err != nil { + t.Fatalf("write custom plugin: %v", err) + } + configPath := filepath.Join(root, "opencode.json") + data, err := ReadJSONFile(configPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + data["instructions"] = append(data["instructions"].([]any), "docs/rules.md") + if err := WriteJSONFile(configPath, data); err != nil { + t.Fatalf("write config: %v", err) + } + + errs := OpenCodeEject(configDir) + if len(errs) > 0 { + t.Fatalf("eject errors: %v", errs) + } + if _, err := os.Stat(filepath.Join(configDir, "skills", "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon skill should be removed, err=%v", err) + } + if _, err := os.Stat(customSkillDir); err != nil { + t.Fatalf("custom skill should remain: %v", err) + } + if _, err := os.Stat(filepath.Join(configDir, "plugins", "mnemon.js")); !os.IsNotExist(err) { + t.Fatalf("mnemon plugin should be removed, err=%v", err) + } + if _, err := os.Stat(customPlugin); err != nil { + t.Fatalf("custom plugin should remain: %v", err) + } + data, err = ReadJSONFile(configPath) + if err != nil { + t.Fatalf("read config after eject: %v", err) + } + instructions := data["instructions"].([]any) + if len(instructions) != 1 || instructions[0] != "docs/rules.md" { + t.Fatalf("only custom instruction should remain: %#v", instructions) + } +} + +func TestOpenCodeEjectRemovesEmptyGeneratedConfig(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, ".opencode") + promptDir := filepath.Join(root, "mnemon", "prompt") + if _, err := OpenCodeRegisterInstructions(configDir, promptDir); err != nil { + t.Fatalf("register instructions: %v", err) + } + + errs := OpenCodeEject(configDir) + if len(errs) > 0 { + t.Fatalf("eject errors: %v", errs) + } + if _, err := os.Stat(filepath.Join(root, "opencode.json")); !os.IsNotExist(err) { + t.Fatalf("generated config should be removed, err=%v", err) + } +}