Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ 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.

The plugin supports OpenCode v2 and v1.18.29 or newer. After updating Mnemon,
rerun setup with the same scope (add `--global` for a user-wide installation),
then restart OpenCode to replace an older plugin. See the
[OpenCode setup notes](docs/USAGE.md#opencode) for runtime requirements.

### [OpenClaw](https://github.com/openclaw/openclaw)

```bash
Expand Down
25 changes: 25 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,31 @@ mnemon setup --eject --target claude-code
| `--eject` | `false` | Remove mnemon integrations |
| `--yes` | `false` | Auto-confirm all prompts |

### OpenCode

The installed plugin supports OpenCode v2 (verified with 2.0.18) and v1.18.29
or newer. Earlier v1 releases may require function exports instead of its shared
`server`/`setup` entrypoint; upgrade OpenCode before refreshing the integration.

After updating Mnemon, run `mnemon setup --target opencode --yes` again in the
project, or add `--global` if the original installation was user-wide. Restart
OpenCode afterward. Updating the Mnemon binary alone does not replace an
already installed `.opencode/plugins/mnemon.js` (or
`~/.config/opencode/plugins/mnemon.js`).

Keep `mnemon` on OpenCode's `PATH`. The plugin runs the CLI through Node's
child-process API, which both OpenCode runtimes provide; a separate Bun
installation is unnecessary. Native executables and standard npm installations
are supported on Unix and Windows. npm installations resolve directly to their
platform binary so a timeout also stops the memory command.

Recall is added to the latest user message before each model request. Tool
continuations reuse that turn's recall without duplicating it; a new user turn
refreshes it. The cache is isolated by session, retains at most 128 sessions,
and is cleared on plugin unload. Compaction receives the durable-memory
guidance, and shell commands receive `MNEMON_OPENCODE=1`. Missing or failed CLI
calls leave the conversation usable; each call has a five-second timeout.

---

## Memory CLI Commands
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ OpenCode 会将 mnemon skill 部署到 `.opencode/skills/`,通过
`.opencode/plugins/` 安装原生 plugin。该 plugin 会在聊天请求前注入
recall context,并在 session compaction 中加入 Mnemon guidance。

该 plugin 支持 OpenCode v2 和 v1.18.29 及以上版本。更新 Mnemon 后,
请按原安装范围重新运行 setup(用户级安装加 `--global`),再重启 OpenCode
以替换旧 plugin。运行环境要求见 [OpenCode 配置说明](USAGE.md#opencode)。

### [OpenClaw](https://github.com/openclaw/openclaw)

```bash
Expand Down
22 changes: 22 additions & 0 deletions docs/zh/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ mnemon setup --eject --target claude-code
| `--eject` | `false` | 移除 mnemon 集成 |
| `--yes` | `false` | 自动确认所有提示 |

### OpenCode

安装的 plugin 支持 OpenCode v2(已验证 2.0.18)和 v1.18.29 及以上版本。
更早的 v1 加载器可能要求函数导出,无法使用共用的 `server`/`setup` 入口,
请先升级 OpenCode。

更新 Mnemon 后,在项目中重新运行 `mnemon setup --target opencode --yes`;
若原来是用户级安装,则加 `--global`。完成后重启 OpenCode。只更新 Mnemon
二进制不会替换已安装的 `.opencode/plugins/mnemon.js` 或
`~/.config/opencode/plugins/mnemon.js`。

请确保 OpenCode 的 `PATH` 能找到 `mnemon`。该 plugin 使用两种 OpenCode
运行时均提供的 Node 子进程 API,无需单独安装 Bun。Unix 和 Windows 均支持
原生可执行文件和标准 npm 安装。npm 安装会直接定位对应平台的二进制,
确保超时时能终止记忆命令。

每次模型请求前,recall 会加入最新的用户消息。工具续调用复用本轮 recall,
不会重复插入;新一轮用户消息会刷新 recall。缓存按 session 隔离,最多保留
128 个 session,并在 plugin 卸载时清空。Compaction 会收到持久记忆指引,
shell 命令会收到 `MNEMON_OPENCODE=1`。CLI 不可用或调用失败不会阻断对话,
每次调用的超时上限为五秒。

---

## Memory CLI 命令
Expand Down
132 changes: 110 additions & 22 deletions internal/memory/setup/assets/opencode/mnemon.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,63 @@
import { spawnSync } from "node:child_process"
import { existsSync, readFileSync, realpathSync } from "node:fs"
import { createRequire } from "node:module"
import path from "node:path"
import process from "node:process"

const MAX_RECALL_CHARS = 4000
const MAX_QUERY_CHARS = 8000
const MAX_SESSIONS = 128
const CONTEXT_MARKER = "<mnemon_context>"
const COMPACTION_GUIDANCE = `## 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.`

function npmBinary(entry) {
// Bypass npm's JS launcher so timeout kills the native process itself. Read
// its installed target registry instead of keeping a second platform list.
const root = path.dirname(path.dirname(entry))
const targets = JSON.parse(readFileSync(path.join(root, "targets.json"), "utf8"))
const target = targets.find((item) => item.platform === process.platform && item.arch === process.arch)
if (!target) throw new Error("Unsupported Mnemon platform")
const require = createRequire(entry)
const native = path.dirname(require.resolve(`${target.alias}/package.json`))
return path.join(native, target.binary)
}

function mnemonCommand() {
for (const directory of (process.env.PATH || "").split(path.delimiter)) {
if (!directory) continue
const binary = path.join(directory, process.platform === "win32" ? "mnemon.exe" : "mnemon")
if (existsSync(binary)) {
const resolved = realpathSync(binary)
return resolved.endsWith(`${path.sep}bin${path.sep}mnemon.js`) ? npmBinary(resolved) : binary
}
if (process.platform === "win32") {
// Node cannot execute npm's .cmd shim directly. Locate the package
// without invoking a shell or interpreting the user's recall text.
for (const modules of [path.join(directory, "node_modules"), path.dirname(directory)]) {
const entry = path.join(modules, "@mnemon-dev", "mnemon", "bin", "mnemon.js")
if (existsSync(entry)) return npmBinary(entry)
}
}
}
return "mnemon"
}

function runMnemon(args, options = {}) {
try {
const proc = Bun.spawnSync(["mnemon", ...args], {
stdout: "pipe",
stderr: "pipe",
const proc = spawnSync(mnemonCommand(), args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
cwd: options.cwd || process.cwd(),
timeout: 5000,
killSignal: "SIGKILL",
maxBuffer: 256 * 1024,
windowsHide: true,
})
if (!proc.success) return ""
return new TextDecoder().decode(proc.stdout).trim()
if (proc.error || proc.status !== 0) return ""
return proc.stdout.trim()
} catch {
return ""
}
Expand All @@ -22,34 +70,73 @@ function textFromPart(part) {
return ""
}

function lastUserMessage(output) {
function lastUserMessage(output, partKey) {
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") }
if (!Array.isArray(msg[partKey])) continue
return { msg, parts: msg[partKey] }
}
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 (status) sections.push(`Status:\n${status.slice(0, MAX_RECALL_CHARS)}`)
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<mnemon_context>\n${sections.join("\n\n")}\n</mnemon_context>\n\n`
}

export const MnemonPlugin = async ({ directory, client }) => {
function recallInjector(directory) {
// OpenCode rebuilds model messages for tool continuations. Reuse the recall
// for the latest user message, but attach it to every fresh outgoing copy.
const sessions = new Map()
return {
inject(sessionID, messageID, parts) {
const text = parts.map(textFromPart).filter(Boolean).join("\n")
if (text.includes(CONTEXT_MARKER)) return
const query = text.slice(0, MAX_QUERY_CHARS)
const cached = sessionID && messageID ? sessions.get(sessionID) : undefined
const context = cached?.messageID === messageID && cached?.query === query
? cached.context
: buildRecallContext(query, directory)
if (sessionID && messageID) {
sessions.delete(sessionID)
sessions.set(sessionID, { messageID, query, context })
if (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value)
}
parts.unshift({ type: "text", text: context })
},
clear() { sessions.clear() },
}
}

async function setup(ctx) {
const recall = recallInjector(ctx.location.directory)
await ctx.shell.hook("create.before", (event) => {
event.env.MNEMON_OPENCODE = "1"
})
await ctx.session.hook("context", (event) => {
const current = lastUserMessage(event, "content")
if (current) recall.inject(event.sessionID, current.msg.id, current.parts)
})
await ctx.session.hook("compaction", (event) => {
if (!event.system.some((part) => part.type === "text" && part.text === COMPACTION_GUIDANCE)) {
event.system.push({ type: "text", text: COMPACTION_GUIDANCE })
}
})
// Registrations belong to OpenCode's plugin scope. No event loop or timer is
// needed; cap cached sessions and release their text when the plugin unloads.
return () => recall.clear()
}

async function server({ directory, client }) {
const recall = recallInjector(directory)
await client?.app?.log?.({
body: {
service: "mnemon",
Expand All @@ -65,18 +152,14 @@ export const MnemonPlugin = async ({ directory, client }) => {
},

"experimental.chat.messages.transform": async (_input, output) => {
const current = lastUserMessage(output)
const current = lastUserMessage(output, "parts")
if (!current) return
const marker = "<mnemon_context>"
if (current.text.includes(marker)) return
prependText(current.parts, buildRecallContext(current.text, directory))
recall.inject(current.msg.info?.sessionID, current.msg.info?.id, current.parts)
},

"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.`)
if (!output.context.includes(COMPACTION_GUIDANCE)) output.context.push(COMPACTION_GUIDANCE)
},

event: async ({ event }) => {
Expand All @@ -89,5 +172,10 @@ Before compaction completes, preserve durable preferences, decisions, insights,
},
})
},
dispose: () => recall.clear(),
}
}

// One export avoids double registration. OpenCode 1.18.29+ calls server();
// OpenCode 2 calls setup() and ignores the legacy adapter.
export default { id: "mnemon", setup, server }
6 changes: 5 additions & 1 deletion internal/memory/setup/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ func TestOpenCodeWriteSkillAndPlugin(t *testing.T) {
if err != nil {
t.Fatalf("read plugin: %v", err)
}
for _, want := range []string{"experimental.chat.messages.transform", "experimental.session.compacting", "shell.env"} {
for _, want := range []string{
`export default { id: "mnemon", setup, server }`,
`ctx.session.hook("context"`, `ctx.session.hook("compaction"`, `ctx.shell.hook("create.before"`,
"experimental.chat.messages.transform", "experimental.session.compacting", "shell.env",
} {
if !strings.Contains(string(plugin), want) {
t.Fatalf("plugin missing %q: %s", want, string(plugin))
}
Expand Down
Loading
Loading