Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion packages/arcanea-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import {
getRelatedCreations,
suggestConnections,
getGraphSummary,
runGuardianSafetyCheck,
getSisWorldGraphFilePath,
exportGraph,
findPath,
type RelationshipType,
Expand Down Expand Up @@ -301,9 +303,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
case "get_world_graph": {
const summary = getGraphSummary(sessionId);
const safety = runGuardianSafetyCheck(sessionId);
return { content: [{ type: "text", text: JSON.stringify({
worldSummary: summary,
description: `Your world contains ${summary.nodeCount} creations connected by ${summary.edgeCount} relationships.`,
guardianSafety: safety,
sisSync: {
status: "synced",
storagePath: getSisWorldGraphFilePath(),
},
description: `Your world contains ${summary.nodeCount} creations connected by ${summary.edgeCount} relationships. Guardian safety is ${safety.status}.`,
}, null, 2) }] };
}
case "find_path": {
Expand Down
218 changes: 217 additions & 1 deletion packages/arcanea-mcp/src/tools/creation-graph.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Creation Graph - Relationship Network for Generated Content
// Inspired by Qdrant vector patterns and knowledge graphs

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { CreationRef } from "../memory/index.js";

export interface CreationNode {
Expand All @@ -11,6 +14,7 @@ export interface CreationNode {
gate?: number;
createdAt: Date;
metadata: Record<string, any>;
embedding?: number[];
}

export interface CreationEdge {
Expand Down Expand Up @@ -43,10 +47,176 @@ interface CreationGraph {
edges: CreationEdge[];
}

export interface GuardianSafetyIssue {
severity: "warning" | "error";
message: string;
}

export interface GuardianSafetyReport {
status: "safe" | "warning" | "error";
issues: GuardianSafetyIssue[];
}

interface PersistedCreationNode extends Omit<CreationNode, "createdAt"> {
createdAt: string;
}

interface PersistedCreationGraph {
nodes: PersistedCreationNode[];
edges: CreationEdge[];
}

interface PersistedWorldGraphsFile {
version: 1;
updatedAt: string;
graphs: Record<string, PersistedCreationGraph>;
}

// In-memory graph store
const graphs = new Map<string, CreationGraph>();
const worldGraphFilePath = join(homedir(), ".arcanea", "sis-world-graphs.json");
let loadedFromDisk = false;

const EMBEDDING_DIMENSIONS = 64;

function ensureSisDirectory(): void {
mkdirSync(dirname(worldGraphFilePath), { recursive: true });
}

function parseNodeTypeForSupabase(type: CreationRef["type"]): "lore" | "character" | "location" {
if (type === "character") return "character";
if (type === "location") return "location";
return "lore";
}

function getSupabaseConfig():
| { url: string; serviceRoleKey: string }
| null {
const url = process.env["SUPABASE_URL"] ?? process.env["NEXT_PUBLIC_SUPABASE_URL"];
const serviceRoleKey = process.env["SUPABASE_SERVICE_ROLE_KEY"];
if (!url || !serviceRoleKey) return null;
return { url, serviceRoleKey };
}

function serializeGraph(graph: CreationGraph): PersistedCreationGraph {
return {
nodes: Array.from(graph.nodes.values()).map((n) => ({
...n,
createdAt: (n.createdAt instanceof Date ? n.createdAt : new Date(n.createdAt)).toISOString(),
})),
edges: graph.edges,
};
}

function toCreationNode(node: PersistedCreationNode): CreationNode {
return {
...node,
createdAt: new Date(node.createdAt),
};
}

function loadGraphsFromSisStorage(): void {
if (loadedFromDisk) return;
loadedFromDisk = true;
ensureSisDirectory();
if (!existsSync(worldGraphFilePath)) return;

try {
const parsed = JSON.parse(readFileSync(worldGraphFilePath, "utf-8")) as Partial<PersistedWorldGraphsFile>;
for (const [sessionId, persistedGraph] of Object.entries(parsed.graphs ?? {})) {
graphs.set(sessionId, {
nodes: new Map((persistedGraph.nodes ?? []).map((node) => [node.id, toCreationNode(node)])),
edges: persistedGraph.edges ?? [],
});
}
} catch {
// Corrupt local SIS file should not block world graph access.
}
}

function syncGraphsToSisStorage(): void {
ensureSisDirectory();
const payload: PersistedWorldGraphsFile = {
version: 1,
updatedAt: new Date().toISOString(),
graphs: Object.fromEntries(
Array.from(graphs.entries()).map(([sessionId, graph]) => [sessionId, serializeGraph(graph)])
),
};
writeFileSync(worldGraphFilePath, JSON.stringify(payload, null, 2), "utf-8");
}

export function getSisWorldGraphFilePath(): string {
ensureSisDirectory();
return worldGraphFilePath;
}

export function buildWorldGraphEmbedding(content: string): number[] {
const vector = new Array<number>(EMBEDDING_DIMENSIONS).fill(0);
for (let i = 0; i < content.length; i++) {
const code = content.charCodeAt(i);
vector[i % EMBEDDING_DIMENSIONS] += code / 255;
}
const norm = Math.sqrt(vector.reduce((acc, value) => acc + value * value, 0));
if (!norm) return vector;
return vector.map((value) => Number((value / norm).toFixed(6)));
}

async function persistNodeToSupabase(sessionId: string, node: CreationNode): Promise<void> {
const config = getSupabaseConfig();
if (!config) return;

const nodeType = parseNodeTypeForSupabase(node.type);
const embeddingText = `[${(node.embedding ?? buildWorldGraphEmbedding(
`${node.name} ${node.metadata?.summary ?? ""}`
)).join(",")}]`;

await fetch(`${config.url}/rest/v1/world_graph_nodes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"apikey": config.serviceRoleKey,
"Prefer": "resolution=merge-duplicates,return=minimal",
},
body: JSON.stringify({
session_id: sessionId,
node_id: node.id,
node_type: nodeType,
name: node.name,
element: node.element ?? null,
gate: node.gate ?? null,
summary: node.metadata?.summary ?? node.name,
metadata: node.metadata ?? {},
embedding: embeddingText,
}),
}).catch(() => undefined);
}

async function persistEdgeToSupabase(sessionId: string, edge: CreationEdge): Promise<void> {
const config = getSupabaseConfig();
if (!config) return;

await fetch(`${config.url}/rest/v1/world_graph_edges`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"apikey": config.serviceRoleKey,
"Prefer": "resolution=merge-duplicates,return=minimal",
},
body: JSON.stringify({
session_id: sessionId,
edge_id: edge.id,
source_node_id: edge.sourceId,
target_node_id: edge.targetId,
relationship: edge.relationship,
strength: edge.strength,
metadata: edge.metadata ?? {},
}),
}).catch(() => undefined);
}

function getOrCreateGraph(sessionId: string): CreationGraph {
loadGraphsFromSisStorage();
if (!graphs.has(sessionId)) {
graphs.set(sessionId, {
nodes: new Map(),
Expand All @@ -69,15 +239,20 @@ export function addCreationToGraph(
name: creation.name,
element: creation.element,
gate: creation.gate,
createdAt: creation.createdAt,
createdAt: creation.createdAt instanceof Date ? creation.createdAt : new Date(creation.createdAt),
metadata,
embedding: buildWorldGraphEmbedding(
`${creation.name} ${creation.summary} ${creation.element ?? ""}`
),
};

graph.nodes.set(creation.id, node);

// Auto-detect relationships based on shared properties
autoLinkByElement(sessionId, node);
autoLinkByGate(sessionId, node);
syncGraphsToSisStorage();
void persistNodeToSupabase(sessionId, node);

return node;
}
Expand Down Expand Up @@ -119,6 +294,9 @@ export function linkCreations(
graph.edges.push(edge);
}

syncGraphsToSisStorage();
void persistEdgeToSupabase(sessionId, edge);

return edge;
}

Expand Down Expand Up @@ -297,6 +475,44 @@ export function getGraphSummary(sessionId: string): {
};
}

export function runGuardianSafetyCheck(sessionId: string): GuardianSafetyReport {
const graph = getOrCreateGraph(sessionId);
const issues: GuardianSafetyIssue[] = [];

for (const node of graph.nodes.values()) {
if (!node.name.trim()) {
issues.push({ severity: "error", message: `Node ${node.id} is missing a valid name.` });
}
if (node.gate !== undefined && (node.gate < 1 || node.gate > 10)) {
issues.push({
severity: "error",
message: `Node ${node.name} has gate ${node.gate}. Gate must be between 1 and 10.`,
});
}
}

for (const edge of graph.edges) {
if (edge.sourceId === edge.targetId) {
issues.push({
severity: "warning",
message: `Edge ${edge.id} links a creation to itself. Review this relationship.`,
});
}
if (edge.relationship === "opposes" && edge.strength > 0.95) {
issues.push({
severity: "warning",
message: `Edge ${edge.id} is highly adversarial (${edge.strength}). Guardian review recommended.`,
});
}
}

const hasError = issues.some((issue) => issue.severity === "error");
return {
status: hasError ? "error" : issues.length > 0 ? "warning" : "safe",
issues,
};
}

// Export graph as JSON for visualization
export function exportGraph(sessionId: string): {
nodes: CreationNode[];
Expand Down
24 changes: 23 additions & 1 deletion packages/arcanea-mcp/tests/mcp-server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { describe, it, before } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync } from 'node:fs';

// ─── Helpers ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -685,7 +686,8 @@ describe('Validate — validateCanon', () => {

describe('Creation Graph — CRUD and relationships', () => {
let addCreationToGraph, linkCreations, getRelatedCreations,
suggestConnections, getGraphSummary, exportGraph, findPath;
suggestConnections, getGraphSummary, exportGraph, findPath,
runGuardianSafetyCheck, buildWorldGraphEmbedding, getSisWorldGraphFilePath;

// Use a unique session prefix per test run to avoid cross-test pollution
const SESSION = `test-session-${Date.now()}`;
Expand All @@ -694,6 +696,7 @@ describe('Creation Graph — CRUD and relationships', () => {
({
addCreationToGraph, linkCreations, getRelatedCreations,
suggestConnections, getGraphSummary, exportGraph, findPath,
runGuardianSafetyCheck, buildWorldGraphEmbedding, getSisWorldGraphFilePath,
} = await import('../dist/tools/creation-graph.js'));
});

Expand Down Expand Up @@ -821,6 +824,25 @@ describe('Creation Graph — CRUD and relationships', () => {
const path = findPath(SESSION, 'char-1', 'char-1');
assert.deepEqual(path, []);
});

it('buildWorldGraphEmbedding returns deterministic 64-d vectors', () => {
const a = buildWorldGraphEmbedding('Ember Citadel');
const b = buildWorldGraphEmbedding('Ember Citadel');
assert.equal(a.length, 64);
assert.deepEqual(a, b);
});

it('runGuardianSafetyCheck reports safe status for valid graph state', () => {
const safety = runGuardianSafetyCheck(SESSION);
assert.equal(safety.status, 'safe');
assert.equal(safety.issues.length, 0);
});

it('world graph writes SIS sync file under ~/.arcanea', () => {
const filePath = getSisWorldGraphFilePath();
assert.ok(filePath.includes('.arcanea'));
assert.ok(existsSync(filePath), 'SIS world graph file should exist after graph mutations');
});
});


Expand Down
Loading