Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
},
"metadata": {
"description": "Hypermnesia MCP Viz — the read-only visualization companion for Cortex",
"version": "3.1.0"
"version": "3.1.1"
},
"plugins": [
{
"name": "hypermnesia-mcp-viz",
"source": "./",
"description": "Live memory galaxy, methodology map, workflow graph, wiki browser, and execution trace over the shared Cortex store.",
"version": "3.1.0",
"version": "3.1.1",
"author": {
"name": "Clement Deust",
"email": "admin@ai-architect.tools"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hypermnesia-mcp-viz",
"description": "Standalone visualization MCP for Cortex — a live neural-graph galaxy of every project, file, symbol, memory, discussion and wiki page, plus a per-session execution trace. Read-only bridge over Cortex's shared PostgreSQL and the ~/.claude artifacts.",
"version": "3.1.0",
"version": "3.1.1",
"author": {
"name": "Clement Deust",
"email": "admin@ai-architect.tools"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hypermnesia-mcp-viz",
"version": "3.1.0",
"version": "3.1.1",
"description": "Read-only visualization and graph MCP for Cortex, packaged for Codex.",
"author": {
"name": "Clement Deust",
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@ Releases before 2.7.0 were recorded as `chore(release)` / `release:` commits in
`workflow_dispatch` recovery path repairs a stale registry entry for an
already-tagged release without re-publishing the package.

## [3.1.1] - 2026-08-10

**Upgrade from 3.1.0 immediately: on 3.1.0 the graph never builds.** The Graph,
Brain, Knowledge, Board and Wiki views stay empty forever; `/api/graph/progress`
reports `phase: "starting", pct: 0.0` and never advances. The Trace view is
unaffected. No data was lost or corrupted — the build died before reading
anything.

### Fixed
- **The background graph build crashed on its first statement.** 3.1.0 removed
the module-level `emit`/`close`/`reset` forwarders from `graph_event_stream`
— deliberately, because one of them silently dropped `event_meta` once `emit`
grew it — on the stated grounds that they "had never had a caller in this
repository's history". They had four: `graph_build_run` called `reset`,
`emit` and `close` on the module, and `graph_build_merge` called `emit` on
what it was handed. `run_build` therefore raised
`AttributeError: module 'cortex_viz.server.graph_event_stream' has no
attribute 'reset'` at `graph_build_run.py:155`, before any source loaded.
The callers now reach the singleton through `get_stream()` and use the
`GraphEventStream` API directly, which is what the singleton's own contract
asks for and what `activity_stream` already did. The forwarders stay removed.
- **The end-of-build stream terminator failed silently.** The same defect in
the `finally` block (`_ev.close()` on the module) sat inside
`except Exception: pass`, so subscribers never received `done` and never
disconnected — a second failure the crash was masking.
- `make_merge`'s `events` parameter is now annotated `GraphEventStream`, so a
caller that passes the module fails at type-check rather than at the first
emit, halfway through a build.

### Added
- `tests/test_graph_build_event_stream_contract.py`: pins that the module keeps
exactly one door (re-adding a forwarder re-opens the weaker `event_meta`-
dropping path), and that the build reaches the stream through `get_stream()`.
Three of its four tests fail on 3.1.0. The existing merge tests could not
catch this — they all inject a fake stream, so the defect lived in how the
production caller *obtains* the stream, not in what it does with it.

## [3.1.0] - 2026-08-10

**Upgrading from 2.8.0:** this release carries a breaking distribution-identity
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<img src="https://img.shields.io/badge/MCP-Codex_%C2%B7_Gemini_%C2%B7_Claude-blue.svg" alt="Cross-platform MCP for Codex, Gemini CLI, and Claude Code">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="Python 3.10+">
<img src="https://img.shields.io/badge/version-3.1.0-brightgreen.svg" alt="Version 3.1.0">
<img src="https://img.shields.io/badge/version-3.1.1-brightgreen.svg" alt="Version 3.1.1">
<a href="https://www.bestpractices.dev/projects/13846"><img src="https://www.bestpractices.dev/projects/13846/badge" alt="OpenSSF Best Practices"></a>
</p>

Expand Down
2 changes: 1 addition & 1 deletion cortex_viz/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@

DISTRIBUTION_NAME = "hypermnesia-mcp-viz"
MCP_REGISTRY_ID = "io.github.cdeust/hypermnesia-mcp-viz"
VERSION = "3.1.0"
VERSION = "3.1.1"
16 changes: 10 additions & 6 deletions cortex_viz/server/graph_build_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
(behaviour-preserving split, 2026-06-14). ``make_merge`` reconstructs the exact
same closure: the per-build dedup sets (``seen_n``/``seen_e``) and kind tallies
(``kind_counts``) are captured locally — one fresh set per build, identical to
the in-line original — and ``domain_filter`` + the ``graph_event_stream``
module are bound from the caller.
the in-line original — and ``domain_filter`` + the process-wide
``GraphEventStream`` are bound from the caller.

Shared cache state lives in ``graph_cache_state`` (the single owner): the
merge mutates it via ``state.X = ...`` direct attribute assignment.
Expand All @@ -17,15 +17,19 @@

from cortex_viz.server import graph_cache_state as state
from cortex_viz.server.graph_build_helpers import _set_progress
from cortex_viz.server.graph_event_stream import GraphEventStream
from cortex_viz.server.graph_wire import _slim_node


def make_merge(domain_filter: str | None, events):
def make_merge(domain_filter: str | None, events: GraphEventStream):
"""Return the build's ``_merge`` callback with fresh dedup state.

``events`` is the ``graph_event_stream`` module (the live SSE delivery
path). The returned closure has the SAME signature and behaviour as the
in-line ``_run._merge``.
``events`` is the process-wide ``GraphEventStream`` from ``get_stream()``
(the live SSE delivery path) — the object, not the module: the module-level
forwarders were removed deliberately, and annotating the parameter is what
makes a caller that passes the module fail at type-check rather than at the
first emit, halfway through a build. The returned closure has the SAME
signature and behaviour as the in-line ``_run._merge``.
"""
# ── Incremental merge state ──
# Dedup sets + kind tallies persist across _merge calls instead
Expand Down
14 changes: 10 additions & 4 deletions cortex_viz/server/graph_build_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,19 @@ def run_build(store, domain_filter: str | None) -> None:
# graph grow instead of waiting for the full ingest to
# finish. RESET on every kicked build so a previous build's
# tail events don't leak into this run's subscribers.
from cortex_viz.server import graph_event_stream as _events
# Reached through get_stream() and used through the GraphEventStream
# API directly, as the singleton's contract requires: the module-level
# emit/close/reset forwarders were removed deliberately, because one of
# them silently dropped `event_meta` once emit grew it.
from cortex_viz.server.graph_event_stream import get_stream

_events = get_stream()

_events.reset()
state._source_totals.clear()

# Construct the cumulative-cache merge closure now that the SSE
# event stream module is available. Fresh dedup state per build.
# event stream is available. Fresh dedup state per build.
_merge = make_merge(domain_filter, _events)

def _resolve_wiki_source_edges() -> None:
Expand Down Expand Up @@ -544,9 +550,9 @@ def _on_batch(label: str, nodes_objs, edges_objs) -> None:
# idempotent; the buffer survives for late-subscriber
# replay until the next build's reset().
try:
from cortex_viz.server import graph_event_stream as _ev
from cortex_viz.server.graph_event_stream import get_stream as _get_stream

_ev.close()
_get_stream().close()
except Exception:
# close() is idempotent and the buffer survives for late-subscriber replay;
# a failure here must not mask the build outcome being unwound.
Expand Down
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hypermnesia-mcp-viz",
"version": "3.1.0",
"version": "3.1.1",
"description": "Read-only visualization and graph MCP for Cortex, packaged for Gemini CLI.",
"mcpServers": {
"hypermnesia-mcp-viz": {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "hypermnesia-mcp-viz"
version = "3.1.0"
version = "3.1.1"
description = "Visualization and graph MCP server for Cortex — neural graph, methodology map, workflow graph, and trace UI extracted from the Cortex memory engine"
readme = "README.md"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
"url": "https://github.com/cdeust/cortex-viz",
"source": "github"
},
"version": "3.1.0",
"version": "3.1.1",
"websiteUrl": "https://ai-architect.tools/cortex",
"packages": [
{
"registryType": "pypi",
"identifier": "hypermnesia-mcp-viz",
"version": "3.1.0",
"version": "3.1.1",
"runtimeHint": "python",
"transport": {
"type": "stdio"
Expand Down
148 changes: 148 additions & 0 deletions tests/test_graph_build_event_stream_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""The background build must reach the event stream through ``get_stream()``.

`graph_event_stream` deliberately carries no module-level ``emit``/``close``/
``reset`` forwarders: one of them silently dropped ``event_meta`` once ``emit``
grew it, so the singleton has exactly one door. When those forwarders were
removed, `graph_build_run` was still calling all three **on the module**, and
nothing here noticed:

- ``_events.reset()`` raised ``AttributeError`` on the first line of every
build, so the graph never built at all — ``/api/graph/progress`` sat at
``phase: "starting", pct: 0.0`` forever;
- ``_ev.close()`` in the ``finally`` block sat inside ``except Exception: pass``,
so it failed **silently** — subscribers never received ``done`` and never
disconnected.

The unit tests around the merge closure all pass a fake stream, so they could
not catch this: the defect was in how the production caller *obtains* the
stream, not in what it does with it. These tests assert that binding, which is
why they are static — reproducing the crash behaviourally would mean standing up
a build, and a build needs PostgreSQL.
"""

from __future__ import annotations

import ast
from pathlib import Path

import pytest

from cortex_viz.server import graph_event_stream as module_under_contract
from cortex_viz.server.graph_event_stream import GraphEventStream

STREAM_ONLY_METHODS = ("emit", "close", "reset")

_BUILD_RUN = Path(module_under_contract.__file__).parent / "graph_build_run.py"


def _module_alias_names(tree: ast.AST) -> set[str]:
"""Names bound to the `graph_event_stream` MODULE, however imported."""
aliases: set[str] = set()
for node in ast.walk(tree):
# `from cortex_viz.server import graph_event_stream as _events`
if isinstance(node, ast.ImportFrom) and node.module in {
"cortex_viz.server",
"cortex_viz",
}:
for name in node.names:
if name.name.endswith("graph_event_stream"):
aliases.add(name.asname or name.name)
# `import cortex_viz.server.graph_event_stream as _events`
elif isinstance(node, ast.Import):
for name in node.names:
if name.name.endswith("graph_event_stream") and name.asname:
aliases.add(name.asname)
return aliases


def _attributes_called_on(tree: ast.AST, names: set[str]) -> set[str]:
return {
node.attr
for node in ast.walk(tree)
if isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id in names
}


@pytest.fixture(scope="module")
def build_run_tree() -> ast.AST:
return ast.parse(_BUILD_RUN.read_text(encoding="utf-8"))


def test_the_singleton_keeps_exactly_one_door():
"""Pins the deliberate removal: re-adding a forwarder re-opens the weaker
path that dropped `event_meta`."""
for name in STREAM_ONLY_METHODS:
assert not hasattr(module_under_contract, name), (
f"graph_event_stream regained a module-level {name}() forwarder; "
"callers must use get_stream()"
)
assert hasattr(GraphEventStream, name), (
f"GraphEventStream lost {name}() — the build calls it"
)


def test_the_build_never_calls_stream_methods_on_the_module(build_run_tree):
"""The regression itself. Fails on the pre-fix source, where
`_events` was the module and `_events.reset()` was an AttributeError."""
aliases = _module_alias_names(build_run_tree)
offending = _attributes_called_on(build_run_tree, aliases) & set(
STREAM_ONLY_METHODS
)
assert not offending, (
f"graph_build_run calls {sorted(offending)} on the graph_event_stream "
f"module (bound as {sorted(aliases)}); those live on GraphEventStream. "
"Obtain the stream with get_stream() instead."
)


def test_make_merge_is_annotated_against_the_stream_not_the_module():
"""A caller that passes the module must fail at type-check, not at the
first emit halfway through a build.

Resolved with ``get_type_hints`` rather than read off ``__annotations__``:
the module uses ``from __future__ import annotations``, so the raw value is
the *string* ``"GraphEventStream"`` and an identity check against the class
would pass vacuously for any annotation at all.
"""
from typing import get_type_hints

from cortex_viz.server.graph_build_merge import make_merge

assert get_type_hints(make_merge).get("events") is GraphEventStream, (
"make_merge's `events` parameter must be typed GraphEventStream"
)


def _names_bound_to_get_stream(tree: ast.AST) -> set[str]:
bound: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
call = node.value
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)):
continue
if not call.func.id.endswith("get_stream"):
continue
bound.update(t.id for t in node.targets if isinstance(t, ast.Name))
return bound


def test_a_real_stream_satisfies_every_call_the_build_makes(build_run_tree):
"""Whatever the build calls on its stream must exist on the real class —
so moving a method on GraphEventStream breaks here, not in production."""
stream_names = _names_bound_to_get_stream(build_run_tree)
assert stream_names, "graph_build_run no longer binds a stream via get_stream()"

live = GraphEventStream()
try:
called = _attributes_called_on(build_run_tree, stream_names)
assert called, "the build binds a stream but never calls anything on it"
for attr in called:
assert hasattr(live, attr), (
f"the build calls .{attr}() on the stream, "
"which GraphEventStream does not provide"
)
finally:
live.reset()
Loading