Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2282cdf
Add files via upload
elevanta21 Oct 23, 2026
8a7e64f
Add files via upload
elevanta21 Oct 23, 2026
89ecef8
Add files via upload
elevanta21 Oct 23, 2026
9a1c6b1
Add files via upload
elevanta21 Oct 23, 2026
1d64cf8
Add files via upload
elevanta21 Oct 23, 2026
a6ab287
Add files via upload
elevanta21 Oct 23, 2026
087653e
Add files via upload
elevanta21 Oct 23, 2026
6f4c672
feat(web): WebGL2 renderer for the 3D memory graph — 50,000 nodes at …
elevanta21 Oct 23, 2026
8235579
feat(web): WebGL2 renderer for the 3D memory graph — 50,000 nodes at …
elevanta21 Oct 23, 2026
06f77b0
fix(web): address CodeRabbit review on the WebGL memory graph (PR #263)
elevanta21 Oct 23, 2026
841dab9
fix(web): address CodeRabbit review on the WebGL memory graph (PR #263)
elevanta21 Oct 23, 2026
cd92ba9
fix(web): address CodeRabbit re-review + hostile-review pass on the W…
elevanta21 Oct 23, 2026
bb8a9b0
fix(web): address CodeRabbit re-review + hostile-review pass on the W…
elevanta21 Oct 23, 2026
c5a1441
fix(web): address CodeRabbit re-review + hostile-review pass on the W…
elevanta21 Oct 23, 2026
8bbd220
fix(web): address CodeRabbit re-review + hostile-review pass on the W…
elevanta21 Oct 23, 2026
6ca613d
fix(web): address CodeRabbit re-review + hostile-review pass on the W…
elevanta21 Oct 23, 2026
817c759
fix(web): address CodeRabbit re-review + hostile-review pass on the W…
elevanta21 Oct 23, 2026
0ba9e53
fix(web): address CodeRabbit re-review + hostile-review pass on the W…
elevanta21 Oct 23, 2026
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
25 changes: 20 additions & 5 deletions apps/api/src/memory-graph-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,21 @@ function memoryIssuesRevisionHash(snapshotRevision: string, states: MemoryIssueS
return digest(JSON.stringify({ snapshotRevision, stateRevisions }));
}

export const MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT = 2000;
export const MEMORY_GRAPH_OVERVIEW_MAX_LIMIT = 50000;

// Keep the same payload budget while retaining the taxonomy needed to interpret a large archive.
export function selectOverviewNodes(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]): MemoryGraphNode[] {
if (nodes.length <= 2000) return nodes;
/**
* Choose which nodes the overview endpoint returns. Small graphs pass through
* untouched; large ones keep taxonomy-bearing nodes first (sources, topics,
* tagged memories, recent records) and fill the remainder in input order.
* @param nodes All matching nodes, pre-filtered by the endpoint.
* @param edges All matching edges; used to identify tagged and connected nodes.
* @param limit Maximum nodes to select; defaults to MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT.
* @returns The selected nodes, in selection order.
*/
export function selectOverviewNodes(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[], limit: number = MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT): MemoryGraphNode[] {
if (nodes.length <= limit) return nodes;
const members = new Map<string, Set<string>>();
const explicit = new Set<string>();
const byId = new Map(nodes.map(node => [node.id, node]));
Expand All @@ -336,7 +348,7 @@ export function selectOverviewNodes(nodes: MemoryGraphNode[], edges: MemoryGraph
if (first) selected.set(first, byId.get(first)!);
}
for (const node of nodes) {
if (selected.size >= 2000) break;
if (selected.size >= limit) break;
selected.set(node.id, node);
}
return [...selected.values()];
Expand Down Expand Up @@ -385,12 +397,15 @@ export function registerMemoryGraphRoutes(
matchingNodeIds.has(edge.target) &&
(query.relationMode !== "RELATIONS" || anchorIds.has(edge.source) || anchorIds.has(edge.target))
);
const selectedNodes = selectOverviewNodes(matchingNodes, matchingEdges);
const nodeLimit = Math.min(query.limit ?? MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT, MEMORY_GRAPH_OVERVIEW_MAX_LIMIT);
const selectedNodes = selectOverviewNodes(matchingNodes, matchingEdges, nodeLimit);
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
// Edges scale with the node budget: three edges per node keeps the
// historical 2,000-node / 6,000-edge ratio up to 150,000 at the maximum.
const selectedEdges = matchingEdges
.filter((edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target))
.sort((a, b) => Number(b.type === "CONTAINS" || b.type === "DERIVED_FROM") - Number(a.type === "CONTAINS" || a.type === "DERIVED_FROM"))
.slice(0, 6000);
.slice(0, Math.min(150000, nodeLimit * 3));
const data = memoryGraphOverviewPayloadSchema.parse({
version: snapshot.version,
layoutVersion: snapshot.layoutVersion,
Expand Down
37 changes: 37 additions & 0 deletions apps/api/tests/memory-graph-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,43 @@ describe("Memory graph API", () => {
await app.close();
});

it("honors the overview limit up to the hard maximum and keeps the default unchanged", async () => {
const graphSnapshot = snapshot() as MemoryGraphSnapshot;
graphSnapshot.nodes = Array.from({ length: 2600 }, (_, index) => ({
id: `memory:limit-${index}`,
type: "MEMORY" as const,
label: `Limit memory ${index}`,
sourcePath: null,
recordId: null
}));
graphSnapshot.edges = [];
graphSnapshot.summary = {
...graphSnapshot.summary,
nodeCount: graphSnapshot.nodes.length,
edgeCount: 0
};
const app = await memoryGraphApp(true, new InMemorySpaceStore(), false, undefined, { graphSnapshot });

const fallback = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview", headers: { cookie } });
expect(fallback.statusCode).toBe(200);
expect(fallback.json().data.nodes).toHaveLength(2000);
expect(fallback.json().data.truncated).toBe(true);
expect(fallback.json().data.totalMatchingNodes).toBe(2600);

const raised = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview?limit=2600", headers: { cookie } });
expect(raised.statusCode).toBe(200);
expect(raised.json().data.nodes).toHaveLength(2600);
expect(raised.json().data.truncated).toBe(false);

const maximum = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview?limit=50000", headers: { cookie } });
expect(maximum.statusCode).toBe(200);
expect(maximum.json().data.nodes).toHaveLength(2600);

const beyond = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview?limit=50001", headers: { cookie } });
expect(beyond.statusCode).toBe(422);
await app.close();
});

it("serves the all-months archive and single-month filtering through the month query param", async () => {
const graphSnapshot = snapshot() as MemoryGraphSnapshot;
const archiveSnapshot = snapshot() as MemoryGraphSnapshot;
Expand Down
40 changes: 37 additions & 3 deletions apps/web/src/demo/DemoStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type { SpaceApiClient } from "../runtime/SpaceRuntime.js";
import { DEMO_LOCAL_REPLY, SpaceApiError } from "../runtime/SpaceRuntime.js";
import { CLI_RUNTIME_PRESENTATIONS, cliRuntimeLabel } from "../cli-runtime-presentation.js";
import { createDemoFixture, DEMO_FIXED_AT, type DemoFixture } from "./demo-fixture.js";
import { generateSyntheticMemoryGraph } from "./synthetic-memory-graph.js";
import { MockServices } from "./mock-services.js";
import { getCliMockTranscript, getCliMockModel, getCliMockCwd, getCliMockDefinition } from "./mock-cli-transcripts.js";

Expand Down Expand Up @@ -148,8 +149,12 @@ type DemoMemoryGraphQuery = {
relationMode?: "CLUSTERED" | "RELATIONS";
page?: number;
pageSize?: number;
limit?: number;
graphSize?: number;
};

export { generateSyntheticMemoryGraph };

function memoryChangeSetSummary(changeSet: MemoryChangeSet): MemoryChangeSetSummary {
const { beforeSnapshot: _before, afterSnapshot: _after, ...summary } = changeSet;
return summary;
Expand Down Expand Up @@ -668,8 +673,27 @@ export class DemoStore {
};
}

/**
* Demo URL override for scale benchmarks: ?demoGraphSize=50000 renders a
* synthetic graph of that size instead of the fixture graph.
* @returns The requested node count, or undefined for a missing/invalid value.
*/
private demoGraphSizeFromUrl(): number | undefined {
try {
if (typeof window === "undefined") return undefined;
const value = Number(new URLSearchParams(window.location.search).get("demoGraphSize"));
return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
} catch {
return undefined;
}
}

private memoryGraph(query: DemoMemoryGraphQuery = {}): MemoryGraphPayload {
const base = this.fixture.memoryWorkspace.graph;
const graphSize = query.graphSize ?? this.demoGraphSizeFromUrl();
const synthetic = graphSize ? generateSyntheticMemoryGraph(graphSize) : null;
const base = synthetic
? { ...this.fixture.memoryWorkspace.graph, nodes: synthetic.nodes, edges: synthetic.edges }
: this.fixture.memoryWorkspace.graph;
const needle = query.q?.trim().toLowerCase();
const nodes = base.nodes.filter((node) => {
const record = node.recordId ? this.fixture.memoryWorkspace.nodeDetails[node.id]?.record : null;
Expand Down Expand Up @@ -1496,8 +1520,18 @@ export class DemoStore {
});
}
case "memoryGraphOverview": {
const graph = this.memoryGraph((args[0] ?? {}) as DemoMemoryGraphQuery);
return Promise.resolve({ data: { ...graph, totalMatchingNodes: graph.nodes.length, totalMatchingEdges: graph.edges.length, truncated: false } });
const query = (args[0] ?? {}) as DemoMemoryGraphQuery;
const graph = this.memoryGraph(query);
const limit = query.limit;
if (!limit || graph.nodes.length <= limit) {
return Promise.resolve({ data: { ...graph, totalMatchingNodes: graph.nodes.length, totalMatchingEdges: graph.edges.length, truncated: false } });
}
const nodes = graph.nodes.slice(0, limit);
const visibleIds = new Set(nodes.map((node) => node.id));
const edges = graph.edges
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
.slice(0, Math.min(150000, limit * 3));
return Promise.resolve({ data: { ...graph, nodes, edges, totalMatchingNodes: graph.nodes.length, totalMatchingEdges: graph.edges.length, truncated: true } });
}
case "memoryCalendar": {
const query = (args[0] ?? {}) as { year?: number; month?: string; date?: string };
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/demo/synthetic-memory-graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { MemoryGraphEdge, MemoryGraphNode } from "@space/contracts";

/**
* Deterministic synthetic memory graph for scale benchmarks: a core source,
* a ring of source files, topics, and memories with contains / tagged /
* semantic edges at roughly three edges per node (50,000 nodes ≈ 150,000
* edges). Anyone can reproduce the 3D renderer's benchmark in demo mode
* with ?demoGraphSize=50000 — no backend or embeddings provider needed.
*/
export function generateSyntheticMemoryGraph(nodeCount: number): { nodes: MemoryGraphNode[]; edges: MemoryGraphEdge[] } {
const total = Math.max(1, Math.floor(nodeCount));
const nodes: MemoryGraphNode[] = [{ id: "source:space.md", type: "SOURCE", label: "space.md", sourcePath: "/demo/memory/space.md", recordId: null }];
const edges: MemoryGraphEdge[] = [];
const sourceCount = Math.max(1, Math.min(400, Math.round(total / 250)));
const sources: string[] = [];
for (let s = 0; s < sourceCount && nodes.length < total; s++) {
const id = `source:file-${s}`;
sources.push(id);
nodes.push({ id, type: "SOURCE", label: `file-${s}.md`, sourcePath: `/demo/memory/file-${s}.md`, recordId: null });
edges.push({ id: `edge:derived-${s}`, type: "DERIVED_FROM", source: id, target: "source:space.md" });
}
const topicCount = Math.max(2, Math.min(64, Math.round(total / 1500)));
const topics: string[] = [];
for (let t = 0; t < topicCount && nodes.length < total; t++) {
const id = `topic:topic-${t}`;
topics.push(id);
nodes.push({ id, type: "TOPIC", label: `Topic ${t}`, sourcePath: null, recordId: null });
}
let memoryIndex = 0;
while (nodes.length < total) {
const id = `memory:synthetic-${memoryIndex}`;
const source = sources[memoryIndex % sources.length]!;
nodes.push({ id, type: "MEMORY", label: `Synthetic memory ${memoryIndex}`, sourcePath: `/demo/memory/file-${memoryIndex % sources.length}.md`, recordId: id });
edges.push({ id: `edge:contains-${memoryIndex}`, type: "CONTAINS", source, target: id });
if (memoryIndex % 3 === 0) {
edges.push({ id: `edge:tagged-${memoryIndex}`, type: "TAGGED_WITH", source: id, target: topics[memoryIndex % topics.length]!, origin: "EXPLICIT_TAG", confidence: 1, evidence: "Synthetic demo tag." });
}
if (memoryIndex > 0) {
edges.push({ id: `edge:related-${memoryIndex}`, type: "SEMANTICALLY_RELATED", source: id, target: `memory:synthetic-${memoryIndex - 1}`, origin: "DETERMINISTIC_TFIDF", confidence: 0.8, evidence: "Synthetic demo relation." });
}
if (memoryIndex >= 7 && memoryIndex % 5 === 0) {
edges.push({ id: `edge:related-skip-${memoryIndex}`, type: "SEMANTICALLY_RELATED", source: `memory:synthetic-${memoryIndex - 7}`, target: id, origin: "DETERMINISTIC_TFIDF", confidence: 0.6, evidence: "Synthetic demo relation." });
}
if (memoryIndex >= 2 && memoryIndex % 2 === 0) {
edges.push({ id: `edge:related-pair-${memoryIndex}`, type: "SEMANTICALLY_RELATED", source: `memory:synthetic-${memoryIndex - 2}`, target: id, origin: "DETERMINISTIC_TFIDF", confidence: 0.7, evidence: "Synthetic demo relation." });
}
memoryIndex++;
}
return { nodes, edges };
}
Loading