Live Memory Dashboard (memory.dashboard)
On this page
Purpose
A self-contained Flask application that visualizes the state of an AIMemoryManager in
real time: short-term / working / long-term / semantic layers, per-user profiles, an
importance-distribution histogram, a semantic relationship graph between memories, and a
live activity feed. It is a read-mostly observability tool, not part of the memory
pipeline itself — nothing in memory.intelligent, memory.storage, or memory.semantic_memory
imports from memory.dashboard.
Run it as a script:
python -m memory.dashboard
or embed it in an existing process:
from zerda.memory.dashboard import create_app
app = create_app()
app.run(port=8877)
Demo data and the simulator
Out of the box the dashboard needs something to display, so it ships with synthetic demo data rather than requiring the caller to already have live traffic:
_DEMO_TENANTS = ["acme", "globex"]and_DEMO_USERS(3 users underacme, 2 underglobex)._DEMO_TURNS— 12 hand-written(user_input, assistant_output, base_importance, topics)tuples spanning a range of importance scores and topics._seed(state, per_tenant=6)runs once at app-creation time, feeding 6 random demo turns through each tenant so the dashboard isn't empty on first load.DashboardState.start_simulator(interval_seconds=3.5)spawns a daemon thread that callssimulate_one()every 3.5 seconds by default, picking a random tenant/user/turn (with a small importance jitter of ±0.08) each time. This is what makes the dashboard's numbers keep moving without any user interaction.
If create_app() is handed an existing AIMemoryManager instance, real calls to that
manager elsewhere in the same process (e.g. save_interaction) are reflected in the
dashboard too — the simulator and real usage write through the same manager and the same
_ActivityLog.
Caveat: the simulator is a global background thread started by create_app() and
never automatically stopped. Calling create_app() more than once in the same process
(e.g. under a test suite or an auto-reloading dev server) leaves multiple simulator
threads running against whichever DashboardState/manager each call created. Call
state.stop_simulator() (via app.config["MEMORY_DASHBOARD_STATE"]) or pass
autostart_simulator=False if this matters for your use case.
create_app(manager=None, *, autostart_simulator=True) -> Flask
Always seeds demo data via _seed() regardless of whether a real manager was passed in
— so plugging in a manager that already has real tenants/users does not skip the demo
acme/globex seeding.
Returns a configured Flask app with static_folder pointed at dashboard/static and
app.config["MEMORY_DASHBOARD_STATE"] set to the DashboardState instance, so callers
embedding the app in a larger service can reach state.manager, state.stop_simulator(),
etc. after the fact.
DashboardState
Owns the single AIMemoryManager, an RLock guarding access to it, and the _ActivityLog.
All dashboard routes go through a DashboardState instance rather than talking to the
manager directly.
Constructor
def __init__(self, manager: Optional[AIMemoryManager] = None, config: Optional[MemoryConfig] = None) -> None
Builds manager or AIMemoryManager(config=config or _default_config()), records
started_at (used to compute uptime), and prepares (but does not start) the simulator
thread/stop-event.
Simulated traffic
start_simulator(interval_seconds=3.5)— no-ops if a simulator thread is already alive; otherwise starts a daemon thread that callssimulate_one()on a loop, silently swallowing any exception raised inside a single simulated turn so one bad iteration can't kill the loop.stop_simulator()— sets the internalthreading.Event, which the loop checks viaEvent.wait(interval_seconds)between iterations. The thread itself is not joined.simulate_one(tenant_id=None, user_id=None) -> Dict[str, Any]— picks a random tenant (if not given), a random user for that tenant (if not given), and a random demo turn; callsmanager.save_interaction(...), and with 60% probability also callsmanager.build_context(...)for the same user/query so working memory fills up too. Pushes an"interaction"event to the activity log and returns{"tenant_id", "user_id", "entry_id", "importance"}.
Reads
graph_data — relationship graph
Builds a lightweight similarity graph over the limit most recent semantic-memory
entries only (not STM/working/LTM) for the tenant:
- Edge weight =
0.65 * cosine_similarity(embeddings) + 0.35 * jaccard(tags), computed for every pair of entries that both have an embedding (cosine contributes0.0for any pair missing one). - A candidate edge is kept only if its weight is
>= sim_threshold(default0.4). - To keep the graph readable, only the strongest
max_edges_per_node(default4) edges per node are retained — an edge survives if it's in either endpoint's top-4, so a node can end up with more than 4 edges if it's a "popular" neighbor of several other nodes. - Runs in O(n²) over the
limitentries (a full pairwise similarity pass), solimitshould stay small (the default of 40 is a deliberate cap) — this is not meant to scale to the full memory store.
profiles — user profile rollup
For every user known to manager._profile_manager (another internal attribute reached
directly): user_id, preferred_language, verbosity, total_interactions, the top 5
(topic, count) pairs by frequency, and memory_retention_boost, rounded to 3 decimals.
Rows are sorted by total_interactions descending. Also returns
manager._profile_manager.aggregate_stats() as "aggregate" — which, per
UserProfileManager, has the side effect of loading every on-disk profile for the tenant
into memory, not just the ones already cached.
_ActivityLog
A thread-safe, fixed-size ring buffer (collections.deque(maxlen=300)) of recent events,
guarded by its own threading.Lock (separate from DashboardState.lock).
push(kind, tenant_id, **extra)— appends{"ts": time.time(), "kind": kind, "tenant_id": tenant_id, **extra}. Once 300 events have been pushed, the oldest is silently dropped.recent(since=None, limit=200) -> List[Dict[str, Any]]— returns up tolimitevents, optionally filtered tots > since; always returns the most recentlimitevents (i.e.events[-limit:]after filtering), not the oldest.
Helper functions
_entry_preview(entry, max_len=140) -> str— for interaction-shaped content ({"input": ...}), previews as"→ {input}"; for encrypted content ({"encrypted": ...}), returns the literal string"[encrypted content]"rather than attempting to show ciphertext; otherwisestr(content). Truncates tomax_len - 1characters plus a trailing…if the result is too long._serialize_entry(entry) -> Dict[str, Any]— flattens aMemoryEntryinto a JSON-safe dict:id,preview(via_entry_preview),memory_type(.value),importance,effective_importance(both rounded to 3 decimals),priority(.name),access_count,tags,age_seconds(rounded to 1 decimal),timestamp, anduser_id(pulled fromentry.metadata.get("user_id"), which isNoneif absent rather than raising)._default_config() -> MemoryConfig— createsPath(tempfile.gettempdir()) / "memory_dashboard_storage"(creating the directory if needed) and returns aMemoryConfigpointed at it. Because this is the system temp directory, dashboard-only data does not survive a temp-dir cleanup and is shared across every process on the machine that doesn't overridemanager/config.
HTTP API
All routes are registered on the Flask app returned by create_app(). None of them
require authentication — the dashboard is intended for local/trusted-network use.
None of the tenant query parameters are validated against /api/tenants — passing an
arbitrary tenant_id that was never seeded (e.g. ?tenant=made-up) is accepted
transparently: AIMemoryManager._bundle() lazily creates a fresh, empty _TenantBundle
for it on first access, so the response is just empty/zeroed data rather than a 404.
main()
The CLI entry point used by python -m memory.dashboard:
def main() -> None
Parses --host (default 127.0.0.1), --port (default 8877), and --no-simulator
(a flag; when passed, create_app(autostart_simulator=False)). Runs
app.run(host, port, debug=False, threaded=True) — threaded=True is required because
the simulator thread and incoming HTTP requests both need to make progress concurrently
against the same AIMemoryManager.
Frontend (static/)
index.html loads Chart.js and D3 from a CDN (cdn.jsdelivr.net) and defines the page
shell: a KPI strip, a "consolidation pipeline" visualization, a tabbed layer inspector
(stm / working / ltm / semantic), an importance-distribution chart, a top-topics
list, the semantic relationship graph, a per-user profile table, and a live activity feed.
app.js polls the /api/* routes on an interval and re-renders each panel; style.css
provides the dark, monospace-accented visual theme (Space Grotesk / Inter / IBM Plex Mono
fonts). None of the frontend code talks to AIMemoryManager directly — it only ever calls
the JSON endpoints listed above.
Thread safety
DashboardState.lock(RLock) guards every read/write that touchesmanageror a_TenantBundle(bundle,simulate_one,run_maintenance, and the manager access insideentries/importance_histogram/graph_data/profiles).RLockis required because e.g.entries()callsself.bundle(...)(which acquires the lock) and then acquires it again itself._ActivityLoghas its own independentLock, so activity-log pushes/reads never contend with the manager lock.- The Flask app itself runs
threaded=True, so multiple requests (plus the simulator thread) can be in flight concurrently; all of them are serialized throughDashboardState.lockfor any manager access.
Dependencies
Requires flask and numpy (for the graph's cosine-similarity computation) in addition
to the base memory package's own dependencies. Neither is a dependency of memory itself
— importing memory.dashboard without flask/numpy installed raises ImportError at
import time, even if you never call create_app().
Common mistakes
- Calling
create_app()multiple times in the same process (e.g. from a test fixture or a reload-on-change dev server) without stopping earlier simulator threads first — each call seeds demo data and starts its own background thread, so previously createdDashboardStates keep running invisibly in the background. - Assuming
/api/tenantsreflects real tenant activity — it is always the hardcoded["acme", "globex"]demo list, regardless of what tenants a passed-inmanageractually has data for. Query/api/stats?tenant=<real_id>directly instead. - Treating this as a production admin API — there is no authentication, and several routes
(
/api/simulate,/api/maintenance) mutate the underlyingAIMemoryManager. Do not expose it on a public network without putting it behind your own auth layer. - Relying on
importance_histogramfor an exact per-entry count — an entry present in both a working set and LTM (or semantic) is counted once per layer it appears in, not once overall.
Related APIs
AIMemoryManager— the component the dashboard wraps; every panel in the UI ultimately reflects state reachable from this class's_bundle(),_profile_manager,stats(),save_interaction(),build_context(), andrun_maintenance().MemoryConfig— what/api/configserializes, and what_default_config()builds a default instance of.UserProfileManager— backs/api/profiles; see itsaggregate_stats()for the on-disk-loading side effect noted above.