Yet another lessons-learned/core-memories autonomous database backed MCP for Opencode
  • Python 82.6%
  • TypeScript 13.4%
  • JavaScript 4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Troed Sångberg 618cf70ab9 fix: point the sample worker_model at Qwen3.8-27B
Qwen3.8-27B is the intended worker model; the sample config still named the Ornith-1.5 model.
2026-10-19 12:55:20 +00:00
docs/superpowers feat: OpenCode context plugin (phase 2B) — pointer injection and attribution (#2) 2026-10-15 15:29:29 +02:00
plugin/opencode docs: OpenCode plugin setup step and managed service env (#3) 2026-10-15 16:30:35 +02:00
reflect fix(worker): correct the v2 warm-up premise and scope the agent's tool surface 2026-10-16 09:27:14 +00:00
systemd docs: clarify README with separate local/hub/client setup sections 2026-08-10 16:19:23 +02:00
tests fix(worker): correct the v2 warm-up premise and scope the agent's tool surface 2026-10-16 09:27:14 +00:00
.gitignore chore: ignore local worktrees 2026-10-14 20:42:08 +00:00
.python-version chore: commit uv-generated project files 2026-08-09 09:52:28 +02:00
AGENTS.md brief: write BRIEF.md instead of splicing AGENTS.md 2026-10-12 13:53:26 +00:00
icon.png chore: add logo assets 2026-08-09 13:48:28 +02:00
icon.svg chore: add logo assets 2026-08-09 13:48:28 +02:00
LICENSE docs: add CC0 1.0 Universal license 2026-08-09 15:01:51 +02:00
pyproject.toml feat: shared matcher (phase 2A) — embeddings, /match, attribution (#1) 2026-10-15 12:11:50 +02:00
README.md docs: OpenCode plugin setup step and managed service env (#3) 2026-10-15 16:30:35 +02:00
reflect.toml fix: point the sample worker_model at Qwen3.8-27B 2026-10-19 12:55:20 +00:00
uv.lock chore: commit uv.lock with the phase 2A embed extra deps 2026-10-19 12:55:20 +00:00

Reflect logo

Reflect

Autonomous nightly extraction of "lessons learned" from Opencode session history, served to future sessions via an MCP server. Sessions are required to judge every lesson they read as useful or not useful, and that judgement — not mere retrieval — is what raises or lowers a lesson's strength. Lessons climb to core memories when repeatedly judged useful and decay when never needed again.

Cost note: Reflect deliberately spends tokens to build and maintain memory — the nightly worker reads session transcripts and an LLM summarizes them, and future sessions can consume lessons. Only run it where token cost is low, e.g. with local LLMs (like llama.cpp) rather than a metered hosted model.

Prerequisites

  • uv (for dependency management)
  • opencode (source of session history)

Setup

Pick the section that matches your deployment. All three share the first two steps (clone + config); they diverge at step 3.

1. Single machine (local)

Everything runs on one computer. The nightly worker reads that machine's opencode.db and writes to a local lessons.db. Future opencode sessions on the same machine read lessons back via a local MCP server.

# 1. Clone and install
git clone <repo-url> reflect
cd reflect
uv sync                            # installs dependencies into .venv

# 2. Configure
cp reflect.toml ~/.config/reflect/reflect.toml
# (edit: nothing mode-specific needed; defaults are local)

# 3. Register the read-only MCP server so opencode sessions can query lessons
opencode mcp add reflect -- uv run --project <path-to-reflect> reflect mcp

# 4. Enable the nightly timer
systemctl --user link <path-to-reflect>/systemd/reflect-nightly.service
systemctl --user link <path-to-reflect>/systemd/reflect-nightly.timer
systemctl --user enable --now reflect-nightly.timer

# 5. Verify a run end-to-end before trusting it
uv run reflect run --dry-run
uv run reflect run

2. Hub

The hub owns the shared lessons.db and serves two MCP servers over HTTP so client machines can connect. The hub also runs the nightly extraction for its own sessions.

# 1. Clone and install (same as local)
git clone <repo-url> reflect
cd reflect
uv sync

# 2. Configure — set mode = "hub"
cp reflect.toml ~/.config/reflect/reflect.toml
# Edit ~/.config/reflect/reflect.toml:
#   mode = "hub"
#   (optionally set mcp_host to a non-loopback address if clients are remote)

# 3. Register the hub's own MCP server for this machine's opencode sessions
opencode mcp add reflect --url http://127.0.0.1:4010/mcp

# 4. Enable the nightly timer (extracts lessons from the hub's own sessions)
systemctl --user link <path-to-reflect>/systemd/reflect-nightly.service
systemctl --user link <path-to-reflect>/systemd/reflect-nightly.timer
systemctl --user enable --now reflect-nightly.timer

# 5. Start the two MCP servers that clients connect to
#    (edit the %h paths in the .service files to match your layout first)
systemctl --user link <path-to-reflect>/systemd/reflect-mcp-read.service
systemctl --user link <path-to-reflect>/systemd/reflect-mcp-worker.service
systemctl --user enable --now reflect-mcp-read.service reflect-mcp-worker.service

# Optional but recommended when the hub serves non-loopback clients:
#   echo 'Environment="REFLECT_MCP_TOKEN=<generated>"'   >> ~/.config/systemd/user/reflect-mcp-read.service.d/token.conf
#   echo 'Environment="REFLECT_MCP_WORKER_TOKEN=<generated>"' >> ~/.config/systemd/user/reflect-mcp-worker.service.d/token.conf
#   systemctl --user daemon-reload

The hub's read server listens on mcp_host:mcp_port (default 0.0.0.0:4010). The worker server listens on mcp_host:mcp_worker_port (default 0.0.0.0:4011). Clients connect to the worker URL for writes and the read URL for queries.

Auth: set REFLECT_MCP_TOKEN (read server) and REFLECT_MCP_WORKER_TOKEN (worker server) in the hub's environment to require Authorization: Bearer <token> on every request. Leave them unset for trusted-network-only deployments (no auth). Clients that set REFLECT_MCP_WORKER_TOKEN send it to the hub automatically, and the generated nightly agent config references it via {env:REFLECT_MCP_WORKER_TOKEN} — export the variable on the client so it resolves.

3. Client

A client (spoke) has no local lessons.db. It reads its own opencode.db, runs the nightly worker against the hub's worker MCP server, and registers the hub's read server so its own opencode sessions can query the shared lessons.

# 1. Clone and install (same as local)
git clone <repo-url> reflect
cd reflect
uv sync

# 2. Configure — set mode = "client" and point at the hub
cp reflect.toml ~/.config/reflect/reflect.toml
# Edit ~/.config/reflect/reflect.toml:
#   mode = "client"
#   hub_url = "http://<hub-hostname>:4011/mcp"
# If the hub requires tokens, export REFLECT_MCP_WORKER_TOKEN in the shell
# profile that launches the nightly timer (systemd user units inherit the
# user environment via ~/.config/environment.d/ or default.target env).

# 3. Register the hub's read server for this machine's opencode sessions
opencode mcp add reflect --url http://<hub-hostname>:4010/mcp

# 4. Enable the nightly timer (runs the extraction driver against the hub)
systemctl --user link <path-to-reflect>/systemd/reflect-nightly.service
systemctl --user link <path-to-reflect>/systemd/reflect-nightly.timer
systemctl --user enable --now reflect-nightly.timer

# 5. Verify
uv run reflect run --dry-run
uv run reflect run

Clients do not need the MCP systemd services — the hub serves those. Clients only need the nightly timer (which drives the extraction) and the opencode mcp add entry (so sessions can read lessons).

4. OpenCode plugin (optional, any deployment)

The plugin pushes matching shared lessons into a session's system instructions without being asked. It needs a hub that serves the matcher (see Shared matcher); register it on any machine whose OpenCode sessions can reach that hub.

# 1. Register the plugin directory. OpenCode resolves <dir>/index.ts (or index.js),
#    NOT package.json's main, and skips silently a directory with no root entrypoint.
#    Add it to the "plugins" array in ~/.config/opencode/opencode.jsonc:
#      "plugins": [
#        "/absolute/path/to/reflect/plugin/opencode"
#      ]

# 2. Give the OpenCode background service the hub endpoints. A path-registered
#    plugin receives no options and reads its environment ONCE, when the service
#    starts — a variable exported for a single `opencode run` never reaches it.
#    Persist them in the managed service configuration, which every service
#    start reuses. (Setting a managed variable stops the running service.)
opencode service set env REFLECT_HUB_URL          http://<hub-hostname>:4010
opencode service set env REFLECT_HUB_WORKER_URL   http://<hub-hostname>:4011
opencode service set env REFLECT_MCP_TOKEN        <read token>
opencode service set env REFLECT_MCP_WORKER_TOKEN <worker token>
#    (REFLECT_PROJECT, REFLECT_MATCH_TIMEOUT_MS and REFLECT_PLUGIN_DISABLED are optional.)

# 3. Start the service again so the new environment takes effect.
opencode service start

REFLECT_HUB_URL is the read app that serves POST /match; REFLECT_HUB_WORKER_URL is the worker app that serves POST /attribution (defaults to the read URL when unset). With no REFLECT_HUB_URL the plugin still loads and logs one line, but registers nothing — the usual "it never injects" cause. opencode service get env prints the stored values including any tokens, so do not share that output. This configures the shared background service only: --standalone and opencode serve use their own process environment, so export the variables in the shell that launches those. On the hub, provision the embedding bundle once (uv run reflect embed fetch, which writes ~/.local/share/reflect/models/all-minilm-l6-v2-onnx/); without it the hub ranks by keyword instead of embeddings. Nothing is injected until a lesson is promoted to the shared tier or pinned. See plugin/opencode/README.md.

Configuration

Config is a TOML file. Resolution order (first hit wins):

  1. --config <path> passed to reflect run|mcp|lifecycle|status
  2. REFLECT_CONFIG environment variable (path to a file)
  3. ~/.config/reflect/reflect.toml if it exists
  4. Built-in defaults

Every option lives under a [core] section — flat top-level keys are silently ignored. To create your config, copy the shipped sample and uncomment what you need:

cp reflect.toml ~/.config/reflect/reflect.toml   # then edit

Invalid or unknown keys are ignored and fall back to defaults.

Key Default Purpose
opencode_db ~/.local/share/opencode/opencode.db session history (read-only)
lessons_db ~/.local/share/reflect/lessons.db lesson store (created on first run; clients ignore this)
worker_model "" model used by the nightly agent (empty = opencode's default)
worker_agent reflect-worker reserved agent name (self-excluded from extraction)
exclude_agents ["reflect-worker"] agents never processed
disabled_projects [] project names hidden from search
core_threshold 80.0 strength at which a lesson becomes a core memory
archive_threshold 15.0 strength below which a lesson is archived
base_strength 30.0 strength of a newly created lesson
decay_per_day 1.0 daily strength decay
reoccurrence_bonus 10.0 strength gain when a lesson re-occurs
retrieval_bump 1.0 strength gain on each search retrieval (stopgap so retrieved lessons survive until judged)
useful_bonus 10.0 strength gain when an agent judges a lesson useful (rate_lessons)
not_useful_penalty 15.0 strength loss when an agent judges a lesson not useful (rate_lessons)
rescan_interval_days 30 re-review older sessions this often
rescan_quota 20 max old sessions re-reviewed per night
max_sessions_per_run 0 (0 = unlimited) per-night cap on sessions processed; the shipped sample sets 50 to drain the first-run backlog gradually
batch_size 15 sessions processed per LLM run (each run starts with a fresh context)
consolidate_interval_nights 7 run the consolidation pass (merge duplicates, supersede outdated lessons) every N nights
shared_enabled true render the shared knowledge block (SHARED.md) and install its pointer each night
attribution_enabled true reconcile client-reported lesson usage into the hub's usage table
shared_min_projects 2 distinct projects a lesson must have been judged useful in before it is shared
shared_strength_floor 45.0 minimum strength a lesson needs to enter the shared block
shared_max_entries 12 ranked members the shared block holds
shared_evict_slack 3 extra slots last night's members keep before they are evicted
shared_max_pinned 3 most pinned lessons the block renders
shared_entry_max_chars 200 maximum length of one rendered block entry
shared_path ~/.config/opencode/SHARED.md rendered shared block (the hub writes it; clients mirror it)
shared_pointer_path ~/.config/opencode/AGENTS.md global file that receives the SHARED.md pointer
embed_model "minilm-l6-v2" model name vectors are stored under and handed to a remote embedder
embed_dim 384 vector width; must match the embedder
embed_url "" optional HTTP embedding endpoint; empty = use the local ONNX bundle
embed_model_dir ~/.local/share/reflect/models/all-minilm-l6-v2-onnx local ONNX bundle directory (see "Shared matcher")
embed_model_repo "onnx-models/all-MiniLM-L6-v2-onnx" the repository reflect embed fetch downloads the bundle from
embed_refresh_batch 500 most lessons the nightly embeds in one run; a backlog drains over several nights
match_min_similarity 0.35 cosine floor; a lesson below it is not offered
match_max_pointers 3 most lessons a match returns (and the FTS5 candidate pool size)
match_query_max_chars 600 query characters embedded; the rest is truncated
match_shared_lift 1.15 score multiplier for lessons already in the shared block
purge_ttl_days 90 hard-delete archived lessons after this long
run_timeout_s 3600 nightly agent timeout
transcript_max_chars 12000 per-session transcript budget fed to the agent
mode "local" "local" (single machine), "hub" (local + serve the MCP over HTTP), or "client" (spoke that writes to a remote hub)
hub_url "" hub worker MCP URL for mode = "client", e.g. http://<hub-hostname>:4011/mcp
mcp_host 0.0.0.0 bind host for the reflect mcp streamable-http transport
mcp_port 4010 default HTTP port for the read MCP server
mcp_worker_port 4011 default HTTP port for the worker MCP server (reflect mcp --worker)

workdir, opencode_bin, uv_bin, and path_extra default to this machine's paths; adjust them if you run reflect on a different machine.

Sizing the token budget (rule of thumb). Sessions are processed in batches of batch_size, and every batch runs in a fresh LLM context (the driver launches one short agent run per batch, so context never accumulates across a night — no compaction, no overflow). To pick a batch_size that fits your model's window, roughly one batch can hold

batch_size ≈ (context tokens × 0.85) ÷ (transcript_max_chars ÷ 4 + ~750)

tokens per session before overflowing — a token is about 4 characters, the 0.85 reserves ~15% of the window for the prompt, tool definitions, and output, and the ~750 accounts for the agent's own reasoning per session. For a 140K-token model with transcript_max_chars = 12000 that is (140000 × 0.85) ÷ (12000 ÷ 4 + 750) ≈ 32 sessions per batch; the shipped default of 15 is a safe choice. If a batch aborts with "request exceeds the available context size", lower transcript_max_chars (each session costs transcript_max_chars ÷ 4 tokens) or lower batch_size, rather than raising max_sessions_per_run — smaller batches keep each run within the window. run_timeout_s applies to each batch run.

Commands

  • uv run reflect run — run the nightly pipeline once (worklist → LLM agent → verify → lifecycle). --config <path> is optional and only needed to point at a config other than the default (~/.config/reflect/reflect.toml). In mode = "client" this drives the spoke against the hub instead.
  • uv run reflect run --dry-run — print the session worklist without running the agent
  • uv run reflect mcp — MCP server (read-only tools; --worker adds extraction tools). --transport stdio|streamable-http, --host, and --port select the transport; the HTTP defaults come from mcp_host/mcp_port/mcp_worker_port.
  • uv run reflect lifecycle — apply decay/archive/purge only
  • uv run reflect status — recent run history
  • uv run reflect stats [--days N] — usefulness dashboard (retrievals, judgements, reoccurrences, coverage, per-project, shared tier)
  • uv run reflect shared [show|pin <lesson_id>|unpin <lesson_id>|drop <lesson_id>|undrop <lesson_id>] — show the shared block with each member's reason and the candidates next in line, or pin/drop a lesson (defaults to show)
  • uv run reflect similar "<text>" [--limit N] — rank the lessons most similar to a query, the same ranking the shared pointer uses. Read-only: it records no retrieval, so the threshold can be tuned without moving the figures that decide whether the matcher earns its place. --limit defaults to match_max_pointers and may exceed it to widen the ranking.
  • uv run reflect embed fetch — download the embedding model bundle into embed_model_dir (see Shared matcher)

status, stats, lifecycle, shared, and similar operate on the hub's lessons.db; running them on a client returns an error.

Shared matcher

The shared tier injects a SHARED.md pointer into every session; the matcher decides which lessons that pointer offers. Everything below is optional — with no embedder the matcher falls back to FTS5 keyword ranking and the nightly run is unaffected.

  • Install. pip install 'reflect[embed]' (or uv sync --extra embed) adds onnxruntime and tokenizers, the local ONNX embedder. Without the extra, or without a model bundle, embeddings are disabled.
  • Hub-side. Embeddings and vectors belong to the hub. The nightly run (mode = "local" or "hub") refreshes the vector index immediately before the shared pass, and a mode = "client" spoke never embeds. reflect similar and the hub's POST /match read the same index. A refresh failure is reported on stderr and never changes a run's status or exit code.
  • Model bundle. The ~90 MB bundle is provisioned once per machine with reflect embed fetch, which needs egress to the Hugging Face host and to the CDN its resolve URL redirects to. Where that egress is unavailable, copy an existing bundle into embed_model_dir instead. To use a remote embedding endpoint rather than the local bundle, set embed_url.

Matcher REST endpoints

Both endpoints share the MCP servers' transports and bearer auth (see the auth note above): /match rides the read server (mcp_port, REFLECT_MCP_TOKEN), /attribution the worker server (mcp_worker_port, REFLECT_MCP_WORKER_TOKEN). Paths are relative to the server root, e.g. POST http://<hub-hostname>:4010/match.

POST /match — rank the lessons nearest a query. Same ranking as reflect similar, and every call appends one match_stat row.

// request
{"query": "how do I open a forgejo pull request",   // required; blank -> 400
 "project": "reflect",                              // optional, recorded on the row
 "session_id": "ses_abc",                           // optional, recorded on the row
 "exclude": ["<lesson-id>", "…"]}                   // optional: already-injected ids

// 200
{"pointers": [{"id": "<lesson-id>", "title": "Forgejo PRs use fj", "score": 0.82}],
 "degraded": false}                                 // true = keyword fallback, score null

A missing or blank query is 400 {"error": "query required"}. Any other failure (a malformed body, a non-list exclude, a store error) is answered 200 {"pointers": [], "degraded": true} rather than 5xx, so a session's prompt is never blocked by the matcher. A lesson already in exclude is dropped from pointers but still counts as clearing the similarity floor.

POST /attribution — report usage the client observed, as the record_usage tool does. Each record is {"lesson_id", "project", "session_id", "action"}, action being one of inject (the lesson was pointed at a session), retrieve_observed (the session pulled it) or judge_useful / judge_not_useful (a reconciled judgement). kind is accepted as an alias for action, with the spec's retrieve mapped to retrieve_observed; an explicit action wins.

// request
{"records": [{"session_id": "ses_abc", "project": "reflect",
              "lesson_id": "<lesson-id>", "action": "inject"}]}

// 200
{"recorded": 1, "skipped": 0}                       // skipped = malformed/duplicate/unknown

Reporting is idempotent — a record whose lesson, session, action and project already exist is skipped, so an overlapping window may be re-reported — and failed or disabled reports answer 200 {"recorded": 0, "skipped": 0} rather than an error.

OpenCode plugin

plugin/opencode/ is a zero-dependency OpenCode plugin that pushes these lessons into a session unprompted: once per prompt, capped and de-duplicated, it injects a small block of lesson pointers into the model request's system instructions and reports inject usage to the worker app. Register the directory — OpenCode resolves its root index.ts, not package.json's main — in the plugins array of ~/.config/opencode/opencode.jsonc; set REFLECT_HUB_URL (the read app serving POST /match) and, when the worker app is elsewhere, REFLECT_HUB_WORKER_URL (serving POST /attribution) in the environment the OpenCode background service starts from, not on an opencode run command line. See plugin/opencode/README.md.

Architecture

┌─────────────────────────────────────────────────────────────┐
│                        HUB machine                          │
│                                                             │
│  nightly timer ──> reflect run (extracts lessons)           │
│                  ──> writes to local lessons.db              │
│                                                             │
│  reflect mcp (port 4010) ──> read-only lessons for anyone   │
│  reflect mcp --worker (port 4011) ──> write tools for clients│
└─────────────────────────────────────────────────────────────┘
           ▲                          ▲
           │ hub_url (worker)         │ opencode mcp add (read)
           │                          │ (localhost on hub)
┌──────────┴──────────┐  ┌───────────┴──────────────┐
│   Client machine 1  │  │   Client machine 2       │
│                     │  │                          │
│ nightly timer ──>   │  │ nightly timer ──>        │
│   reflect run       │  │   reflect run            │
│   (writes via hub)  │  │   (writes via hub)       │
│                     │  │                          │
│ opencode mcp add ───┘  │ opencode mcp add           │
│   (reads from hub)     │   (reads from hub)         │
└───────────────────────┘  └──────────────────────────┘

On each machine the lesson decay/purge lifecycle is claimed at most once per UTC day across all machines, gated by the hub's shared lessons.db: whichever reflect run or finish_run call runs first that day applies it, and the rest skip — so spokes and the hub's own night never double-run it.

Notes

  • Reflect sessions are excluded from extraction (agent reflect-worker and descendants).
  • opencode.db is read-only; all writes go to lessons.db.
  • Search is FTS5 keyword search; the optional shared matcher (above) additionally ranks candidates by embedding similarity.
  • The nightly worker is confined to reflect_* MCP tools only; all other tools (bash, edit, webfetch, …) are denied via a deny-by-default agent permission allowlist.
  • max_sessions_per_run caps how many sessions a single night processes (built-in default 0 = no cap). Sessions not processed stay in the worklist and are picked up the next night. The shipped reflect.toml overrides this to 50 as a safety valve: the first run faces the entire history of past sessions as one huge backlog, so the cap drains it gradually instead of in a single enormous run.

License

This project is released to the public domain under the CC0 1.0 Universal license. See the LICENSE file for the full legal text.