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
kind | What it is |
|---|---|
entity | A remembered thing — a person, org, project, concept, place. Refined by entityKind. Carries mentionCount, factCount, avgMemoryStrength. |
episode | A non-synthetic conversational episode; factCount is the number of linked facts. |
decision | An 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:
| Import | Contains | Touches Postgres? |
|---|---|---|
@mnemosyne/core/graph | drawNode, drawEdge, nodeRadius, ENTITY_KIND_COLOR, EDGE_STYLES, defaultForceConfig, all types | No |
@mnemosyne/core/graph/server | buildGraphData, buildGraphQuery | Yes (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.
Recommended libraries
The GraphNode / GraphEdge shapes were designed to drop straight into the
force-graph ecosystem. Pick based on the interaction you want:
| Need | Library | Why |
|---|---|---|
| 2D force-directed (default) | react-force-graph-2d | Canvas-based, high perf for hundreds of nodes. { nodes, links } matches GraphResponse after a one-line rename (edges → links). |
| 3D | react-force-graph-3d | Same API on three.js. Swapping 2D→3D is a one-import change. |
| Layout tuning | d3-force | defaultForceConfig() 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.