A faithful, local port of Hermes' built-in memory to OpenCode — plus skill authoring. Two Markdown files, injected whole, a single memory tool, and a self-learning review. No vector database, no external service.
- Two files, injected whole —
MEMORY.md(your notes) andUSER.md(the user profile) are rendered into the system prompt every session, with Hermes' headers and a per-session frozen snapshot. No retrieval. - Hard char caps —
MEMORY.md2200,USER.md1375 (Hermes defaults), configurable. - One
memorytool —target: memory|user,action: add|replace|remove|demote, plus an atomic batch (operations[]). Adds are idempotent; the budget is checked only on the final batch result, and an over-budget write returns a consolidate-and-retry error rather than silently dropping anything. - Self-learning review — after N turns, on session idle, a review pass reads the conversation and writes memory and skills. When the capped files are full it frees room itself (demote/shorten stale entries in the same batch, with one retry), so a full store never silently swallows new facts.
- Session recall —
session_searchreads OpenCode's own session database (read-only) to find and read past conversations (discovery / read / scroll / browse; actual messages, no LLM). Discovery matches message text only (never JSON metadata like"type":"text"), any term, ranked by terms hit. A system-prompt nudge tells the model to use it when the user references the past. Both storage layouts are supported (OpenCode 1session/message/part, OpenCode 2session_v2/session_message), detected per database. - External memory provider (optional, one) —
provider: "sqlite"adds an unbounded local note store with automatic prefetch (relevant notes injected before each turn). It mirrors built-in writes:add/replacestore the current fact (a replace deletes the superseded one),removepropagates the deletion, anddemoteevicts a fact from the capped file while keeping it recallable. Off by default (Hermes ships built-in-only). - Skills (procedural memory) — one
skill_managetool: authoring actionscreate/patch/edit/delete/write_file/remove_file, plus lifecycle actionscurate(mark agent-created skills stale, archive very old ones; never touches user/pinned skills) andrestore. Validation and guards: support files may only be written/removed underreferences//scripts//templates//assets/(neverSKILL.md), path traversal is rejected,restorematches the exact archived name, and the autonomous review may only edit agent-created skills. opencode itself lists skills in the system prompt and its read tool opens SKILL.md/support files, so no separate list/view tools. - Dream (idle reconciliation) — when a provider is active, an idle pass compares its store against the current
MEMORY.md/USER.mdand tombstones notes made obsolete by a canonical fact (they stop being recalled, then are garbage-collected); an optional model judge resolves ambiguous near-duplicates.dreamon by default,dreamJudgeoff. - Robust writes — atomic temp+rename with SHA-256 fingerprint conflict detection and retry (safe for multiple concurrent OpenCode processes).
- Observable failures — provider errors (e.g. a dead embeddings endpoint) and a malformed config file are logged (throttled) instead of silently disabling recall or falling back to defaults.
- OpenCode >= 2.0 (V2 plugin API) or >= 1.18.29 (V1 object entrypoint) — one entrypoint serves both
- Bun (for local plugin loading and tests)
git clone https://github.com/GitmanIII/opencode-hermes.git ~/opencode-hermes
cd ~/opencode-hermes
bun installAdd the plugin directory to ~/.config/opencode/opencode.jsonc (OpenCode 2):
OpenCode 2 resolves a configured plugin path as a directory (its package.json entry), so point at the clone itself, not at a single file. On OpenCode 1 the same default export works through the V1 plugin/file form:
{
"plugin": ["file:///home/you/opencode-hermes/plugin.ts"]
}Restart OpenCode. The memory store is created on first run.
Precedence: environment variables > config file > defaults. Defaults match Hermes.
| Variable | Default | Purpose |
|---|---|---|
HERMES_OPENCODE_MEMORY_ROOT |
~/.config/opencode/memories |
memory store root (MEMORY.md, USER.md) |
HERMES_OPENCODE_SKILLS_ROOT |
~/.agents/skills |
skills root (scanned by OpenCode) |
HERMES_OPENCODE_MEMORY_LIMIT |
2200 |
MEMORY.md char limit |
HERMES_OPENCODE_USER_LIMIT |
1375 |
USER.md char limit |
HERMES_NUDGE_INTERVAL |
10 |
turns between background reviews |
HERMES_IDLE_DEBOUNCE_MS |
10000 |
quiet period after the last turn before the review runs |
HERMES_OPENCODE_LOG |
~/.local/share/opencode/log/opencode-hermes.log |
log file |
HERMES_OPENCODE_SESSIONS_DB |
~/.local/share/opencode/opencode.db |
session DB for session_search (read-only) |
HERMES_OPENCODE_PROVIDER |
none |
external long-term memory provider: none | sqlite |
HERMES_OPENCODE_PROVIDER_PATH |
<memory root>/provider.sqlite |
provider store path |
HERMES_OPENCODE_PREFETCH_LIMIT |
5 |
provider notes injected per turn |
HERMES_OPENCODE_DREAM |
true |
idle reconciliation of the provider store against canonical memory |
HERMES_OPENCODE_DREAM_JUDGE |
false |
let the dream ask the model about ambiguous near-duplicates |
HERMES_OPENCODE_CONFIG |
~/.config/opencode/opencode-hermes.json |
config file path |
Or put the same keys in ~/.config/opencode/opencode-hermes.json (JSON or JSONC):
{
"memoryCharLimit": 2200,
"userCharLimit": 1375,
"nudgeInterval": 10
}memory · session_search · skill_manage · provider_memory — 4 tools (3 with no provider active; provider_memory is registered only when a provider is configured)
OpenCode 1 and 2 have different, incompatible plugin APIs, so the plugin is split into a host-agnostic core and two thin adapters — one default export serves both:
lib/core.ts— store/provider lifecycle, the frozen system-prompt snapshot, turn counting, and the idle/flush/dream triggers. Host-agnostic.lib/channel.ts+lib/llm.ts— the small host surface the learning loop needs (transcript,complete,sessionTitle,injectNote,toast) and its V1 implementation (throwaway internal session).lib/tool-specs.ts— each tool declared once as data, rendered to zod (V1) or JSON Schema (V2), with shared executors.index.ts— V1 adapter:experimental.chat.system.transform,chat.message,event,experimental.session.compacting,tool().v2.ts— V2 adapter:session.hook("context"|"prompt"|"compaction"),event.subscribe(),tool.transform(), andgenerate.text()for completions (so V2 needs no internal sessions).
Two V2 details worth knowing, both verified against 2.0.32:
- The idle trigger. V2's transient
session.idleevent never reaches plugin subscribers (the server writes anidlemessage row and emitssession.execution.succeeded, but nosession.idle/session.status), so the review/dream trigger on the durablesession.execution.{succeeded,failed,interrupted}events — the same moment V1'ssession.idlefired. - The review's model.
generate.textwithout a model uses the global default, which may be an unrelated free-tier model that rejects the request; completions therefore pin the reviewed session's own model (session.get().model).
index.ts default-exports { ...v2, server }: V2 reads id + setup, V1 calls server(). plugin.ts re-exports it for file-based configs.
- Entries are
§-delimited (same as Hermes). There is no per-entry metadata. - At session start the two files are rendered into the system prompt (frozen for the session; refreshed when a new session starts).
- Every N user turns, on idle, a review pass builds a transcript, asks the model for JSON
{ operations, skills }, and applies them (memory as atomic per-target batches, skills with the agent guard). - Before compaction, a shorter flush review saves what matters.
session_searchis model-invoked (not automatic): the guidance + tool description tell it to recall past sessions when relevant, then it returns stored messages from the session DB.- The optional provider is automatic: after each user message it prefetches relevant notes and injects them;
provider_memoryadds/searches notes. One provider at a time; implementMemoryProvider(lib/memory-provider.ts) for a different backend (e.g. embeddings).
provider accepts a module spec, not just the built-ins (none, sqlite):
- a
file://URL / path to a module exportingcreateProvider(options)or a default factory, or - an npm package name.
Provider options go under providerOptions; initialize() receives { memoryRoot, providerPath, prefetchLimit, options, projectId, log, warn }. log/warn are the host's sinks — config JSON can't carry functions, so a provider that wants its diagnostics in opencode-hermes.log (rather than console) should use them. One provider at a time. It's used for automatic prefetch (before each user message), mirrors built-in memory writes from both the memory tool and the background/flush review (add/replace/remove/demote — removals propagate, evictions stay recallable), and backs the provider_memory tool. Providers that scope notes can implement setProject(id).
Companion: opencode-hermes-embeddings — semantic recall on your own GPU via HuggingFace text-embeddings-inference (nomic-embed-text-v1.5, 768-dim), project-scoped.
Versioning: from v0.10.4 onward, this repo and the companion share one version number. They are a pair — the host and its optional provider — so a release of either bumps both, and the same number always means a tested pair. The current pair is opencode-hermes v0.10.6 + opencode-hermes-embeddings v0.10.6. The core exposes the host log/warn sinks from v0.10.3, which embeddings v0.9.3+ uses; on an older core the provider falls back to console. See RELEASING.md.
{
"provider": "file:///home/you/opencode-hermes-embeddings/src/provider.ts",
"providerOptions": {
"endpoint": "http://127.0.0.1:8080",
"model": "nomic-ai/nomic-embed-text-v1.5",
"topK": 5,
"minScore": 0.58
}
}bun run test # 244 hermetic checks (no model, no network)
bun run e2e:idle # live end-to-end of the V2 idle path (needs a model + TEI)
# E2E_KEEP=1 keep the clean room for inspection
# E2E_FULL_MEMORY=1 seed the char cap full: proves the review frees room itself
# E2E_MODEL=… give the clean-room session a model (needed when the global
# default is a free-tier model, which the server refuses)scripts/e2e-idle.py is the live counterpart of the hermetic idle-trigger check: the suite proves a session.execution.* event reaches core.onIdle and runs the review against a mock host, while the script proves a live server actually emits those events and the review runs end to end. It drives a clean-room opencode acp instance (HERMES_OPENCODE_MEMORY_ROOT etc. redirected, so your real memory is untouched), asserts background review: memory=… with no error, prints the written memory, and prints the dream line when a provider is active; it exits non-zero otherwise. It sets HERMES_IDLE_DEBOUNCE_MS low so it needn't wait the default 10 seconds.
Note: a bare ACP session/new has no model, so the plugin's pin (which reads the reviewed session's model) resolves to nothing and generate.text falls back to the global default. If that default is a free-tier model the server rejects it (Generate.UnavailableError), so pass E2E_MODEL — otherwise the script fails with that reason printed.
244 hermetic checks (no model, no network): store semantics (incl. concurrent-write serialization and cross-process refresh), injection, plugin wiring (V1 hooks and the V2 context/hooks/tools), the V2 event pump (reconnect + location filtering), the V2 idle trigger (turn-completion event → review, and session.created re-freezing the snapshot), the 4-tool surface, self-learning, review consolidation on a full store (per-target retry; a skill write or error cannot suppress it), skills (incl. remove_file/restore guards), efficacy, token cost, curation, JSONC config parsing + warnings, session recall on both DB layouts (multi-term discovery, metadata-token filtering), dream GC.
MIT. The memory core is derived from opencode-hermes-memory (© 2026 realchendahuang, MIT), itself a port of the Hermes memory system. The skills subsystem, review integration, curation, and fixes are original to this project (GitmanIII). See LICENSE.
{ "$schema": "https://opencode.ai/config.json", "plugins": ["file:///home/you/opencode-hermes"] }