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:

Bash
python -m memory.dashboard

or embed it in an existing process:

Python · 4 lines
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 under acme, 2 under globex).
  • _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 calls simulate_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

ParameterTypeRequiredDefaultNotes
managerOptional[AIMemoryManager]NoNoneIf omitted, a new AIMemoryManager is constructed from _default_config() (an in-tempfile.gettempdir() SQLite/JSON persistence path, window_size=8, max_working=12, max_short_term=100, max_long_term=500, importance_threshold=0.65).
autostart_simulatorboolNoTrueIf True, starts the background synthetic-traffic thread immediately after seeding demo data.

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

Python
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 calls simulate_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 internal threading.Event, which the loop checks via Event.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; calls manager.save_interaction(...), and with 60% probability also calls manager.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

MethodReturnsNotes
bundle(tenant_id)_TenantBundleReaches into manager._bundle(tenant_id) — an underscore-prefixed, "internal" attribute of AIMemoryManager. The dashboard is documented as trusted, in-process code that deliberately bypasses the manager's public surface for read access.
full_stats(tenant_id)Dict[str, Any]manager.stats(tenant_id) verbatim.
entries(tenant_id, layer, limit=50)List[Dict[str, Any]]layer is one of "stm", "working", "ltm", "semantic" (any other value silently returns []). STM is sorted newest-first by timestamp; working memory is returned in its own stored order; LTM uses get_top_by_importance(limit=limit); semantic reads bundle.semantic._store.values() directly (another internal attribute) sorted newest-first. Each entry is serialized via _serialize_entry().
importance_histogram(tenant_id, bins=5)List[int]Pools every entry across STM, working memory, up to 300 LTM entries by importance, and all semantic entries; buckets each by effective_importance into bins equal-width buckets (min(bins - 1, int(effective_importance * bins))). Note this means STM/working/semantic entries that also happen to be promoted to LTM (or vice versa) can be double-counted in the histogram — it does not deduplicate by entry ID across layers.
graph_data(tenant_id, limit=40, sim_threshold=0.4, max_edges_per_node=4){"nodes": [...], "edges": [...]}See below.
profiles(tenant_id){"users": [...], "aggregate": {...}}See below.
run_maintenance(tenant_id)Dict[str, Any]manager.run_maintenance(tenant_id), plus a "maintenance" activity-log entry (with the "timestamp" key stripped from the logged copy of the report).

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 contributes 0.0 for any pair missing one).
  • A candidate edge is kept only if its weight is >= sim_threshold (default 0.4).
  • To keep the graph readable, only the strongest max_edges_per_node (default 4) 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 limit entries (a full pairwise similarity pass), so limit should 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 to limit events, optionally filtered to ts > since; always returns the most recent limit events (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; otherwise str(content). Truncates to max_len - 1 characters plus a trailing … if the result is too long.
  • _serialize_entry(entry) -> Dict[str, Any] — flattens a MemoryEntry into 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, and user_id (pulled from entry.metadata.get("user_id"), which is None if absent rather than raising).
  • _default_config() -> MemoryConfig — creates Path(tempfile.gettempdir()) / "memory_dashboard_storage" (creating the directory if needed) and returns a MemoryConfig pointed 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 override manager/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.

MethodPathQuery / body paramsReturns
GET/—static/index.html.
GET/api/tenants—{"tenants": ["acme", "globex"]} — always the hardcoded demo list, not the tenants actually present in manager._bundles (a real manager with other tenant IDs won't have them appear here).
GET/api/statstenant (default "acme")manager.stats(tenant) plus uptime_seconds and importance_histogram.
GET/api/entriestenant, layer (default "stm"), limit (default 50){"layer", "label", "entries"} — label is looked up from _LAYER_LABELS, falling back to the raw layer string if unrecognized.
GET/api/graphtenant, limit (default 40)DashboardState.graph_data(...) result.
GET/api/profilestenantDashboardState.profiles(...) result.
GET/api/activitytenant (optional), since (optional float){"events", "server_time"} — if tenant is omitted, events for all tenants are returned.
POST/api/simulateJSON body {"tenant": ..., "user": ...} (both optional)Result of one simulate_one(...) call — lets the frontend's "Simulate" button trigger a turn on demand, independent of the background thread.
POST/api/maintenanceJSON body {"tenant": ...} (default "acme")The maintenance report from manager.run_maintenance(tenant).
GET/api/config—manager.config.to_dict() — the live MemoryConfig, not tenant-scoped.

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:

Python
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 touches manager or a _TenantBundle (bundle, simulate_one, run_maintenance, and the manager access inside entries/importance_histogram/graph_data/profiles). RLock is required because e.g. entries() calls self.bundle(...) (which acquires the lock) and then acquires it again itself.
  • _ActivityLog has its own independent Lock, 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 through DashboardState.lock for 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 created DashboardStates keep running invisibly in the background.
  • Assuming /api/tenants reflects real tenant activity — it is always the hardcoded ["acme", "globex"] demo list, regardless of what tenants a passed-in manager actually 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 underlying AIMemoryManager. Do not expose it on a public network without putting it behind your own auth layer.
  • Relying on importance_histogram for 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.
  • 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(), and run_maintenance().
  • MemoryConfig — what /api/config serializes, and what _default_config() builds a default instance of.
  • UserProfileManager — backs /api/profiles; see its aggregate_stats() for the on-disk-loading side effect noted above.

Related pages