npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

opencode-memory-pro

v1.6.2

Published

LanceDB-backed long-term memory provider for OpenCode — standalone fork of lancedb-opencode-pro with entity graph, lifecycle, and retention

Readme

opencode-memory-pro

npm version License: MIT CI

Long-term memory subsystem for OpenCode — a maintained, standalone fork of lancedb-opencode-pro (source: GitHub). It stores memories in LanceDB, embeds them locally or via OpenAI, builds an offline entity graph to boost and expand recall, tracks its own effectiveness, and ships a full lifecycle toolkit (backup, digests, retention, scoping, episodic learning). No source patching required: install the package directly.

Highlights

  • LanceDB vector store with IVF + hybrid (vector ✚ BM25) retrieval, real recency/importance/feedback scoring, and automatic compaction.
  • Offline entity graph (no LLM) — sqlite-backed co-occurrence + typed relation edges (uses, depends_on, ...), BFS graph-expansion recall, and a [graph+X%/n] boost on search results.
  • Hybrid capture — offline keyword heuristics by default, or LLM-quality structured extraction and abstractive digests via the OpenCode SDK (no API keys in plugin config).
  • Memory lifecycle tools — export/import, summarize/digest, expiry, dedup consolidation, scoping (promote/demote), citations, and a retention policy that digests then hides — it never deletes memories.
  • Self-observing — effectiveness events, KPI dashboard, weekly learning summary, and feedback wiring (memory_feedback_*) that feeds recall scoring.
  • Episodic learning — session/task episodes, similar-task recall, retry budget and recovery-strategy suggestions, success-pattern persistence.
  • Correct OpenCode ≥ 1.x wiringsession.created / session.deleted, per-session scoping, and fault-tolerant capture (falls back to heuristics when the LLM or embedder is offline).

Install

Published on npm — install directly (requires OpenCode ≥ 1.x and Node.js ≥ 22):

opencode plugin opencode-memory-pro

The latest release is on npm; source and releases are on GitHub.

Getting started

1. Install and restart OpenCode — done above. That's it for a baseline setup: the plugin works with zero configuration.

2. What you get out of the box, and what needs config:

| Capability | Out of the box | Needs config to enhance | |---|---|---| | Recall | Works — falls back to pure BM25 if no embedder is reachable | Embedding model → semantic/hybrid vector search | | Capture (session → memories) | Works — offline heuristic keyword capture | LLM summary model → LLM-quality extraction + abstractive digests | | Digests (memory_summarize / memory_expire) | Extractive offline digests | Same LLM summary model → abstractive digests |

Nothing below is required — every enhancement has an offline fallback. But configuring an embedding model makes recall dramatically better (semantic similarity instead of keyword-only), and configuring an LLM summary model makes captured memories higher quality and digests far more useful.

3. (Optional) configure an embedding model.

The plugin stores memories in a vector store; the embedding model decides how well recall can find semantically related memories. Two options:

  • Local (no API key, no cost): default — ollama + nomic-embed-text at http://127.0.0.1:11434. Requires Ollama running.
  • OpenAI-compatible (hosted): e.g. OpenAI, OpenRouter, or any endpoint that serves the /embeddings API. Set embedding.provider to "openai", the model, the base URL, and an API key:
{
  "embedding": {
    "provider": "openai",
    "model": "openai/text-embedding-3-small",
    "baseUrl": "https://openrouter.ai/api/v1",
    "apiKey": "sk-..."
  }
}

If the embedder is unreachable, recall falls back to pure BM25 over the FTS index and capture still works — the plugin is offline-tolerant by design.

4. (Optional) configure an LLM summary model (for LLM-quality capture/digests).

With capture.mode: "llm", on session idle the plugin sends the session buffer to an LLM (via an ephemeral OpenCode SDK session) which returns structured memories, and digests become LLM-written abstractive summaries. The LLM is addressed by OpenCode provider + model IDs — OpenCode owns routing, auth, and base URLs, so no API key or baseUrl lives in the plugin config. The provider must be resolvable in your opencode.json:

{
  "capture": {
    "mode": "llm",
    "llm": { "provider": "openrouter", "model": "z-ai/glm-5.3-flash" }
  }
}

On any LLM failure, capture falls back to heuristics and records an llm-fallback capture event — the plugin never breaks because the LLM is unavailable.

5. (Optional) start from the full annotated example — the package includes opencode-memory-pro.example.json with every option documented in-file. Copy it to ~/.config/opencode/opencode-memory-pro.json and edit:

cp node_modules/opencode-memory-pro/opencode-memory-pro.example.json ~/.config/opencode/opencode-memory-pro.json

Healthy installs are never silent: at startup the plugin logs a warning if it detects missing pieces (e.g. capture.mode: "llm" without a resolvable provider, or an OpenAI embedder without a key), and memory_stats reports the same as degradedFlags, plus llmHealth — so you can always tell what's running at full strength vs. degraded.

Configuration

The sidecar file opencode-memory-pro.json is resolved from (first match wins, then depth-merges):

  1. ~/.opencode/opencode-memory-pro.json
  2. ~/.config/opencode/opencode-memory-pro.json
  3. <worktree>/.opencode/opencode-memory-pro.json
  4. OPENCODE_MEMORY_PRO_CONFIG_PATH (explicit path; set OPENCODE_MEMORY_PRO_SKIP_SIDECAR=true to disable sidecar loading entirely)

Legacy config.memory blocks in opencode.json are still honored and deep-merged underneath the sidecar. Every setting below has an OPENCODE_MEMORY_PRO_* environment override that always wins over the file.

{
  "provider": "opencode-memory-pro",
  "dbPath": "~/.opencode/memory/lancedb",
  "embedding": { "provider": "openai", "model": "openai/text-embedding-3-small" },
  "graph": { "enabled": true },
  "retention": { "memory": { "enabled": true } }
}

Embedding

| Key | Default | Description | |---|---|---| | embedding.provider | "ollama" | "ollama" or "openai". | | embedding.model | "nomic-embed-text" (ollama) / required (openai) | Embedding model. | | embedding.baseUrl | http://127.0.0.1:11434 / https://api.openai.com/v1 | API base URL. | | embedding.apiKey | — | OpenAI only; required (or via env). | | embedding.timeoutMs | 6000 | Request timeout (min 500). | | embedding.retry.enabled | true | Retry failed embedding calls. | | embedding.retry.maxAttempts | 3 | Max attempts. | | embedding.retry.initialDelayMs | 1000 | Initial backoff delay. | | embedding.retry.backoffMultiplier | 2 | Exponential backoff factor. |

Env: OPENCODE_MEMORY_PRO_EMBEDDING_PROVIDER, ..._EMBEDDING_MODEL, ..._OPENAI_BASE_URL, ..._OLLAMA_BASE_URL, ..._OPENAI_API_KEY, ..._OPENAI_MODEL, ..._OPENAI_TIMEOUT_MS / ..._EMBEDDING_TIMEOUT_MS, ..._EMBEDDING_RETRY_ENABLED, ..._EMBEDDING_RETRY_MAX_ATTEMPTS, ..._EMBEDDING_RETRY_INITIAL_DELAY_MS, ..._EMBEDDING_RETRY_BACKOFF_MULTIPLIER.

If the embedder is unreachable, recall falls back to pure BM25 over the FTS index and capture still works — the plugin is offline-tolerant by design.

Retrieval

| Key | Default | Description | |---|---|---| | retrieval.mode | "hybrid" | "hybrid" (vector+BM25+fuzzy RRF) or "vector". | | retrieval.vectorWeight | 0.7 | Vector/BM25/fuzzy ratio before normalization. | | retrieval.bm25Weight | 0.3 | (Weights are normalized to sum 1.) | | retrieval.fuzzyWeight | 0.15 | fuse.js typo-tolerant fuzzy channel weight; 0 disables it. | | retrieval.fuzzyThreshold | 0.5 | fuse.js match threshold (lower = stricter). | | retrieval.minScore | 0.3 | Minimum score for a result to qualify. | | retrieval.rrfK | 60 | RRF constant. | | retrieval.recencyBoost | true | Boost recently recalled/created memories. | | retrieval.recencyHalfLifeHours | 72 | Half-life of the recency boost. | | retrieval.importanceWeight | 0.4 | Weight of stored importance in scoring (0–2). | | retrieval.feedbackWeight | 0.3 | Weight of feedback history in scoring (0–1). |

Env: OPENCODE_MEMORY_PRO_RETRIEVAL_MODE, ..._VECTOR_WEIGHT, ..._BM25_WEIGHT, ..._FUZZY_WEIGHT, ..._FUZZY_THRESHOLD, ..._MIN_SCORE, ..._RRF_K, ..._RECENCY_BOOST, ..._RECENCY_HALF_LIFE_HOURS, ..._IMPORTANCE_WEIGHT, ..._FEEDBACK_WEIGHT.

Injection

How memories are injected into the model context.

| Key | Default | Description | |---|---|---| | injection.mode | "fixed" | "fixed" (n memories), "budget" (fill token budget), "adaptive" (score-aware). | | injection.maxMemories | 3 | Max memories injected (fixed mode). | | injection.minMemories | 1 | Min memories always injected. | | injection.budgetTokens | 4096 | Token budget (budget/adaptive modes). | | injection.maxCharsPerMemory | 1200 | Per-memory character cap. | | injection.summarization | "none" | "none" / "truncate" / "extract" / "auto". | | injection.summaryTargetChars | 300 | Target length for summarized memories. | | injection.scoreDropTolerance | 0.15 | Allowed score drop when filling a budget. | | injection.injectionFloor | 0.2 | Hard score floor for injected memories. | | injection.codeSummarization.enabled | true | Summarize code-heavy memories on injection. | | injection.codeSummarization.pureCodeThreshold | 500 | Chars of pure code that trigger it. | | injection.codeSummarization.maxCodeLines | 15 | Max code lines kept. | | injection.codeSummarization.codeTruncationMode | "smart" | "smart" / "signature" / "preserve". | | injection.codeSummarization.preserveComments | true | Keep leading comments. | | injection.codeSummarization.preserveImports | false | Keep import statements. | | injection.taskTypeProfiles.* | per-type | Per task type: maxMemories, budgetTokens, summaryTargetChars, categoryWeights. |

Task types: coding, documentation, review, release, general.

Env (subset): OPENCODE_MEMORY_PRO_INJECTION_MODE, ..._INJECTION_MAX_MEMORIES, ..._INJECTION_MIN_MEMORIES, ..._INJECTION_BUDGET_TOKENS, ..._INJECTION_MAX_CHARS, ..._INJECTION_SUMMARIZATION, ..._INJECTION_SUMMARY_TARGET_CHARS, ..._INJECTION_SCORE_DROP_TOLERANCE, ..._INJECTION_FLOOR, ..._CODE_SUMMARIZATION_ENABLED, plus ..._INJECTION_{CODING,DOCS,REVIEW,RELEASE,GENERAL}_{MAX_MEMORIES,BUDGET_TOKENS,SUMMARY_CHARS}.

Dedup

| Key | Default | Description | |---|---|---| | dedup.enabled | true | Write-time dedup + consolidation. | | dedup.writeThreshold | 0.92 | Cosine similarity that blocks a duplicate write. | | dedup.consolidateThreshold | 0.95 | Similarity that merges duplicates during consolidation. | | dedup.candidateLimit | 50 | ANN candidates considered per row (10–200, clamped). |

Env: OPENCODE_MEMORY_PRO_DEDUP_ENABLED, ..._DEDUP_WRITE_THRESHOLD, ..._DEDUP_CONSOLIDATE_THRESHOLD, ..._DEDUP_CANDIDATE_LIMIT.

Consolidation runs automatically on session.idle (throttled to a 30-min cooldown), session.deleted (forced final pass), and session.compacted. Recent memories (last-recalled within 5 min) and already-merged rows are skipped. The batch size of the ANN consolidation queries is tunable via OPENCODE_MEMORY_PRO_QUERY_BATCH (default 16).

Entity graph

| Key | Default | Description | |---|---|---| | graph.enabled | true | Enable the offline entity graph. | | graph.dbPath | ~/.opencode/memory/graph.db | sqlite location. | | graph.boostLambda | 0.3 | Entity-overlap score boost (0–1). | | graph.maxEntitiesPerMemory | 20 | Max entities extracted per memory/query. | | graph.maxEdgeProvenance | 20 | Max memories backing an edge (bounds stored weight). | | graph.typedEdges | true | Emit typed relation edges (uses, depends_on, ...). | | graph.expansionEnabled | false | BFS graph-expansion recall (off by default since 1.6.0; see changelog). | | graph.maxHops | 2 | BFS depth (1–4). | | graph.expansionLimit | 5 | Max expanded candidates. | | graph.expansionLambda | 0.3 | Expansion score weight (0–1). |

Env: OPENCODE_MEMORY_PRO_GRAPH_ENABLED, ..._GRAPH_DB_PATH, ..._GRAPH_BOOST_LAMBDA, ..._GRAPH_TYPED_EDGES, ..._GRAPH_EXPANSION_ENABLED, ..._GRAPH_MAX_HOPS, ..._GRAPH_EXPANSION_LIMIT, ..._GRAPH_EXPANSION_LAMBDA.

Expansion fetches each entity's strongest, most-recent edges first and applies a ranking-only recency decay (edges ≥ 1 year old fade to a 0.35 floor), so stale connections lose influence without ever being deleted.

Capture modes

capture.mode selects how session content becomes memories (and how memory_summarize / memory_expire build digests):

  • "heuristics" (default) — offline keyword-signal detection (success, decision, fact, preference signals), zero LLM cost, works with the embedder offline.
  • "llm" — structured extraction: on session.idle, the session buffer is sent to the configured LLM (via an ephemeral OpenCode SDK session; tools disabled), which returns [{content, type, importance}] JSON; each item is embedded, dedup-checked, stored, and graph-indexed. Digests become LLM-written abstractive summaries (digestKind: "llm" in metadata). Preference extraction is unaffected (it runs at recall time, offline). On any LLM failure the pipeline falls back to heuristics and records a llm-fallback capture event. Extraction runs with reasoning suppressed (the system prompt forbids step-by-step thinking — 1.4.9) to keep session.idle latency down; transcripts are capped at 60k chars, keeping the newest tail (1.4.9). A successful prompt that returns no text parts (flash-tier providers under load) is retried on the same ephemeral session with backoff before falling back (1.6.1): OPENCODE_MEMORY_PRO_LLM_RETRY_MAX_ATTEMPTS (default 3), OPENCODE_MEMORY_PRO_LLM_RETRY_INITIAL_DELAY_MS (default 250), OPENCODE_MEMORY_PRO_LLM_RETRY_BACKOFF_MULTIPLIER (default 2).

The LLM is addressed by OpenCode provider + model IDs — OpenCode owns routing, auth, and base URLs, so no API key or baseUrl lives in the plugin config. The provider must be resolvable in your opencode.json.

{
  "capture": {
    "mode": "llm",
    "llm": { "provider": "openrouter", "model": "z-ai/glm-5.3-flash" }
  }
}

Env: OPENCODE_MEMORY_PRO_CAPTURE_MODE, OPENCODE_MEMORY_PRO_CAPTURE_LLM_PROVIDER, OPENCODE_MEMORY_PRO_CAPTURE_LLM_MODEL.

Capture thresholds: OPENCODE_MEMORY_PRO_MIN_CAPTURE_CHARS (default 80, min 30) and OPENCODE_MEMORY_PRO_MAX_ENTRIES_PER_SCOPE (default 3000, min 50) bound what gets captured.

Summarize & retention

memory_summarize builds digests of old memories; memory_expire runs the retention sweep (digest-then-hide — originals are marked digested and hidden from recall, never deleted).

summarize:

| Key | Default | Description | |---|---|---| | summarize.enabled | true | Allow digest creation. | | summarize.minAgeDays | 30 | Min memory age to be digest-eligible. | | summarize.minGroupSize | 3 | Smallest group earning a digest. | | summarize.targetChars | 500 | Digest length. | | summarize.replace | false | Mark originals digested after absorbing. |

Env: OPENCODE_MEMORY_PRO_SUMMARIZE_ENABLED, ..._SUMMARIZE_MIN_AGE_DAYS, ..._SUMMARIZE_MIN_GROUP_SIZE, ..._SUMMARIZE_TARGET_CHARS, ..._SUMMARIZE_REPLACE.

retention:

| Key | Default | Description | |---|---|---| | retention.effectivenessEventsDays | 90 | TTL for effectiveness events (0 disables; negative → 90). | | retention.memory.enabled | true | Enable the memory-level digest-then-hide sweep. | | retention.memory.unusedDays | 60 | Unused (not recalled) for this many days → candidate. | | retention.memory.minAgeDays | 180 | Minimum memory age. | | retention.memory.minGroupSize | 2 | Smallest per-category group that earns a digest. | | retention.memory.targetChars | 500 | Digest length. | | retention.memory.minImportance | 0.3 | Importance floor — protects high-value rows. | | retention.memory.protectedCategories | ["digest"] | Categories never expired. | | retention.memory.digestMaxAgeDays | 365 | Hard-expire digests older than this (0 disables digest expiry). |

Env: OPENCODE_MEMORY_PRO_RETENTION_EVENTS_DAYS, ..._RETENTION_MEMORY_ENABLED, ..._RETENTION_MEMORY_UNUSED_DAYS, ..._RETENTION_MEMORY_MIN_AGE_DAYS, ..._RETENTION_MEMORY_MIN_GROUP_SIZE, ..._RETENTION_MEMORY_TARGET_CHARS, ..._RETENTION_MEMORY_MIN_IMPORTANCE, ..._RETENTION_MEMORY_DIGEST_MAX_AGE_DAYS.

retention.scoring — scope-cache truncation weights (which records survive when a scope exceeds maxRecordsPerScope, default 1000):

| Key | Default | Description | |---|---|---| | retention.scoring.recencyHalfLifeHours | retrieval value (72) | Recency decay half-life for the retention score. | | retention.scoring.importanceWeight | retrieval value (0.4) | Importance multiplier for the retention score. | | retention.scoring.feedbackWeight | retrieval value (0.3) | Feedback multiplier for the retention score. |

Each key defaults from the matching retrieval.* weight when unset, but can diverge — raising retention.scoring.importanceWeight (up to 2) protects old important/verified/positively-fedback memories from being evicted by newer throwaway captures without changing live search ranking.

Env: OPENCODE_MEMORY_PRO_RETENTION_SCORING_RECENCY_HALF_LIFE_HOURS, ..._RETENTION_SCORING_IMPORTANCE_WEIGHT, ..._RETENTION_SCORING_FEEDBACK_WEIGHT.

Scoping

| Key | Default | Description | |---|---|---| | scoping | "global" | "global" collapses all scopes (single-user mode); "project" restores per-project scoping. | | includeGlobalScope | true | Project queries also see global memories. | | globalDetectionThreshold | 2 | Projects with ≥ N memories become "detected" scopes. | | globalDiscountFactor | 0.7 | Score discount applied to cross-scope global hits. | | unusedDaysThreshold | 30 | Threshold for "unused" classification in lifecycle views. |

Env: OPENCODE_MEMORY_PRO_SCOPING, ..._INCLUDE_GLOBAL_SCOPE, ..._GLOBAL_DETECTION_THRESHOLD, ..._GLOBAL_DISCOUNT_FACTOR, ..._UNUSED_DAYS_THRESHOLD.

Logging

logging controls the plugin's log sink. Logs route to opencode's /log bus (shows in the TUI); the optional file adds a crash-surviving append-only file (timestamps, level, message, JSON extras).

{
  "logging": { "level": "debug", "file": "~/.opencode/memory/opencode-memory-pro.log" }
}
  • level"debug" | "info" (default) | "warn" | "error". "debug" traces index creation, consolidation, compaction, and embedder/retrieval internals.
  • file — log file path (~ expanded). Omit or set null to keep bus-only.
  • Named diagnostic lines (both at info): [timing] <span> took <ms> spans (1.4.7+) for store/embedder/LLM/capture operations, and [llm] <title>: usage in=… out=… reasoning=… cacheRead=… per-prompt token usage (1.4.8+) — together they make llm.prompt / capture.flush latency attributable from the log alone.

Env: OPENCODE_MEMORY_PRO_LOG_LEVEL, OPENCODE_MEMORY_PRO_LOG_FILE (applied at plugin initialization, before sidecar resolution).

ANN tunables

Advanced knobs for the vector-search layer. Defaults are conservative; raise nprobes if you see recall misses, raise QUERY_BATCH if consolidation is slow.

  • OPENCODE_MEMORY_PRO_NPROBES — IVF probe count for filtered vector searches (recall vs. latency). Default 40.
  • OPENCODE_MEMORY_PRO_QUERY_BATCH — ANN queries per batched vector-search call during consolidation. Default 16.

Tools

All tools are auto-registered when the plugin loads. Hybrid recall surfaces .d.ts type declarations for the IDEs.

Memory core

| Tool | Description | |---|---| | memory_search | Hybrid semantic search using vector + BM25 + graph boost. | | memory_remember | Explicitly store a memory (with optional category). | | memory_delete | Remove or disable a memory. | | memory_clear | Clear all memories in a scope. | | memory_why | Explain why a specific memory was recalled. | | memory_explain_recall | Explain the factors behind the last recall. | | memory_citation | View or update citation info for a memory. | | memory_validate_citation | Validate a citation and update its status. | | memory_global_list | List global-scoped memories (with filter). | | memory_stats | Memory provider status and index health. |

Feedback & effectiveness

| Tool | Description | |---|---| | memory_feedback_useful | Record whether a recalled memory was helpful. | | memory_feedback_wrong | Record memory that should not have been stored. | | memory_feedback_missing | Record memory that should have been stored. | | memory_effectiveness | Effectiveness metrics for capture recall and feedback. | | memory_dashboard | Weekly learning dashboard with trends and insights. | | memory_kpi | Learning KPIs (retry-to-success rate, memory lift). | | memory_what_did_you_learn | Recent learning summary by category. |

Lifecycle

| Tool | Description | |---|---| | memory_export | Backup all memories to JSON. | | memory_import | Restore memories from an export (merge/replace). | | memory_summarize | Create digests of old memories. | | memory_expire | Retention sweep: fold unused memories into digests. | | memory_event_cleanup | Clean up expired effectiveness events (optional archive). | | memory_consolidate | Merge near-duplicate memories in a scope. | | memory_consolidate_all | Global duplicate cleanup (daily cron friendly). | | memory_reembed | Detect/repair an embedding-dimension mismatch (backs up, rebuilds the table, re-embeds every memory). |

Scoping

| Tool | Description | |---|---| | memory_scope_promote | Promote a project memory to global scope. | | memory_scope_demote | Demote a memory from global to project scope. |

Episodic learning

| Tool | Description | |---|---| | task_episode_create | Create a task episode record. | | task_episode_query | Query task episodes by scope and state. | | similar_task_recall | Find similar past tasks via semantic search. | | retry_budget_suggest | Retry budget suggestion from historical data. | | recovery_strategy_suggest | Recovery strategy suggestions after failures. |

Tooling extras

| Tool | Description | |---|---| | memory_port_plan | Plan non-conflicting host ports for compose services. |

Failed sessions additionally record a classified failureType (syntax/runtime/logic/resource/unknown) and the raw errorMessage on their task episode, so similar_task_recall / retry_budget_suggest / recovery_strategy_suggest learn from real failures.

Data locations

  • Memories + events: ~/.opencode/memory/lancedb (LanceDB) — override with dbPath / OPENCODE_MEMORY_PRO_DB_PATH.
  • Entity graph: ~/.opencode/memory/graph.db (sqlite) — override with graph.dbPath.
  • Log file (optional): ~/.opencode/memory/opencode-memory-pro.log.

Development

npm install
npm test            # node --test (unit + integration suites)
npm run test:e2e    # full plugin E2E scenario (mock embedder, real LanceDB)
npm run verify      # tests + pack dry-run

CI runs on GitHub Actions (Node 22 + 24) on every push/PR to main.

Changelog

v1.6.2 (2026-09-09)

Minor-queue completion — all 29 MINOR findings from the Sep 8 code review (r1–r29) are fixed. Highlights, grouped:

  • Capture pipeline hardeningFLUSH_SNAPSHOT_CONSUME (fragments appended during a flush survive it — the read-then-delete was losing them), FLUSH_IN_PROGRESS_GUARD (concurrent session.idle + session.deleted flushes coalesce, no duplicate captures), ACTIVE_EPISODES_CAP (episode map FIFO-capped at 500, mirroring sessionErrors), CONFIG_CHANGE_INIT_RESET (an embedding-config change while init is in flight now resets the in-flight promise so the new dimension is probed).
  • LLM retry + extractionRETRY_SESSION_PER_ATTEMPT (each no-text retry now runs in a FRESH ephemeral session — same-session reuse multiplied the transcript × attempts and could blow small context windows), SET_RETRY_POLICY_CLAMP (setLlmRetryPolicy clamps maxAttempts/ initialDelayMs/backoffMultiplier like the env path), EXTRACTION_EMPTY_IMPORTANCE (empty-string/null importance no longer silently ranks 0.0).
  • Store consistencyCONSOLIDATE_WRITES_ON_ABORT (aborted consolidation flushes + resets its staged writes), FEEDBACK_TTL_INVALIDATION (deleted feedback rows invalidate the affected scope's aggregate), USAGE_CACHE_FRESHNESS (updateMemoryUsage's cache fast path ignores stale entries — fixes the cross-process recallCount regression), DELETE_ORDER_FALLBACK (force-delete prefix fallback is deterministic, newest-first), RETRY_TO_SUCCESS_PARSE (malformed retryAttemptsJson no longer crashes memory_kpi).
  • Retention / digestsIDLE_SWEEP_DEDUP_DECOUPLE (retention sweep now runs on idle/compacted even with dedup disabled), DIGEST_EXPIRY_RESTORE (hard-expired digests restore their originals to active instead of orphaning them), DIGEST_SCOPE_FOLLOWS_MEMBERS, DIGEST_EXPIRY_SAFE_NUM (malformed digestMaxAgeDays falls back to 365), PROTECTED_CATEGORIES_EMPTY (explicit protectedCategories: [] now disables digest protection).
  • Entity graphREINDEX_BACKFILL_HEAL (backfill re-runs idempotently, healing partial graphs), ENTITY_GC_EDGE_CLEANUP (GC deletes a dead entity's edges), REINDEX_ENTITY_HEAL (missing entity row healed with count 1, not a count-0 ghost).
  • Parsing / validation / preference / configVALIDATION_ZERO_COUNT
    • VALIDATION_OUTCOME_CASE (clean "0 errors" and lowercase "found 1 error" classified correctly), SIGNAL_WORD_BOUNDARY (capture gate no longer fires "passed" inside "bypassed"), PREFERENCE_VERB_LOOKAHEAD ("prefer to use docker" no longer captures junk "use"/"to" keys), KEY_SENTENCE_FALLBACK (summaries never return an empty string), EPISODIC_SHAPE_GUARD (wrong-shape episodic JSON degrades, not throws), STABLE_HASH_NONSTRING, RRF_K_CLAMP ([1,1000]), DEDUP_CLAMP_LOG, SCOPING_CACHE_LRU, EMBEDDER_HEALTH_RESET + EMBEDDER_RETRY_COUNT_RESET, MEMORY_STATS_EMBEDDER_GUARD, DELETE_FORCE_SCOPE.

Every fix is mutant-verified with a regression test; suite 166/166 + e2e.

v1.6.1 (2026-09-09)

Code-review hardening bundle — the 1.6.0 source-verified review pass (7 MAJOR findings) is fully fixed:

  • NO_TEXT_RETRY — flash-tier providers under load (gemini-2.5-flash-lite, z-ai/glm-5.3-flash) sometimes resolve session.prompt successfully with an empty parts array, silently downgrading capture to heuristics. The capture prompt is now retried on the same ephemeral session (create/delete stay 1:1) with a short backoff before giving up — only the silent-empty case retries; thrown errors still fail immediately. Each attempt logs its own usage line so empty replies stay attributable. Knobs (read at module load, like the other env knobs): OPENCODE_MEMORY_PRO_LLM_RETRY_MAX_ATTEMPTS (default 3, 1–10), OPENCODE_MEMORY_PRO_LLM_RETRY_INITIAL_DELAY_MS (default 250, 0–60000), OPENCODE_MEMORY_PRO_LLM_RETRY_BACKOFF_MULTIPLIER (default 2).
  • RETRY_BACKOFF_CLAMP — the new backoffMultiplier knob is clamped to [1,10] at module load and on setLlmRetryPolicy (it was unbounded — maxAttempts=10, multiplier=10 meant delays up to ~8.7y and a capture flush hanging for weeks on the no-text path).
  • OWN_SESSION_RECALL_GUARD — the experimental.chat.system.transform hook is the ONLY hook without an isOwnSession guard, so the plugin's own ephemeral LLM sessions (capture/digest) got recall injected into their extraction prompt with the transcript as the query (self-amplification + an extra embed+search per flush). The guard now applies there too.
  • RECALL_SEARCH_GUARD — the recall pipeline's store.search in the transform hook was the only unguarded store call: a LanceDB failure failed the user's chat turn instead of degrading to no-injection. Now wrapped — any failure degrades to results = [] (no injection), lastRecall still recorded.
  • CAPTURE_BUFFER_AFTER_WRITES — capture fragments were deleted from the buffer BEFORE the store writes completed; a transient LanceDB failure threw after the delete and the transcript was permanently lost (same class as the 1.4.5 CAPTURE_RETRY_ON_DEFERRED fix, which covered init-deferral only). The delete now runs on all five success exit paths, after their store writes — a throw leaves fragments in place for retry.
  • EPISODIC_TABLE_SINGLE_FLIGHTensureEpisodicTaskTable had no single-flight guard (the 1.4.5 init race at a different entry point): concurrent first-touches raced createTable, the loser threw "table already exists", and every episodic hook (commands/retries/KPI) silently no-oped for that session. Memoized in-flight promise, same pattern as init().
  • DIGEST_SCOPE_FOLLOWS_MEMBERS — in scoping:"project" mode the retention sweep / memory_summarize read [project:X, global] candidates but stored the digest under the active project scope while markDigested hid the global originals — every other project lost those memories from recall with no replacement. A digest now lives in global whenever any member is global (visible to every project); pure-project groups keep their digest in the project scope.
  • PORT_PLAN_TCP_CHECKmemory_port_plan consulted only persisted reservations, so a host port bound by a live process was handed out and compose failed at up time. The planner now probes each candidate with a real TCP bind (isTcpPortAvailable, previously dead code in ports.js) before assigning.

All fixes are mutant-verified with regression tests; suite 133/133 + e2e.

v1.6.0 (2026-09-08)

Precision-tuned defaults + searchable-cache cap wiring.

  • graph.expansionEnabled default true → false, retrieval.minScore default 0.2 → 0.3. Tuned on the live store with scripts/precision-tune.mjs (8 ground-truth queries, MRR@5/Recall@5/Precision@5/noise): BFS graph expansion injected tangentially-related memories into top-5 (expansionNoiseTop5 12 → 0) and suppressed MRR@5 (0.556 → 0.917 once off). Entity co-occurrence boost (graph.boostLambda, still 0.3) measures better than expansion alone.
  • SCOPE_CACHE_CAP_WIRE — the store's per-scope searchable-cache cap (cacheConfig.maxRecordsPerScope, module default 1000) now follows config.maxEntriesPerScope (default 3000) via a new wireStoreCacheCap seam, unless the operator explicitly set OPENCODE_MEMORY_PRO_MAX_RECORDS_PER_SCOPE (env keeps precedence). A scope past 1000 records previously hid up to maxEntriesPerScope - 1000 memories from search (live store: 1315 records → ~315 invisible); raising to 3000 measured +25% Recall@5, +19% Precision@5, +9% MRR@5 on the tuning suite.
  • New scripts/precision-tune.mjs tuning harness (ground-truth queries, metric aggregation, config grid sweep, --candidate + OPENCODE_MEMORY_PRO_CAP overrides) — score any candidate against live or temp stores.
  • Tests: 122/122 (incl. two SCOPE_CACHE_CAP_WIRE regression tests).

v1.5.9 (2026-09-08)

FEEDBACK_SCAN_BOUND — bounded feedback-stats scancomputeFeedbackStatsForScope used the global SCAN_LIMIT (5M default) with a timestamp-desc sort on every feedback-cache miss/stale window (default 10 min), turning a recall turn into a multi-second scan of effectiveness_events.lance — made worse when a second opencode instance (e.g. an idle opencode-serve daemon) shared the same LanceDB directory (write contention; spikes correlated with truncation warnings). Now a per-instance feedbackStatsScanLimit (constructor-time env int, default 50k, min 1 for testability) bounds the .limit() in computeFeedbackStatsForScope — env OPENCODE_MEMORY_PRO_FEEDBACK_STATS_SCAN_LIMIT. recall/store.search returned to baseline (~0.6s) after the fix; regression test integration: feedback stats scan honors the per-instance bound (FEEDBACK_SCAN_BOUND).

v1.5.8 (2026-09-08)

Hygiene + contention bundle — three areas:

  • EPISODIC/TOOL HYGIENEtools/episodic.js + tools/feedback.js: safe store calls (wrapped, logged, friendly retry message instead of raw throws), accurate "not initialized" message instead of blaming the embedding provider (episodic/feedback tools never embed), dead ?? zod fallbacks removed, confidence guarded against non-finite values, unused imports dropped. similar_task_recall is keyword-overlap (not "semantic search") — description corrected and default threshold lowered 0.85 → 0.5 (store default + auto-recall path aligned) so it actually returns results.
  • ISSUE3_YIELD — consolidate event-loop starvation — consolidation is CPU-bound and runs on session.idle in-process; it used to monopolize the event loop for the whole run and starve concurrent recalls (observed recall.pipeline 9.4s / store.search 9.1s vs ~0.6s clean). Now yields to the event loop every sub-batch when >40ms has elapsed since the last yield, in both the ANN and O(N²) fallback paths. Also removed ~4 redundant metadataJson JSON.parse per candidate pair (reuses the pre-built metaById).
  • CONSOLIDATE_WRITE_BATCHING — all consolidation row updates (merge loser, merge survivor, cleared duplicate flags) are staged and flushed once per row at the end, so a row touched by both a merge and a flag-clear commits exactly once. LanceDB applies one values object per predicate, so distinct rows still commit 1:1 — this is the single chokepoint where a future LanceDB per-row bulk update can slot in for the real 100× write win.

v1.5.7 (2026-09-08)

VERSION_STAMP_FIX — 1.5.6 released with a stale version stamp — the 1.5.6 release (SURVIVOR_MERGE_FIX, commit e134077) bumped package.json but missed PLUGIN_VERSION in dist/index.js and both package-lock.json entries, so the runtime self-reported Plugin v1.5.5 initialized while running 1.5.6 code. This release corrects the stamps (PLUGIN_VERSION, package-lock root + package entry) so the runtime log, npm metadata, and git tag all agree on 1.5.7.

v1.5.6 (2026-09-08)

SURVIVOR_MERGE_FIX — consolidation deadlockconsolidateDuplicates stopped merging near-duplicates because the candidate guard on the merge target treated metadataJson.mergedFrom (the survivor's provenance stamp, written by every previous merge) as a merged-away marker alongside status:"merged". Survivor rows were therefore permanently immune from being absorbed, and once a piece of content had been merged once, every future ≥threshold duplicate pair involving it was skipped forever:

  • Fix — the merge-target guard now blocks only status:"merged" (the row that actually lost a merge); mergedFrom no longer disqualifies a survivor from absorbing new duplicates. Applied to the ANN path and both sides of the brute-force fallback.
  • Impact — a live store that had 0 merges across recent runs despite 589 flagged duplicates merged 675 pairs on the first run after the fix (1794 → 1119 active rows, 0 remaining ≥0.95 pairs).
  • Regression test constructs the deadlock shape (two same-text survivors both carrying mergedFrom) and asserts they merge (mutant-verified: fails with the old guard).

v1.5.5 (2026-09-08)

Scope-cache retention scoring (proposal Issue 1) — when a scope exceeds maxRecordsPerScope (default 1000), truncation no longer keeps only the N newest records; the survivors are now the top-N by a composite retention score so valuable old memories (important, verified, positively fed back) aren't silently dropped from search by newer throwaway captures:

  • RETENTION_SCORING — composite retention score (recency × importance × feedback × citation) in the scope-cache truncation branch; wrong citations score -1 and are evicted first. Timestamp is the deterministic tiebreak. Unconfigured stores keep the legacy recency-only behavior.
  • retention.scoring.* config blockrecencyHalfLifeHours, importanceWeight, feedbackWeight, each defaulting from retrieval.* but able to diverge, so cache eviction can protect old memories without changing live search ranking (env: OPENCODE_MEMORY_PRO_RETENTION_SCORING_*).
  • Regression tests: unit coverage of the scoring semantics + config resolution, integration coverage of truncation on a real store (mutant- verified both the sort key and the config wiring).

Note: with default weights (importance 0.4) the retention score closely tracks recency for most records — raise retention.scoring.importanceWeight to ~1.0+ to actively favor old important/verified memories in the cache.

v1.5.4 (2026-09-07)

Hotfix — plugin no longer loads (1.5.3 regression):

  • V1_PLUGIN_EXPORT — the default export is now a V1 plugin object ({ id, server }) instead of the legacy factory function: opencode's plugin loader treats a module whose default export is a function as a legacy plugin and then calls every function export as a plugin factory with (input, options). Module namespace exports sort alphabetically, so 1.5.3's new export function appendCaptureFragment sorted before default; the loader invoked it first, it threw on input.captureBuffer being undefined, and loading aborted before the real plugin ever ran — the plugin silently failed to initialize ("failed to load plugin ... state.captureBuffer.get") with no error surfaced to the user. The V1 shape makes the loader call server(input) only, and the helper was renamed to recordCaptureFragment (r > d) plus moved to the bottom export list so even the legacy fallback path would reach the server factory first. Regression tests lock the V1 shape, the export-order invariant, and that no export before the server throws when invoked legacy-style.

v1.5.2 (2026-09-07)

Bug fix + three perf patches bundled into one release:

  • TOOL_DELETE_FORCE — memory_delete can now hard-delete soft-deleted memories: it used store.deleteById, whose status-filtered read cannot see disabled rows — so memory_forget (soft) followed by memory_delete returned "not found in current scope" forever and the row stayed on disk. Switched to deleteByIdForce (the same fix memory_forget got in 1.3.8), which does an exact-id raw delete that sees hidden rows.
  • FEEDBACK_STATS_CACHE — per-scope cache for the feedback aggregate: getMemoryFeedbackStatsMap rebuilt a fresh memoryId-bounded events-table query sized to the entire candidate set (up to 1000 ids) on every search. The per-scope aggregate is now cached and invalidated on each new feedback event — zero behavior change, fewer/cheaper queries on every recall turn.
  • FIRE_AND_FORGET_RECALL_EVENT — recall telemetry write no longer awaited: the type: "recall" event put (a LanceDB table commit) used to add its latency to every chat turn's system-prompt construction; nothing downstream reads it. Now fired without awaiting (with a warn log on failure), matching the existing updateMemoryUsage pattern.
  • REGEX_DEDUP / QUERY_ENTITY_MEMO — graph extraction hot-path dedup: extractEntities recompiled 57 keyword regexes on every call; the precompiled set is now exported and shared with graph.js, and getEntitiesForQuery memoizes the last query so the back-to-back boostResults + expandRecall calls per recall turn run extraction once instead of twice.

v1.5.1 (2026-09-07)

Performance review — four fixes cutting blocking subprocess spawns, full-table scans, and redundant re-scans out of the hot paths (dedup on capture, consolidation, recall usage-tracking, scoping):

  • NO_GIT_SCOPE — project scoping no longer shells out to git: deriveProjectScope ran a BLOCKING git config --get remote.origin.url subprocess on every call in project mode. Dropped entirely; the project scope is now always derived from the worktree path alone. Trade-off: two clones/worktrees of the same repo now get different scopes (they shared one via the remote URL before).
  • SCOPING_CACHE — resolveScoping cached per worktree (5s TTL, 20 entries): it previously re-read + re-parsed config sidecars on every tool call. The cache is keyed on the OPENCODE_MEMORY_PRO_SCOPING env value and cleared by setScopingConfigSource, so runtime env flips and config-hook injection are never served stale.
  • FAST_PATH_USAGE_LOOKUP — updateMemoryUsage no longer full-scans the table per recalled row: it checks the warm scope cache first (zero I/O), then an id-bounded findRecordsByIds query, and only falls back to the full scan (which also supports id-prefix matching). Manual memory_search now fires usage updates without awaiting, matching the auto-recall path.
  • CACHE_REUSE_DEDUP — no-index vector dedup reuses the warm scope cache: the brute-force fallback in findSimilarVectors/findSimilarVectorsBatch issued a fresh full-scope scan on every capture dedup check and every consolidation batch. It now reuses version- and age-checked cached rows (precomputed norms included) and returns nothing on any miss so callers fall back unchanged.
  • INDEX_RECHECK_INTERVAL_MS — vector index builds without a restart: ensureIndexes ran exactly once at init, so a store crossing MIN_ROWS_FOR_INDEX (256 rows) mid-process stayed on the brute-force cosine fallback forever. maybeOptimizeAll (after every write path) now kicks an independently throttled, fire-and-forget recheck (5-min interval) that rebuilds the ANN index once the store is eligible.

v1.5.0 (2026-09-07)

Episodic-task query ordering fix (bug report: task_episode_query silently hid new episodes once a scope's count exceeded the query limit):

  • EPISODE_SCAN_ORDER — episodic_tasks reads are now recency-ordered: queryTaskEpisodes and suggestRetryBudget queried with no .orderBy() and no .limit()task_episode_query's client-side .slice(0, limit) then returned an arbitrary oldest-first scan prefix, so once a scope held more episodes than the limit (max 100) every newer episode was truncated away permanently, with no error or log. Both now order by startTime DESC (the startTime equivalent of the 1.4.3 SCAN_ORDER/SCAN_LIMIT pattern used for the memories/event tables). task_episode_query's slice now means "most recent N"; suggestRetryBudget's failedEpisodes[0] is now the most recent failure (the reference error), not an arbitrary row. Verified live vs the reported failure: with 1110 episodes, the newest session's episodes and all pending rows are now returned first; previously 0 of 147 same-day episodes were visible at any limit ≤ 100. Memory-KPI aggregations are unaffected (they read the full ordered set).
  • KPI (calculateRetryToSuccessRate/calculateMemoryLift) and suggestRecoveryStrategies reads also flow through the ordered/limited query where applicable (aggregates see every row; none truncate).

v1.4.9 (2026-09-07)

Scope-cache persistence + capture-LLM prompt hardening:

  • CACHE_PATCH_USAGE — usage writes no longer evict the scope cache: updateMemoryUsage used to table.update + invalidateScope per recalled result, bumping the scope version on every LLM request so the cache never survived one round (20+/20+ cache misses, recalls 1279–1570ms). Usage fields (lastRecalled/recallCount/projectCount) are not used by search scoring, so the write now patches the cached record in place — the cache stays hot and consumers still see fresh counters. Verified live: back-to-back recalls hit at ~1ms and recall.pipeline dropped to 360–377ms.
  • CAPTURE_NO_REASONING — extraction prompt suppresses chain-of-thought: live usage samples showed every capture.flush burning reasoning=638–1173 tokens before a tiny out=168–671 JSON reply, with llm.prompt p50=20.4s / p90=52.9s. The SDK prompt body exposes no maxTokens/temperature/reasoning knobs, so the system prompt now explicitly forbids step-by-step reasoning. Verdict via the reasoning= field in the usage log.
  • CAPTURE_TAIL_KEEP — truncation keeps the newest tail: transcripts routinely rail at the 60k-char cap; truncation previously kept the HEAD, discarding exactly the recent decisions a memory system should keep. Now the tail (freshest context) survives.
  • PLUGIN_VERSION constant synced with package.json (the "initialized" log no longer lies about the version).

v1.4.8 (2026-09-07)

Latency-attribution release:

  • CACHE_TTL_DEFAULT — scope-cache staleness default 60s → 10 min (cache.staleAfterMs, env-tunable; 0 restores pure version gating). The 60s default was shorter than the inter-turn gap, so the cache almost never hit.
  • PROMPT_USAGE_LOG — per-prompt token usage logged: every ephemeral LLM call now logs [llm] <title>: usage in=… out=… reasoning=… cacheRead=… so llm.prompt latency is attributable (provider queue vs reasoning-token burn vs input volume).

v1.4.7 (2026-09-07)

TIMING_SPANS — timing spans for performance tuning: [timing] log lines (ms) for store.search, store.getCachedScopes, store.put, store.putEvent, store.pruneScope, store.optimize, store.consolidate, consolidate.duplicates, retention.sweep, embedder.embed, llm.prompt, and capture.flush (with fragmentCount) — per-flush and per-recall costs are attributable from the log alone.

v1.4.6 (2026-09-07)

  • SESSION_LIFECYCLE_GUARDsession.start/session.end store I/O is try/caught; a transient LanceDB failure no longer propagates out of the event hook or skips end-of-session dedup/consolidation (a failed session end retains the episode for retry).
  • PREFERENCE_BUDGET_CONFIG — preference injection honors injection.budgetTokens (was hard-coded 300; the ?? 500 fallback was dead).
  • CLEAR_SCOPE_COUNT_ALLclearScope counts every deleted row including merged/digested/disabled (previously undercounted) and notifies their graph nodes.
  • OWN_SESSIONS_CAP — the own-session ID set is FIFO-capped at 500.
  • NONE_MODE_NO_TRUNCATEsummarization: "none" no longer truncates at textThreshold*4.
  • PRUNE_SCOPE_BATCH_DELETE — per-scope prune deletes in one batched id IN (...) statement instead of a per-row loop.

v1.4.5 (2026-09-07)

Bug-fix release — nine verified production bugs, each closed with a mutant-verified regression test:

  • Single-flight init — concurrent ensureInitialized/store.init calls coalesce instead of double-running (createTable race, connection leak, double graph backfill).
  • Capture buffer survives deferred init — fragments are retained and retried when init is deferred, instead of being deleted before the guard.
  • session.idle flush guarded — a transient store failure inside flushAutoCapture no longer aborts capture or skips consolidate/sweep.
  • Auto-repair on embedding-dimension changestore.init detects a dimension mismatch and repairs (backup → rebuild → re-embed, ids preserved) instead of silently corrupting vectors.
  • BM25 index alignment — BM25 scores the correct tokenized row after scope filtering (a filtered-array index bug).
  • Retry-budget parsesuggestRetryBudget parses retryAttemptsJson instead of measuring the JSON string's length ("[]" counted as 2).
  • Merge entity GC — merging memories now decrements shared entities' mention counts and GCs them at zero (merged-away entities no longer leak).
  • Reindex idempotent — re-indexing a memory no longer inflates mention_count (the count bumps only when a new link is inserted).
  • Scoping config source honoredmemory.scoping: "project" in opencode.json is respected (was silently ignored; everything collapsed to "global").

v1.4.4 (2026-09-07)

  • zod declared as a direct dependency (it was imported but undeclared).
  • JSON parse guards across store read paths — malformed rows can no longer crash search/recall.
  • Read-modify-write locks serialize concurrent update paths (no lost updates on concurrent feedback/usage writes).
  • Fuzzy channel wired into auto-recall (typos are recalled, not just searched) and wasted duplicate query embeds dropped.

v1.4.3 (2026-09-06)

Scaling & retention hardening for large stores:

  • READ_CAP_FIX — deterministic, configurable read caps: full-scope reads (readByScopes, readByScopesIncludingMerged, readAllActive, event and feedback reads) previously used a hard-coded .limit(100000) with no ORDER BY, so beyond 100k rows a search silently truncated an arbitrary, non-deterministic subset of the table. Reads now order by timestamp DESC (latest-first when the cap binds) and the cap is configurable via OPENCODE_MEMORY_PRO_MAX_SCAN_ROWS (default 5M; 0 = unlimited). exportAllRecords (backup) is now unbounded and ordered — a truncated backup was silent data loss.
  • SCOPE_CACHE_CAP — configurable scope cache: the per-scope cache used to truncate to a hard-coded 1000 newest records, silently making older memories invisible to search once a scope outgrew it. Now env-overridable via OPENCODE_MEMORY_PRO_MAX_RECORDS_PER_SCOPE (default 1000, pre-1.4.3 behavior; explicit cacheConfig.maxRecordsPerScope wins over env).
  • DIGEST_EXPIRY — digests now expire: the retention sweep hard-deletes category:"digest" rows older than retention.memory.digestMaxAgeDays (default 365; 0 disables). Previously digests lived forever, so the store grew without bound no matter how often the sweep ran. Runs even when no new memories qualify; pinned digests are protected; dry-runs list candidates. New memory_expire arg + memory_stats reporting (digestMaxAgeDays / digestsEligible).

v1.4.2 (2026-09-06)

New fuzzy search channel — fuse.js joins the RRF merge as a third retrieval channel alongside vector and BM25, giving typo-tolerant matching out of the box:

  • Typo tolerance: memory_search "lancedb vectr srch" now surfaces the right memory even when vector and BM25 both miss — useful for queries with misspellings, partial words, or accented text (ignoreDiacritics).
  • Zero-config: retrieval.fuzzyWeight defaults to 0.15 (renormalized with vector/BM25); set it to 0 to restore pre-1.4.2 scores exactly.
  • Channel semantics: records that don't appear in the fuzzy top-N contribute no RRF rank, same as the other channels; fuzzyThreshold (default 0.5) drops weak matches.
  • Fallback-aware: the fuzzy channel stays active in the BM25-only fallback (embedder unavailable) — that's exactly when typo tolerance helps most — and is disabled only in explicit retrieval.mode = "vector".
  • Index lifecycle: fuse.js index is built lazily over the scope cache, reused across single-scope searches, and rebuilt automatically on cache invalidation or threshold change.
  • memory_stats now reports the fuzzy channel (enabled/weight/threshold).

v1.4.1 (2026-09-06)

New memory_reembed tool — detects and repairs embedding-dimension mismatches, which previously corrupted the store silently:

  • Root cause: the memories table's vector column is an Arrow FixedSizeList whose width is fixed forever by the first row ever written. init() re-probes the embedder's dimension on every startup but silently discarded that value once a table already existed — nothing ever compared "what the embedder produces now" against "what the table is physically built for." Switching embedding.provider/embedding.model to a different-dimension model did not error: LanceDB silently coerced mismatched writes into the old fixed-width column (corrupting the vector, not rejecting the write), and every vectorSearch() call at the new dimension threw inside findSimilarVectors's catch block, which silently swallowed it — so write-time dedup and memory_consolidate silently stopped finding neighbors for anything written after the switch, with zero visible symptom beyond a passive memory_stats.incompatibleVectors count.
  • Detection: init() now reads back the table's actual physical vector width (getPhysicalVectorDim()) and compares it to the freshly-probed embedder dimension on every startup, logging a warn on mismatch. getIndexHealth() (and therefore memory_stats.index) now reports dimensionMismatch/expectedDim/actualDim, and computeDegradedFlags surfaces an embedding-dimension-mismatch flag pointing at the fix.
  • Repair: memory_reembed (dryRun default true, confirm gate for the actual repair — same pattern as memory_clear/memory_forget) discovers every scope in the store (a dimension mismatch is table-wide, not scope-scoped), backs up every memory to <dbPath's parent>/backups/reembed-repair-<ts>.json (same shape as memory_export, written before any mutation, always), then drops and recreates the memories table at the current embedder's dimension and re-embeds every memory from its stored text under its original id (so entity-graph edges and citation chains keyed by id stay valid).
  • Tests: new integration test covers detection on a freshly-created table (no false positive), detection after reopening with a different dimension, and a full repair pass — asserting the physical column width actually changes, every original id/text survives, and post-repair health reports no mismatch.

v1.4.0 (2026-09-06)

Dedup correctness overhaul — the write-time duplicate check compared against the wrong score type, and the resulting flags were a one-way ratchet:

  • Write-time dedup now compares a raw cosine similarity: the capture path went through the hybrid search() API, whose RRF score is algebraically >= 1.0 for limit: 1 (and up to 1.4 with importance) — so every capture in a non-empty scope compared >= 1.0 against dedup.writeThreshold (clamped to [0,1]) and got falsely flagged as a potential duplicate. storeCapturedMemory now calls findSimilarVectors (the same raw cosine primitive consolidation measures) and compares that to the threshold. Consequence: recall scores can no longer exceed 100%, and dedup.enabled's write-time detection actually detects.
  • False duplicate flags now self-correct: isPotentialDuplicate was a one-way ratchet — consolidation never cleared it, so memory_stats flaggedCount only grew (153 flagged / 0 merged observed on a live store). consolidateDuplicates now revalidates flags against the real cosine threshold and clears (isPotentialDuplicate/duplicateOf removed) any flagged row whose closest found neighbor never reaches the merge bar. Returns clearedFlags so tools can report the correction.
  • Auto-consolidation cooldown is per-scope: the shared lastConsolidateAt timestamp meant the first scope to consolidate blocked all other scopes for 30 minutes. Cooldowns are now tracked per scope (same for the retention sweep, which had the identical flaw).
  • Scope cache staleness bound: the per-process version counter can't see writes from another opencode process sharing the same dbPath, so process A could serve stale records indefinitely. Cache entries now reload after a 60s age bound even when the local version is unchanged (configurable via cache.staleAfterMs; 0 restores pure version gating).
  • Consistent truncation warnings: deleteByIdForce's 100k-row fallback scan and pruneScope's 100k-row read now log a warning when the cap is hit, matching getCachedScopes.
  • Tests: three new integration tests — the dedup write-check primitive returns cosine in [0,1] (plus a guard that the old RRF path still scores >= 1.0), consolidation clears false flags, and the scope cache reloads after the age bound when a second process writes behind its back.

v1.3.8 (2026-09-06)

Fixes memory_forget(force=true) being unable to permanently delete a memory that was soft-deleted first:

  • Force delete now sees hidden rows: softDeleteMemory marks a row status='disabled', and the force path previously used deleteById, whose readByScopes query filters out status='disabled' (and merged/digested) rows — so "Use force=true for permanent deletion" silently failed and left the hidden row on disk forever. The force path now uses the new deleteByIdForce in dist/store.js, which tries the exact-id raw delete first and otherwise scans unfiltered rows (so id prefixes still match).
  • Tests: integration test covers the exact scenario — soft-delete, confirm the old path returns false, then deleteByIdForce removes the row and reports false on a second attempt.

v1.3.7 (2026-09-06)

Scope normalization — fixes lost memories when a scope argument is explicitly passed to a tool while scoping is "global":

  • Explicit scopes now collapse to global in global mode: previously memory_remember(scope="project") (and every other tool accepting a scope arg) stored the row under the literal string "project" — but scope-filtered reads derive the scope via deriveProjectScope(), which returns "global" in global mode, so the memory was effectively lost (invisible to search, promote, why, memory_global_list, ...; reachable only by passing scope="project" explicitly).
  • New resolveScope(scope, worktree) helper in dist/scope.js — collapses any explicit scope to "global" in global mode and honors it in project mode (falling back to the derived project scope when omitted). Applied to all 30 scope-arg sites across dist/tools/memory.js, dist/tools/episodic.js, and dist/tools/feedback.js, including memory_clear, which previously called clearScope(args.scope) without any normalization.
  • Tests: two new unit tests cover the collapse-to-global and honor-in-project-mode behavior.

v1.3.6 (2026-09-06)

Compaction lock hardening — fixes the "Compaction commit failed; leaving N rewritten fragment(s) in place for GC" warning reappearing on the TUI at startup / first turn when two opencode instances share one store:

  • No more lock stealing during the owner's init window: the owner creates .optimize.lock with open("wx") and then writes its pid; a contender reading in between saw an empty file, declared it stale, deleted it, and created its own — so both processes "owned" the lock and raced optimize() (the native LanceDB stderr line is uninterceptable by the plugin). The lock now treats an empty file as "being initialized" for a short grace instead of reclaiming it.
  • Contenders wait instead of giving up instantly: when a live process holds the lock, the second instance now polls up to 10s for it to finish (serializing compaction across processes) before skipping this cycle and retrying next interval, instead of racing it.
  • In-process guard set synchronously: maybeOptimizeAll now sets optimizing = true before any await, so overlapping calls in one process (fire-and-forget write trigger + awaited explicit call on the first turn) can no longer both run optimize() concurrently.
  • Tests: two new unit tests cover the open→write TOCTOU (old lock returns true and steals; new lock returns false and preserves ownership) and stale-lock reclamation.

v1.3.5 (2026-09-06)

Code-review hardening pass — bug fixes, no breaking changes:

  • Metadata is no longer destroyed on recall (updateMemoryUsage): the first recall of a global memory used to replace metadataJson with { recalledProjects: [...] }, silently dropping pinned, duplicate flags, source, and graph entities — breaking memory_export provenance, duplicate- aware pruning, and the pin protection in retention. It now merges into the existing metadata blob.
  • LLM capture respects an explicit "nothing to store" verdict: when the LLM extraction succeeds but returns [], the transcript no longer falls through to the keyword heuristics and gets stored against the model's judgment — the heuristic fallback now only runs when extraction actually fails.
  • Ephemeral LLM sessions no longer trigger consolidate/sweep: the session.deleted cleanup ran unconditionally with force=true, so in capture.mode="llm" every ephemeral extraction/digest session paid a full dedup + retention scan on teardown (and could spawn further LLM digests). Own sessions are skipped entirely; pending transcript fragments are flushed before user sessions close.
  • Consolidation only merges active memories: digested (retention-hidden) and disabled (soft-deleted) rows can no longer be picked as merge endpoints, which previously flipped their status to merged and could resurrect disabled memories / corrupt digest provenance.
  • memory_import replace-mode can't duplicate ids: existence is now checked against raw rows (digested/merged/disabled included) and replace deletes the exact id before re-adding, so a hidden row is truly replaced instead of leaving two physical rows per id. Citation chains are also stringified consistently on write.
  • Episodic data is actually recorded: failed validations now write numbered retry attempts (so retry_budget_suggest has real data), and sessions that receive injected memories are stamped recallUsed (so memory_kpi's memory lift is meaningful).
  • Smaller fixes: session.error session-id fallback (info.id), bounded getEventTtlStatus read, graph backfill covers all scopes, embedder fallbackActive resets on recovery, scope-cache truncation is logged, memory_forget records wrong feedback instead of polluting unhelpful stats.

License

MIT — fork of lancedb-opencode-pro (MIT, tryweb).