OpenCode memory plugin — git-backed, two-tier hot/cold MemFS with progressive disclosure.
All memory operations go through 10 dedicated custom tools (memory_read, memory_write, etc.)
for full isolation from core file tools. The rendered <memfs> system-prompt block is
content-hash-cached per session to preserve upstream prompt-cache prefix hits.
npm run build # tsc — compile to dist/
npm run dev # tsc --watch — incremental rebuild
npm run clean # rm -rf dist
npx tsc --noEmit # type-check without emitting
npm test # vitest run — run all tests once
npm run test:watch # vitest — watch mode
npx vitest run src/__tests__/frontmatter.test.ts # run a single test fileNo linter is configured yet.
src/
├── index.ts # Public entry point — re-exports MemFSPlugin
├── plugin.ts # Plugin factory, hook registration, lifecycle, runSystemTransform
├── tools.ts # 10 tool handlers (read/write/edit/delete/promote/demote/tree/history/rollback/flush)
├── store.ts # Memory FS operations (scan dirs, read files, build tree)
├── prompt.ts # System prompt <memfs> XML rendering (tree + hot content)
├── git.ts # Git operations via simple-git (init, commit, log, rollback)
├── watcher.ts # fs.watch + debounce for auto-commit (+ onChange callback)
├── frontmatter.ts # YAML frontmatter parse/serialize + atomic writes (tmp+rename)
├── seed.ts # First-run directory structure + starter files
├── config.ts # Zod schema + loader for ~/.config/opencode/memfs.json
├── hash.ts # Deterministic SHA-256 over memory state (injection-cache key)
├── cacheTtl.ts # Parse "5m"/"30s"/"1h" duration strings into milliseconds
├── sessionMeta.ts # Per-session meta (lastResponseTime, token usage, context limit)
├── renderCache.ts # Per-session rendered-block cache + shouldRefreshNow decision ladder
├── lock.ts # Per-file lock + git-operation mutex
└── types.ts # Shared type definitions (MemoryFile, MemoryFrontmatter, etc.)
- Hot (
system/) — full content pinned in system prompt every turn - Cold (
reference/,archive/) — path + description in tree listing, read on demand viamemory_read
The plugin conforms to Plugin from @opencode-ai/plugin:
import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"
export const MemFSPlugin: Plugin = async (ctx) => {
// ctx provides: client, project, directory, worktree
return {
tool: {
memory_read: tool({
description: "Read a memory file",
args: { path: tool.schema.string() },
async execute(args, context) { return "result" }
}),
},
"experimental.chat.system.transform": async (input, output) => {
output.system.splice(1, 0, renderedBlock)
},
}
}tool— registers all 10 memory toolsexperimental.chat.system.transform— injects<memfs>block at position 1 in system prompt; runs through the injection-cache ladder (seerenderCache.ts)event— updateslastResponseTime/lastTokensinsessionMetaonmessage.updatedfor assistant messagescommand.execute.before— intercepts/memfs-flushto force-bust the injection cache
All memory lives under ~/.config/opencode/memory/:
global/— shared across all projects (persona, human, projects registry)projects/<name>/— per-project memory (named by directory basename)
Single git repo and single filesystem watcher at the memory root.
A single git repo at ~/.config/opencode/memory/ covers all stores. The watcher
auto-commits on file change with a 2-second debounce. Tools write to disk; watcher handles git.
| Package | Purpose |
|---|---|
@opencode-ai/plugin |
Plugin SDK — Plugin type, tool() helper |
js-yaml |
YAML frontmatter parse/serialize |
simple-git |
Git operations (init, add, commit, log, checkout) |
zod |
Config validation (Zod v3 for our schemas) |
Note: @opencode-ai/plugin bundles its own Zod v4 internally. Use tool.schema.* for
tool arg schemas (Zod v4). Use the project's zod import for config/frontmatter validation (Zod v3).
- Strict mode —
strict: truein tsconfig. No implicitany. Explicit return types on public functions. import typefor type-only imports — required byisolatedModules: true.- ES2022 target — modern syntax is fine (optional chaining, nullish coalescing,
Array.at(), etc.) - Module system — ESM (
import/export).module: "preserve"with bundler resolution. - No
const enum— forbidden byisolatedModules.
| Thing | Convention | Example |
|---|---|---|
| Files | camelCase.ts |
frontmatter.ts, store.ts |
| Exported values | PascalCase |
MemFSPlugin |
| Functions/variables | camelCase |
ensureSeed(), renderTree() |
| Types/interfaces | PascalCase |
MemoryFile, MemoryFrontmatter |
| Tool names | snake_case with memory_ prefix |
memory_read, memory_promote |
| Config keys | camelCase |
hotDir, defaultLimit |
- 2-space indentation
- Double quotes for strings
- Named exports only — no default exports
- Re-export pattern:
export { X } from "./module"
Order imports in this sequence:
- Node built-ins (
fs,path) - External packages (
js-yaml,simple-git,zod) - Plugin SDK (
@opencode-ai/plugin) - Internal modules (
./store,./types)
Separate groups with a blank line. Use import type for type-only imports.
import { readFile } from "fs/promises"
import path from "path"
import yaml from "js-yaml"
import { z } from "zod"
import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"
import type { MemoryFile } from "./types"
import { parseMemoryFile } from "./frontmatter"- JSDoc block at the top of every file explaining its purpose
- Inline TODOs reference task IDs:
// TODO: TASK-NN — description - Prefer self-documenting code over inline comments
- Validate inputs early with Zod schemas; surface errors clearly
- Use
try/catcharound file and git operations — degrade gracefully, never crash the plugin - Return descriptive error messages from tools (the agent sees them)
- Atomic file writes via tmp+rename to prevent corruption
Every memory file uses YAML frontmatter:
---
description: "What this file contains and when to reference it"
limit: 5000
readonly: false
---Auto-generate defaults when omitted:
description— humanized from filenamelimit—5000readonly—false
Sort YAML keys alphabetically for deterministic diffs.
Use Conventional Commits with the TASK-ID as scope:
<type>(TASK-<N>): <description>
| Type | When to use |
|---|---|
feat |
New feature or functionality |
fix |
Bug fix |
refactor |
Code change that neither fixes a bug nor adds a feature |
chore |
Build, config, tooling, or dependency changes |
docs |
Documentation only |
test |
Adding or updating tests |
feat(TASK-18): implement memory_read tool handler
fix(TASK-13): handle missing .git directory on first init
refactor(TASK-14): extract file scanner into separate function
chore(TASK-9): add .gitignore and clean up node_modules
docs(TASK-20): add usage and config docs to README
test(TASK-11): add frontmatter parse/serialize tests
If a commit spans multiple tasks, use the primary task. If no task applies, omit the scope:
chore: update dependencies