InternalsRendering the Memory Graph

Mnemosyne models a workspace’s memory as a graph: entities that have been remembered, the episodes and decisions attached to them, and the typed relations between them. The @mnemosyne/core package gives you the data and the drawing primitives — it does not ship a framework-coupled <MemoryGraph/> component. The visualization itself lives in your app, so you keep full control over framework, theme, and interaction. This page is the contract and the recipes.

The full runnable version of every snippet below lives in examples/graph-viewer.


The data contract

The graph payload is three flat arrays plus a meta count block. All types are exported from @mnemosyne/core/graph and are framework-agnostic — no React, no Drizzle, no Node built-ins.

export type GraphEntityKind =
  | "person" | "organization" | "project" | "concept" | "place" | "other";
 
export type GraphNodeKind = "entity" | "episode" | "decision";
 
export interface GraphNode {
  id: string;
  kind: GraphNodeKind;
  entityKind?: GraphEntityKind;      // present only when kind === "entity"
  label: string;
  description?: string | null;
  mentionCount: number;               // how often this entity surfaced
  factCount: number;                  // active facts linked to this node
  avgMemoryStrength: number;          // mean potentiation; 1.0 is the neutral baseline
  createdAt: string;                  // ISO 8601
}
 
export interface GraphEdge {
  id: string;
  source: string;                     // GraphNode.id
  target: string;                     // GraphNode.id
  relation: string;                   // a relation verb (see below)
  confidence: number;                 // 0..1
  provenance: string | null;          // where this relation came from
}
 
export interface GraphResponse {
  nodes: GraphNode[];
  edges: GraphEdge[];
  meta: {
    entityCount: number;
    episodeCount: number;
    decisionCount: number;
    relationCount: number;
  };
}
 
export interface GraphQueryOptions {
  focusEntityId?: string;             // restrict to one entity + its neighbors
}

Node kinds

kindWhat it is
entityA remembered thing — a person, org, project, concept, place. Refined by entityKind. Carries mentionCount, factCount, avgMemoryStrength.
episodeA non-synthetic conversational episode; factCount is the number of linked facts.
decisionAn active decision record.

GraphNode.id is stable, so edges reference nodes by id. Only canonical entities are emitted as nodes (entities merged into a canonical one via canonicalId are dropped), and the shape never changes by kind — fields that don’t apply (e.g. entityKind on an episode) are simply absent.

Edge fields

Edges are directional relations with a confidence in [0, 1] and an optional provenance string. The relation verb drives styling — known verbs include related, compatible, conflicts_with, derived_from, supersedes, part_of, member_of, and scoped. Edges referencing a node that isn’t in the response are filtered out, so the payload is always internally consistent.


The client/server split

@mnemosyne/core exposes the graph through two separate entry points, and the boundary is load-bearing:

ImportContainsTouches Postgres?
@mnemosyne/core/graphdrawNode, drawEdge, nodeRadius, ENTITY_KIND_COLOR, EDGE_STYLES, defaultForceConfig, all typesNo
@mnemosyne/core/graph/serverbuildGraphData, buildGraphQueryYes (Drizzle + postgres driver)

Why the split exists: @mnemosyne/core/graph/server imports ../db, which pulls in Drizzle and the postgres driver (which uses node:net / node:tls). If those leaked into a browser bundle, the build would either fail or balloon. The client entry is guaranteed to import zero server/DB code — it is the exact surface a browser (or any non-Node renderer) is meant to import. Keep this discipline in your own code: fetch graph data on the server with buildGraphQuery, ship the plain GraphResponse JSON to the client, and let the client import only @mnemosyne/core/graph for drawing.

// SERVER ONLY — e.g. an API route / loader. Runs inside an RLS-scoped tx.
import { buildGraphQuery } from "@mnemosyne/core/graph/server";
import { withMnemoTx } from "@mnemosyne/core";
 
export async function loadGraph(workspaceId: string) {
  return withMnemoTx(workspaceId, (tx) => buildGraphQuery(tx, workspaceId));
}

buildGraphData is the pure transform underneath buildGraphQuery — useful for unit tests, since it takes plain row arrays and never opens a connection.


The GraphNode / GraphEdge shapes were designed to drop straight into the force-graph ecosystem. Pick based on the interaction you want:

NeedLibraryWhy
2D force-directed (default)react-force-graph-2dCanvas-based, high perf for hundreds of nodes. { nodes, links } matches GraphResponse after a one-line rename (edges → links).
3Dreact-force-graph-3dSame API on three.js. Swapping 2D→3D is a one-import change.
Layout tuningd3-forcedefaultForceConfig() returns exactly the charge / link-distance / center / alphaDecay knobs d3-force consumes.
Node-based / editable (alternative)@xyflow/react (React Flow)When you want boxes with handles and manual editing instead of physics nodes.
Plain canvas, no framework(none)Use drawNode / drawEdge directly on a <canvas> — the package ships these.

The package deliberately does not depend on any of these; you install whichever fits.


Recipe A — react-force-graph-2d (minimal)

import ForceGraph2D from "react-force-graph-2d";
import {
  drawNode, drawEdge, nodeRadius, ENTITY_KIND_COLOR,
  type GraphResponse, type GraphNode,
} from "@mnemosyne/core/graph";
 
export function MemoryGraph({ data }: { data: GraphResponse }) {
  const maxMentions = Math.max(1, ...data.nodes.map((n) => n.mentionCount));
 
  return (
    <ForceGraph2D
      // react-force-graph expects `links`, Mnemosyne calls them `edges`.
      graphData={{ nodes: data.nodes, links: data.edges }}
      nodeCanvasObject={(node, ctx, globalScale) => {
        const n = node as GraphNode & { x: number; y: number };
        const color =
          ENTITY_KIND_COLOR[n.entityKind ?? n.kind] ?? ENTITY_KIND_COLOR.other;
        drawNode(ctx, {
          x: n.x, y: n.y,
          r: nodeRadius(n.mentionCount, maxMentions),
          color, selected: false,
          memoryStrength: n.avgMemoryStrength,
          label: n.label, kind: n.kind, entityKind: n.entityKind,
          globalScale,
        });
      }}
      linkCanvasObject={(link, ctx) => {
        const l = link as { source: any; target: any; relation: string; confidence: number };
        drawEdge(ctx, {
          sx: l.source.x, sy: l.source.y,
          tx: l.target.x, ty: l.target.y,
          relation: l.relation, confidence: l.confidence,
        });
      }}
    />
  );
}

drawNode chooses a shape from the node’s kind (circle for people, hexagon for orgs, diamond for concepts/decisions, rounded-rect for projects/episodes, pentagon for places) and renders a memory-strength aura scaled by avgMemoryStrength. drawEdge styles the line by relation (via EDGE_STYLES) and modulates width/opacity by confidence, adding a directional arrowhead.


Recipe B — 2D ⇄ 3D toggle

Because both renderers share the same graphData shape and the same node/link accessors, swapping dimensions is one import. (3D draws nodes with three.js objects rather than the 2D canvas helpers — use nodeThreeObject / nodeColor there, keeping ENTITY_KIND_COLOR and nodeRadius for sizing.)

import { useState } from "react";
import ForceGraph2D from "react-force-graph-2d";
import ForceGraph3D from "react-force-graph-3d";
import { nodeRadius, ENTITY_KIND_COLOR, type GraphResponse } from "@mnemosyne/core/graph";
 
export function ToggleGraph({ data }: { data: GraphResponse }) {
  const [is3D, setIs3D] = useState(false);
  const maxMentions = Math.max(1, ...data.nodes.map((n) => n.mentionCount));
  const graphData = { nodes: data.nodes, links: data.edges };
 
  const common = {
    graphData,
    nodeColor: (n: any) => ENTITY_KIND_COLOR[n.entityKind ?? n.kind] ?? ENTITY_KIND_COLOR.other,
    nodeVal: (n: any) => nodeRadius(n.mentionCount, maxMentions),
    nodeLabel: (n: any) => n.label,
  };
 
  return (
    <>
      <button onClick={() => setIs3D((v) => !v)}>{is3D ? "2D" : "3D"}</button>
      {is3D ? <ForceGraph3D {...common} /> : <ForceGraph2D {...common} />}
    </>
  );
}

Recipe C — raw <canvas> (no framework)

drawNode / drawEdge are pure CanvasRenderingContext2D calls — you don’t need React or a graph library. Drive the layout with d3-force seeded from defaultForceConfig():

import * as d3 from "d3-force";
import {
  drawNode, drawEdge, nodeRadius, defaultForceConfig,
  ENTITY_KIND_COLOR, type GraphResponse,
} from "@mnemosyne/core/graph";
 
export function render(canvas: HTMLCanvasElement, data: GraphResponse) {
  const ctx = canvas.getContext("2d")!;
  const cfg = defaultForceConfig();
  const maxMentions = Math.max(1, ...data.nodes.map((n) => n.mentionCount));
 
  const nodes = data.nodes.map((n) => ({ ...n }));
  const links = data.edges.map((e) => ({ ...e }));
 
  const sim = d3
    .forceSimulation(nodes as any)
    .force("charge", d3.forceManyBody().strength(cfg.chargeStrength))
    .force("link", d3.forceLink(links as any).id((d: any) => d.id).distance(cfg.linkDistance))
    .force("center", d3.forceCenter(canvas.width / 2, canvas.height / 2).strength(cfg.centerStrength))
    .alphaDecay(cfg.alphaDecay)
    .on("tick", () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      for (const l of links as any[]) {
        drawEdge(ctx, {
          sx: l.source.x, sy: l.source.y, tx: l.target.x, ty: l.target.y,
          relation: l.relation, confidence: l.confidence,
        });
      }
      for (const n of nodes as any[]) {
        drawNode(ctx, {
          x: n.x, y: n.y,
          r: nodeRadius(n.mentionCount, maxMentions),
          color: ENTITY_KIND_COLOR[n.entityKind ?? n.kind] ?? ENTITY_KIND_COLOR.other,
          selected: false, memoryStrength: n.avgMemoryStrength,
          label: n.label, kind: n.kind, entityKind: n.entityKind,
          globalScale: 1,
        });
      }
    });
 
  return () => sim.stop();
}

What the package owns vs. what you own

  • Mnemosyne owns: the data (buildGraphQuery), the type contract, and the drawing/color/layout primitives (drawNode, drawEdge, ENTITY_KIND_COLOR, EDGE_STYLES, nodeRadius, defaultForceConfig).
  • You own: the component, the framework choice, the theme, panning/zoom, and click/hover interaction.

This keeps the package small, browser-safe, and un-opinionated about your frontend stack — while still giving you a graph that looks right out of the box.