Special Thanks
- @minaamulhaq: the entity graph export to a Markdown vault (#1856).
- @itxmjr: click-to-focus in the dashboard knowledge graph (#1855).
- @massimiliano1991: the restore that the sync queue dropped after max retries (#1890).
- @qwist1233-cpu: sync failures that logged an empty reason (#1899, #1893).
- @mbreitkreuz: reported and diagnosed the conflict handling on the hybrid backend (#1910).
- @tonydzi: the Cloudflare store-scoping report (#1915).
- @GPT-AGI: the auto-split leading blank line and the "recently" / "yesterday morning" recall fixes.
Highlights
- The
transformersNLI backend runs on ONNX Runtime, without torch on the inference path (#1848). - Custom memory types, relationship types, type patterns and retention periods can live in the memory database (#1897, phases 1, 3, 4).
memory_searchretrieves a memory by exact content hash (#1911).- Store scoping reaches Cloudflare, hybrid and remote HTTP storage (#1878, #1879, #1915).
- The dashboard and its static files are served with
Cache-Control: no-cache(#1892).
Full changelog: https://github.com/doobidoo/mcp-memory-service/blob/main/CHANGELOG.md
Added
- The
transformersNLI backend now exists and runs on ONNX Runtime (#1848).
MCP_NLI_BACKEND=transformersused to answerneutral 0.0with a
"not implemented" warning; it now classifies through the spec's
cross-encoder/nli-deberta-v3-smallloaded from the model repo's own ONNX
weights (onnx/model.onnxfp32 by default, quantized variants via
MCP_NLI_ONNX_FILE), using onlyonnxruntime,tokenizersand
huggingface_hub— torch and transformers are not on the inference path.
Batch classification goes through one session call; any load or inference
failure degrades to the heuristic for that call with one bounded warning.
No default changes: the backend stays opt-in. - Export entity graph to a Markdown vault (
scripts/maintenance/export_entity_vault.py, #1856, minaamulhaq).
Generates an Obsidian-compatible Markdown vault from the memory entity graph with
one page per entity, YAML frontmatter, extractive summaries, ranked chunk quotes,
[[entity_id]]wikilinks to related entities, and anindex.mdgrouped by type.
Extractsget_entity_detailand extractive summary logic inserver.handlers.graph
for reuse while preserving store scoping and deleted memory filters. - Custom memory types can live in the memory database, without a restart (#1897, phase 1).
Migration 022 adds anontology_entriestable.SqliteVecMemoryStorage.add_memory_type()
andremove_memory_type()write to it, and the entries are merged after the built-ins and
MCP_CUSTOM_MEMORY_TYPES. The overlay only adds: it cannot replace or re-parent an existing
type. Every write bumpsontology_versioninmetadata, and processes sharing the database
file poll that counter (at most once per second) and reload their ontology caches when it
changes. The table is local to sqlite_vec for now. It is not synced through the hub and not
carried by Cloudflare, so multi-node setups still needMCP_CUSTOM_MEMORY_TYPES. - Retrieve memories by exact content hash through
memory_search(#1911).
content_hashperforms a store-scoped direct lookup instead of text search, and
misses return no results. An allowlist rejectsqueryand unsupported search
arguments rather than ignoring them. Text queries, including hash-shaped strings,
are unchanged. - Interactive Knowledge Graph focus mode on click (
src/mcp_memory_service/web/static/app.js, #1855, itxmjr).
Add interactive click-to-focus neighborhood mode in both 3D ForceGraph and 2D D3 graph
visualizations. Single-clicking a node highlights its direct connections and fades
distant nodes and edges across three visual tiers. Introduces a floating inspector HUD
with connection grouping by relationship type, quick neighbor pivoting, and full memory
detail launching, with smooth Escape key and canvas background dismissal. - Relationship types and inference type patterns can live in the memory database (#1897, phase 3).
SqliteVecMemoryStorage.add_relationship_type()/remove_relationship_type()store a
relationship with itsvalid_patternsand symmetry as arelationshiprow in
ontology_entries, andset_type_pattern()/clear_type_pattern()store the base-type
pairs relationship inference maps to a relationship (type_patternrows). Both merge after
the built-ins and only add: built-in relationships, their patterns and the built-in pairs
cannot be replaced.validate_relationship,is_allowed_pairand
is_symmetric_relationshipread the merged set, and the inlinetype_patternsdict moved
toTYPE_PATTERNSinmodels/ontology.py.has_entityis now a proper entry in
RELATIONSHIPS; type patterns cannot name it, since its target is an entity key. - Retention periods for custom memory types, stored in the memory database (#1897, phase 4).
A base type added through the ontology overlay had no key inretention_periods, so its
memories decayed on the 30-day fallback.SqliteVecMemoryStorage.set_retention()and
clear_retention()store a period (1..3650 days) as aretentionrow inontology_entries,
and the decay calculator uses it for any type the consolidation config does not name.
Subtypes without their own entry inherit their parent's period. The overlay only fills gaps:
built-in periods andMCP_RETENTION_*overrides still win, andset_retention()refuses
those types. Like the rest of the overlay, the rows are local to sqlite_vec for now.
Fixed
- A search could erase metadata written while it ran (#1464). After
retrieve()the access write-back replaced the whole stored metadata JSON with the snapshot it had read, so a key thatupdate_memory_metadata()committed in between (for example areviewedflag set viamemory_update) was lost without an error. The sqlite-vec and Cloudflare write-backs nowjson_setonlyaccess_count,last_accessed_atandaccess_queriesinto the stored JSON and keep every other key. Stored metadata that is not a JSON object is treated as{}, as on read. - Cloudflare keeps a
storeper memory and confines hash-addressed operations to it (#1878, part of #1683). D1 had nostorecolumn, soget_by_hash,update_memory_metadataand delete-by-hash acceptedstoreand looked the memory up bycontent_hashalone. An existing D1 database now getsstore TEXT NOT NULL DEFAULT 'default'on startup, which fills every existing row with"default"; the check runs onPRAGMA table_info, so a database that already has the column is left alone.store()writes the store it is given, and the three hash-addressed paths addAND store = ?, so a memory in another store is reported as not found and its D1 row, vector and R2 object stay untouched.Noneand"all"stay unscoped, and restoring a tombstone keeps the row's store.get_by_hash_is_store_scopedstaysFalsefor now: the server also grants per-store credentialsmemory_searchandmemory_liston that flag, and the Cloudflare read paths (retrieve,recall,get_all_memories,count_all_memoriesand others) still ignorestore, so store-scoped credentials keep being refused on Cloudflare until those reads are confined too. RemoteHTTPStorageconfinesget_by_hash,update_memory_metadataand delete-by-hash tostore(#1879, part of #1683)./api/sync/capabilitiesnow advertisesstore_scoped_access, taken from the server storage'sget_by_hash_is_store_scoped. The HTTP client reads it when it initializes (and on each sync handshake) and sends a named store as thestorequery parameter, so a memory in another store comes back as not found. A peer that does not advertise the field (every server before this release) ignores the query parameter, so against it a call naming a store is refused without a request and logged once per peer. Upgrade the hub before the clients: once a caller passes a named store to an HTTP peer, an older hub refuses every such call.None,"default"and"all"keep working against an older hub:"default"is not sent because the server resolves a missingstoreto"default"itself, and"all"is passed through for reads.- A restore the sync queue dropped after
max_retriesstayed off Cloudflare (#1890, massimiliano1991).
force_syncprotected every hash with a remote tombstone, so a restored row was never
pushed unless someone resolved the conflict by hand. A row stored after the tombstone
it conflicts with is now pushed throughstore_if_tombstone_matches; a local edit
racing a remote delete is still kept and reported as a conflict. The row is read again
around the upload, so one deleted whileforce_syncruns stays deleted on both sides. - The dashboard and its static files are served with
Cache-Control: no-cache(#1892). Browsers kept running a staleapp.jsafter an upgrade because nothing told them to check for a newer copy. They now revalidate every request against the ETag and get a304when the file is unchanged. - Sync failures logged an empty reason whenever the exception carried no message (#1899, #1893, qwist1233-cpu).
str(exc)is empty for most httpx timeout and transport errors, so a line like
"Delta-sync capability probe failed for peer hub: " ended at the colon and left no
trace of what went wrong. The remote-HTTP request path, the startup capability probe
and the sync cycle now log the exception class next to the message. - A memory type the node did not know was overwritten with
observationon read (#1897).
Memory()coerced unknown types in its constructor, which also runs when storage
hydrates a stored row. A node without a peer's custom type (MCP_CUSTOM_MEMORY_TYPES
differs per process) turned the type intoobservationand wrote that back on the next
update. The coercion now happens only where a new memory is written, through
coerce_memory_type(), and the #843 warning in the store response is unchanged.
Merging custom subtypes also no longer extends the built-inTAXONOMYlists in place,
so a subtype removed from the env var stops validating afterclear_ontology_caches(). - Restore dashboard time-of-day cues (#1907). Memory cards and document groups once again show the eight local time-of-day icons removed during emoji cleanup, alongside the clock time.
- Detected conflicts could not be listed, resolved or cleaned up on the hybrid backend (#1910, reported and diagnosed by mbreitkreuz).
Hybrid stores through its sqlite-vec primary, which detects conflicts and tags both
memoriesconflict:unresolved, but hybrid did not delegateget_conflicts(),
resolve_conflict()orreconcile_conflict_tags(), so the base defaults applied:
memory_conflictsand/api/conflictsfound nothing,memory_resolveand
/api/conflicts/resolveanswered "not supported", and themaintainconflict steps
reported 0 conflicts and{"supported": false}. The three methods now run on the
primary, likemark_superseded_batch(). - Cloudflare read paths honor
store(part of #1915, tonydzi).retrieve,recall,get_all_memories,count_all_memories,get_memories_by_time_range,get_by_exact_content,get_all_tags_with_counts,get_memory_timestampsanddelete_by_tagsacceptedstoreand returned or deleted every store's memories. Each now adds the store predicate to its D1 statement;retrieveand the semantic branch ofrecallapply it when loading Vectorize matches from D1 byvector_id, since Vectorize holds every store's vectors, anddelete_by_tagspassesstoreon todelete.Noneand"all"stay unscoped, and the defaults are unchanged, soget_all_memories,count_all_memoriesandretrievewithout a store now read"default"only, as on sqlite-vec.get_by_hash_is_store_scopedstaysFalseuntil the hybrid sync queue carriesstoreto Cloudflare as well. - The dashboard answers 503 instead of 500 when its static files are missing (#1926). Both fallbacks in
GET /returnedhtml_template, which no longer exists, so a missing or unreadableweb/static/index.htmlraisedNameErrorand hid the original error. The route now logs the cause and serves a short "Dashboard files not found" page with status 503; the REST API is unaffected. - Five dashboard locale catalogs dropped seven dead
analytics.*keys, and two guard
tests keep the i18n wiring from drifting again (#1928, Goldloli; follow-up to #1806).
de,es,fr,jaandkostill carriedanalytics.graphControls,
analytics.graphLegend,analytics.noRelationships,analytics.resetZoom,
analytics.untyped,analytics.zoomInandanalytics.zoomOut, whichen.jsonno
longer had and nothing referenced.tests/web/test_i18n_catalog_parity.pynow fails
when any catalog's key set differs fromen.json's, missing or extra, and
tests/web/test_dashboard_no_unwired_text.pyfails on visible text,placeholder,
titleoraria-labelvalues inindex.htmlthat carry English words without a
matchingdata-i18n*attribute, so hardcoded UI text cannot slip back in. - The startup dependency check reported torch and sentence-transformers as missing on the ONNX install (#1930).
check_critical_dependencies()only knew the PyTorch backend, so every start of a
[sqlite]install (onnxruntime + tokenizers, no torch) printed[MISSING]and timeout
warnings, doubled the init timeout, and pointed atpython install.py, which pip,
uv toolanduvxinstalls do not have. Either backend now satisfies the check, the
hint ispip install "mcp-memory-service[sqlite]", andis_first_run()also counts a
model in the ONNX model cache (~/.cache/mcp_memory/onnx_models) when itsmodel.onnx
andtokenizer.jsonare both there. - Auto-split dropped a note that started with a blank line (GPT-AGI). With boundary preservation on (the default), a leading blank line was chosen as the split and the empty chunk aborted the loop, so a memory longer than the backend limit was stored as empty text. Leading whitespace is skipped and the rest of the note is split.
- Recall clipped "recently" and "yesterday morning" (GPT-AGI). Extracting "recently" matched only "recent" and left "ly" in the search text. "yesterday morning" was the whole of yesterday, and "morning" was searched as text. Both now keep the window the time parser already returns for those phrases.
- Hybrid storage files the Cloudflare copy of a memory in the store it was written to (#1915, part of #1683).
HybridMemoryStorage.store()wrote the SQLite row into the named store but queued the sync without it, so the queue calledsecondary.store(memory)and every memory synced through hybrid landed indefaulton Cloudflare. The queued operation now carries the store, and the queue passes it tosecondary.store()when the secondary'sstore()takes astoreparameter; one that does not keeps the previous call and a warning is logged once. Of the other calls the issue lists, the queued delete and metadata update stay unscoped on purpose:content_hashis unique across stores in both SQLite and D1, the primary has already confined the operation, and D1 rows synced before this change all carrydefault, so a delete or update scoped to the real store would miss them.force_sync()reads only the primary'sdefaultstore, so its uploads were already filed correctly, and a tombstone restore keeps the D1 row's own store. Memories pulled from Cloudflare byget_by_hash()still land in the localdefaultstore, becauseMemorycarries no store. Existing D1 rows are not re-filed: D1 refuses a secondstore()of a live hash andforce_sync()skips hashes already present, so the backfill needs its own step (anUPDATE memories SET store = ? WHERE content_hash = ?for each primary row outsidedefault), and it has to land beforeget_by_hash_is_store_scopedflips on Cloudflare.
Internal
- Extract core entity detail and extractive summary logic for reuse (#1856).
The core retrieval and hydration logic fromhandle_memory_detailand summary extraction from_build_knowledge_mapare extracted into plain functions (get_entity_detailandextract_extractive_summary) for use by maintenance export scripts. - The Claim Check passed PRs that named their issue without a closing keyword (#1901).
PR #1895 referred to #1893 only as "Addresses #1893" plus aFixes #1893that GitHub
did not parse, so it counted as linked forno-issuewhile the claim side saw no issue
and passed. Open issues of this repository mentioned in the description are now checked
for the author's assignment too, and an assignment re-runs the check of PRs that only
mention the issue. - Documentation audit and consolidation for v11 & v12 features (#1905).
Updates documentation guides for NLI cascade, harvest scheduler, decay and retention policies, and archives legacy ChromaDB migration guidance. server/cache_manager.pylogs with lazy%-style arguments (#1146).
Its f-string logger calls now pass their values as arguments. Storage IDs,
hit/miss counts, and instance totals are logged with%d, and percentages
and elapsed times with%.1f. The module is added toGUARDED_MODULES, so
the log-injection ratchet keeps it clean.lm_studio_compat.pylogs with lazy%-style arguments (#1146).
Its six f-string logger calls now pass their values as arguments; the values
that come from outside (the exception, the incoming notification and its
reason) stay wrapped in_sanitize_log_value. The module is added to
GUARDED_MODULES, so the log-injection ratchet keeps it clean.web/oauth/storage/sqlite.pylogs with lazy%-style arguments (#1146).
Its eight f-string logger calls now pass their values as arguments. The client
ids, the database path and the consumed authorization code stay wrapped in
_sanitize_log_value; the expired-row counts from the cleanup job are
internal integers and are logged with%d. The module is added to
GUARDED_MODULES, so the log-injection ratchet keeps it clean.- Expose untagged-memory and embedding-dimension storage APIs, share log sanitization, simplify ontology/taxonomy wrappers, and manage document cleanup through the web application lifespan.