From 9aa9035d7895436eba58471f8892c2fbd12700ad Mon Sep 17 00:00:00 2001 From: Albert Date: Sun, 9 Nov 2025 18:26:39 +0000 Subject: [PATCH] fix: Correct OAuth localhost/127.0.0.1 config and fix grapheme counting for Bluesky posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed OAuth client configuration to properly use localhost for client_id and 127.0.0.1 for redirect_uris per RFC 8252 and ATproto spec - Added proper grapheme counting using RichText API instead of character length - Fixed thread splitting to account for link suffix and thread indicators in grapheme limits - Added GOOGLE_EMBEDDING_DIMENSIONS env var to all env files - Added clear-nodes.ts utility script for database management - Added galaxy node detail page route 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .example.env | 2 + app/api/galaxy/route.ts | 5 +- app/api/nodes/route.ts | 152 +++++++++++++++--- app/api/suggest-links/route.ts | 18 ++- app/chat/page.tsx | 18 +-- app/edit/page.tsx | 7 +- app/galaxy/[node-id]/page.tsx | 28 ++++ app/galaxy/page.tsx | 4 +- components/Navigation/DesktopSidebar.tsx | 7 +- components/ThoughtGalaxy.tsx | 193 +++++++++++++++++++---- components/UserMenu.tsx | 74 +++++++-- lib/ai.ts | 10 ++ lib/auth/oauth-client.ts | 14 +- scripts/clear-nodes.ts | 23 +++ 14 files changed, 453 insertions(+), 102 deletions(-) create mode 100644 app/galaxy/[node-id]/page.tsx create mode 100644 scripts/clear-nodes.ts diff --git a/.example.env b/.example.env index ff6d714..7c6c28c 100644 --- a/.example.env +++ b/.example.env @@ -11,6 +11,8 @@ SURREALDB_JWT_SECRET=your-secret-key-here-change-in-production # Google AI API Key (for Gemini embeddings and chat) GOOGLE_GENERATIVE_AI_API_KEY=your-google-ai-api-key GOOGLE_AI_MODEL=gemini-pro-latest +GOOGLE_EMBEDDING_MODEL=gemini-embedding-001 +GOOGLE_EMBEDDING_DIMENSIONS=3072 # Deepgram API Key (for voice-to-text) DEEPGRAM_API_KEY=your-deepgram-api-key diff --git a/app/api/galaxy/route.ts b/app/api/galaxy/route.ts index c4b5234..ed82065 100644 --- a/app/api/galaxy/route.ts +++ b/app/api/galaxy/route.ts @@ -6,6 +6,9 @@ import { verifySurrealJwt } from '@/lib/auth/jwt'; interface NodeData { id: string; title: string; + body: string; + user_did: string; + atp_uri: string; coords_3d: [number, number, number]; } @@ -41,7 +44,7 @@ export async function GET(request: NextRequest) { // Fetch nodes that have 3D coordinates const nodesQuery = ` - SELECT id, title, coords_3d + SELECT id, title, body, user_did, atp_uri, coords_3d FROM node WHERE user_did = $userDid AND coords_3d != NONE `; diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts index 7e2e33c..a499ad6 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -42,6 +42,10 @@ export async function POST(request: NextRequest) { const createdAt = new Date().toISOString(); + // Generate a unique node ID upfront (we'll use this for the detail page link) + const nodeId = `node:${crypto.randomUUID()}`; + const detailUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://ponderants.app'}/galaxy/${encodeURIComponent(nodeId)}`; + // --- Step 1: Write to Source of Truth (ATproto) --- let atp_uri: string; let atp_cid: string; @@ -60,41 +64,137 @@ export async function POST(request: NextRequest) { console.log('[POST /api/nodes] Successfully restored OAuth session and created agent'); // Bluesky posts are limited to 300 graphemes - // Format a concise post with title and truncated body - const maxLength = 280; // Leave room for ellipsis + // We'll create a thread for longer content const fullText = `${title}\n\n${body}`; - let postText: string; - if (fullText.length <= maxLength) { - postText = fullText; - } else { - // Truncate at word boundary - const truncated = fullText.substring(0, maxLength); - const lastSpace = truncated.lastIndexOf(' '); - postText = truncated.substring(0, lastSpace > 0 ? lastSpace : maxLength) + '...'; + // Helper to count graphemes using RichText + function getGraphemeLength(text: string): number { + const rt = new RichText({ text }); + return rt.graphemeLength; } - // Format the text as RichText to detect links, mentions, etc. - const rt = new RichText({ text: postText }); - await rt.detectFacets(agent); + // Reserve space for the detail page link in first post + const linkSuffix = `\n\nRead more: ${detailUrl}`; + const linkGraphemes = getGraphemeLength(linkSuffix); - // Create the ATproto record using standard Bluesky post collection - // This works with OAuth scope 'atproto' without requiring granular permissions - const response = await agent.api.com.atproto.repo.createRecord({ - repo: userDid, - collection: 'app.bsky.feed.post', - record: { + // For first post, we need room for the content + link + const firstPostMaxGraphemes = 300 - linkGraphemes - 5; // 5 char buffer + + // For subsequent thread posts, we need room for the thread indicator like "(2/3) " + const threadIndicatorMaxGraphemes = 10; // Max graphemes for "(99/99) " + const threadPostMaxGraphemes = 300 - threadIndicatorMaxGraphemes - 5; // 5 char buffer + + // Helper function to split text into chunks at word boundaries using grapheme counting + function splitIntoChunks(text: string, firstMaxGraphemes: number, otherMaxGraphemes: number): string[] { + const chunks: string[] = []; + let remainingText = text; + let isFirst = true; + + while (remainingText.length > 0) { + const maxGraphemes = isFirst ? firstMaxGraphemes : otherMaxGraphemes; + const graphemeCount = getGraphemeLength(remainingText); + + if (graphemeCount <= maxGraphemes) { + chunks.push(remainingText); + break; + } + + // Find last space within maxGraphemes + let testText = remainingText; + + // Binary search for the right split point + while (getGraphemeLength(testText) > maxGraphemes) { + const lastSpace = testText.lastIndexOf(' '); + if (lastSpace === -1 || lastSpace < testText.length * 0.5) { + // No good space found, just hard cut at character boundary + // Start from the end and work backwards + testText = testText.substring(0, Math.floor(testText.length * 0.9)); + } else { + testText = testText.substring(0, lastSpace); + } + } + + chunks.push(testText.trim()); + remainingText = remainingText.substring(testText.length).trim(); + isFirst = false; + } + + return chunks; + } + + let chunks: string[]; + if (getGraphemeLength(fullText) <= firstPostMaxGraphemes) { + // Single post + chunks = [fullText]; + } else { + // Split into thread, accounting for link on first post and thread indicators on others + chunks = splitIntoChunks(fullText, firstPostMaxGraphemes, threadPostMaxGraphemes); + } + + // Create the thread posts + let previousPost: { uri: string; cid: string } | null = null; + const threadUris: string[] = []; + + for (let i = 0; i < chunks.length; i++) { + const isFirstPost = i === 0; + let postText = chunks[i]; + + // Add thread indicator if not first/last post + if (chunks.length > 1 && !isFirstPost) { + postText = `(${i + 1}/${chunks.length}) ${postText}`; + } + + // Add detail page link to first post + if (isFirstPost) { + postText += linkSuffix; + } + + // Validate grapheme count before posting + const finalGraphemes = getGraphemeLength(postText); + if (finalGraphemes > 300) { + console.error(`[POST /api/nodes] Post ${i + 1} exceeds 300 graphemes (${finalGraphemes})`); + throw new Error(`Post exceeds 300 grapheme limit: ${finalGraphemes} graphemes`); + } + + // Format the text as RichText to detect links, mentions, etc. + const rt = new RichText({ text: postText }); + await rt.detectFacets(agent); + + // Prepare the post record + const postRecord: any = { $type: 'app.bsky.feed.post', text: rt.text, facets: rt.facets, - createdAt, - // Add a tag to identify this as a Ponderants node + createdAt: new Date().toISOString(), tags: ['ponderants-node'], - }, - }); + }; - atp_uri = response.data.uri; - atp_cid = response.data.cid; + // If not first post, add reply reference + if (previousPost && threadUris.length > 0) { + // Get the first post (root) CID from the stored thread data + const rootCid = atp_cid; // First post's CID + postRecord.reply = { + root: { uri: threadUris[0], cid: rootCid }, + parent: { uri: previousPost.uri, cid: previousPost.cid }, + }; + } + + // Create the post + const response = await agent.api.com.atproto.repo.createRecord({ + repo: userDid, + collection: 'app.bsky.feed.post', + record: postRecord, + }); + + threadUris.push(response.data.uri); + previousPost = { uri: response.data.uri, cid: response.data.cid }; + + // Store the first post's URI as the main atp_uri + if (isFirstPost) { + atp_uri = response.data.uri; + atp_cid = response.data.cid; + } + } console.log('[POST /api/nodes] ✓ Published to ATproto PDS as standard post:', atp_uri); } catch (error) { @@ -135,7 +235,7 @@ export async function POST(request: NextRequest) { nodeData.embedding = embedding; } - const newNode = await db.create('node', nodeData); + const newNode = await db.create(nodeId, nodeData); // Handle linking if (links && links.length > 0) { diff --git a/app/api/suggest-links/route.ts b/app/api/suggest-links/route.ts index 3d616a2..4d0f439 100644 --- a/app/api/suggest-links/route.ts +++ b/app/api/suggest-links/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { connectToDB } from '@/lib/db'; -import { generateEmbedding } from '@/lib/ai'; +import { generateEmbedding, EMBEDDING_DIMENSIONS } from '@/lib/ai'; import { verifySurrealJwt } from '@/lib/auth/jwt'; /** @@ -36,6 +36,19 @@ export async function POST(request: NextRequest) { try { // 1. Generate embedding for the current draft const draftEmbedding = await generateEmbedding(body); + console.log('[Suggest Links] Draft embedding dimension:', draftEmbedding.length); + + // Validate that the embedding has the expected dimensions + if (draftEmbedding.length !== EMBEDDING_DIMENSIONS) { + console.error('[Suggest Links] Embedding dimension mismatch:', { + expected: EMBEDDING_DIMENSIONS, + actual: draftEmbedding.length, + }); + return NextResponse.json( + { error: `Embedding dimension mismatch: expected ${EMBEDDING_DIMENSIONS}, got ${draftEmbedding.length}` }, + { status: 500 } + ); + } // 2. Connect to DB with root credentials const db = await connectToDB(); @@ -44,6 +57,7 @@ export async function POST(request: NextRequest) { // This query finds the 5 closest nodes in the 'node' table // using cosine similarity on the 'embedding' field. // We filter by user_did to ensure users only see their own nodes. + // Note: All embeddings in the DB should be EMBEDDING_DIMENSIONS length const query = ` SELECT id, @@ -52,7 +66,7 @@ export async function POST(request: NextRequest) { atp_uri, vector::similarity::cosine(embedding, $draft_embedding) AS score FROM node - WHERE user_did = $user_did + WHERE user_did = $user_did AND embedding != NONE ORDER BY score DESC LIMIT 5; `; diff --git a/app/chat/page.tsx b/app/chat/page.tsx index d88a9e4..0146459 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -248,9 +248,9 @@ export default function ChatPage() { }} w="80%" > - - {m.role === 'user' ? 'You' : 'AI'} - + + {m.role === 'user' ? 'YOU' : 'INQUISITOR'} + {(() => { if ('parts' in m && Array.isArray((m as any).parts)) { return (m as any).parts.map((part: any, i: number) => { @@ -279,9 +279,9 @@ export default function ChatPage() { style={{ alignSelf: 'flex-start', backgroundColor: '#212529' }} w="80%" > - - AI - + + INQUISITOR + @@ -301,9 +301,9 @@ export default function ChatPage() { style={{ alignSelf: 'flex-end', backgroundColor: '#343a40' }} w="80%" > - - You (speaking...) - + + YOU (speaking...) + {transcript} )} diff --git a/app/edit/page.tsx b/app/edit/page.tsx index 7973d5b..b2d409d 100644 --- a/app/edit/page.tsx +++ b/app/edit/page.tsx @@ -21,9 +21,10 @@ import { Checkbox, Badge, Loader, + Alert, } from '@mantine/core'; import { useState, useEffect } from 'react'; -import { IconDeviceFloppy, IconX, IconRefresh } from '@tabler/icons-react'; +import { IconDeviceFloppy, IconX, IconRefresh, IconInfoCircle } from '@tabler/icons-react'; import { useAppMachine } from '@/hooks/useAppMachine'; import { useSelector } from '@xstate/react'; import { notifications } from '@mantine/notifications'; @@ -197,6 +198,10 @@ export default function EditPage() { + } title="Public Node" color="blue" variant="light"> + All nodes published through Ponderants are publicly visible to everyone. Your thoughts will be posted to Bluesky and visible in the public knowledge graph. + + ; +} + +export default function NodeDetailPage({ params }: NodeDetailPageProps) { + const { 'node-id': nodeId } = use(params); + + return ( + + {/* R3F Canvas for the 3D visualization, focused on specific node */} + + Loading your thought galaxy... + + }> + + + + ); +} diff --git a/app/galaxy/page.tsx b/app/galaxy/page.tsx index 5e14352..0013f66 100644 --- a/app/galaxy/page.tsx +++ b/app/galaxy/page.tsx @@ -6,10 +6,10 @@ import { ThoughtGalaxy } from '@/components/ThoughtGalaxy'; export default function GalaxyPage() { return ( - + {/* R3F Canvas for the 3D visualization */} + Loading your thought galaxy... }> diff --git a/components/Navigation/DesktopSidebar.tsx b/components/Navigation/DesktopSidebar.tsx index c247f79..0df34da 100644 --- a/components/Navigation/DesktopSidebar.tsx +++ b/components/Navigation/DesktopSidebar.tsx @@ -111,11 +111,8 @@ export function DesktopSidebar() { - } - variant="filled" - /> + {/* User Menu - styled like other nav items */} + {/* Development state panel */} {process.env.NODE_ENV === 'development' && ( diff --git a/components/ThoughtGalaxy.tsx b/components/ThoughtGalaxy.tsx index 6d3bc32..b265894 100644 --- a/components/ThoughtGalaxy.tsx +++ b/components/ThoughtGalaxy.tsx @@ -7,13 +7,17 @@ import { Text, } from '@react-three/drei'; import { Suspense, useEffect, useRef, useState } from 'react'; -import { Stack, Text as MantineText } from '@mantine/core'; +import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor } from '@mantine/core'; +import { useRouter, usePathname } from 'next/navigation'; import * as THREE from 'three'; // Define the shape of nodes and links from API interface NodeData { id: string; title: string; + body?: string; + user_did: string; + atp_uri: string; coords_3d: [number, number, number]; } @@ -23,17 +27,27 @@ interface LinkData { } // 1. The 3D Node Component -function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeData) => void }) { +function Node({ + node, + isFocused, + onNodeClick +}: { + node: NodeData; + isFocused: boolean; + onNodeClick: (node: NodeData) => void; +}) { const [hovered, setHovered] = useState(false); - const [clicked, setClicked] = useState(false); + + const isExpanded = isFocused || hovered; + const scale = isFocused ? 2.5 : 1; return ( { e.stopPropagation(); onNodeClick(node); - setClicked(!clicked); }} onPointerOver={(e) => { e.stopPropagation(); @@ -43,15 +57,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD > - {/* Show title on hover or click */} - {(hovered || clicked) && ( + {/* Show title on hover or focus */} + {isExpanded && ( ([]); const [links, setLinks] = useState([]); + const [selectedNode, setSelectedNode] = useState(null); const cameraControlsRef = useRef(null); const hasFitCamera = useRef(false); + const hasFocusedNode = useRef(null); // Fetch data from API on mount and poll for updates useEffect(() => { @@ -148,14 +166,42 @@ export function ThoughtGalaxy() { // Fit camera when nodes change and we haven't fitted yet useEffect(() => { - if (!hasFitCamera.current && nodes.length > 0) { + if (!hasFitCamera.current && nodes.length > 0 && !focusNodeId) { + // Only auto-fit if we're not focusing on a specific node // Try to fit after a short delay to ensure Canvas is ready const timer = setTimeout(() => { fitCameraToNodes(); }, 100); return () => clearTimeout(timer); } - }, [nodes]); + }, [nodes, focusNodeId]); + + // Auto-focus on specific node if focusNodeId is provided + useEffect(() => { + if (focusNodeId && nodes.length > 0) { + const focusNode = nodes.find((n) => n.id === focusNodeId); + if (focusNode) { + console.log('[ThoughtGalaxy] Focusing on node:', focusNodeId); + + // Always update selected node when focusNodeId changes (don't wait for camera ref) + setSelectedNode(focusNode); + + // Move camera if ref is available and we haven't focused this specific node yet + if (cameraControlsRef.current && (!hasFocusedNode.current || hasFocusedNode.current !== focusNodeId)) { + cameraControlsRef.current.setLookAt( + focusNode.coords_3d[0], + focusNode.coords_3d[1], + focusNode.coords_3d[2] + 2, // Position camera 2 units in front + focusNode.coords_3d[0], + focusNode.coords_3d[1], + focusNode.coords_3d[2], + hasFocusedNode.current ? true : false // Animate if not initial load + ); + hasFocusedNode.current = focusNodeId; + } + } + } + }, [focusNodeId, nodes]); // Map links to node positions const linkLines = links @@ -174,14 +220,30 @@ export function ThoughtGalaxy() { // Camera animation on node click const handleNodeClick = (node: NodeData) => { - if (cameraControlsRef.current) { - // Smoothly move to look at the clicked node - cameraControlsRef.current.moveTo( - node.coords_3d[0], - node.coords_3d[1], - node.coords_3d[2], - true // Animate - ); + const targetPath = `/galaxy/${encodeURIComponent(node.id)}`; + + // Set selected node immediately for responsive UI + setSelectedNode(node); + + // Only navigate if we're not already on this node's page + if (pathname !== targetPath) { + // Clear the focused node ref to ensure camera animates on next render + hasFocusedNode.current = null; + // Use replace instead of push to avoid page reload issues + router.replace(targetPath); + } else { + // Already on this page, just animate camera to node + if (cameraControlsRef.current) { + cameraControlsRef.current.setLookAt( + node.coords_3d[0], + node.coords_3d[1], + node.coords_3d[2] + 2, + node.coords_3d[0], + node.coords_3d[1], + node.coords_3d[2], + true // Animate + ); + } } }; @@ -202,18 +264,73 @@ export function ThoughtGalaxy() { } return ( - { - console.log('[ThoughtGalaxy] Canvas created successfully'); - // Try to fit camera now that scene is ready - if (!hasFitCamera.current && nodes.length > 0) { - setTimeout(() => fitCameraToNodes(), 50); - } - }} - > + <> + {/* Floating content overlay for selected node */} + {selectedNode && ( + + + + + + {selectedNode.title} + + + View on Bluesky + + + setSelectedNode(null)} + aria-label="Close node details" + style={{ flexShrink: 0 }} + /> + + {selectedNode.body && ( + + + {selectedNode.body} + + + )} + + + )} + + { + console.log('[ThoughtGalaxy] Canvas created successfully'); + // Try to fit camera now that scene is ready + if (!hasFitCamera.current && nodes.length > 0) { + setTimeout(() => fitCameraToNodes(), 50); + } + }} + > @@ -222,7 +339,12 @@ export function ThoughtGalaxy() { {/* Render all nodes */} {nodes.map((node) => ( - + ))} {/* Render all links */} @@ -237,5 +359,6 @@ export function ThoughtGalaxy() { + ); } diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx index 8db1eba..ae23231 100644 --- a/components/UserMenu.tsx +++ b/components/UserMenu.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Menu, Avatar, UnstyledButton, Group, Text } from '@mantine/core'; +import { Menu, Avatar, NavLink, ActionIcon } from '@mantine/core'; import { useRouter } from 'next/navigation'; interface UserProfile { @@ -11,7 +11,7 @@ interface UserProfile { avatar: string | null; } -export function UserMenu() { +export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) { const router = useRouter(); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); @@ -43,10 +43,30 @@ export function UserMenu() { }; if (loading || !profile) { - return ( - - ? - + return showLabel ? ( + + ? + + } + variant="filled" + color="blue" + styles={{ + root: { + borderRadius: '8px', + fontWeight: 400, + }, + }} + disabled + /> + ) : ( + + + ? + + ); } @@ -65,29 +85,49 @@ export function UserMenu() { return ( - - + {showLabel ? ( + + {initials} + + } + variant="filled" + color="blue" + styles={{ + root: { + borderRadius: '8px', + fontWeight: 400, + }, + }} + /> + ) : ( + {initials} - - + + )} - - {displayText} - - + {displayText} +
+ @{profile.handle} -
+
diff --git a/lib/ai.ts b/lib/ai.ts index 036fc76..1f547a9 100644 --- a/lib/ai.ts +++ b/lib/ai.ts @@ -9,12 +9,22 @@ if (!process.env.GOOGLE_EMBEDDING_MODEL) { throw new Error('GOOGLE_EMBEDDING_MODEL environment variable is required (e.g., gemini-embedding-001)'); } +if (!process.env.GOOGLE_EMBEDDING_DIMENSIONS) { + throw new Error('GOOGLE_EMBEDDING_DIMENSIONS environment variable is required (e.g., 3072)'); +} + const genAI = new GoogleGenerativeAI(process.env.GOOGLE_GENERATIVE_AI_API_KEY); const embeddingModel = genAI.getGenerativeModel({ model: process.env.GOOGLE_EMBEDDING_MODEL, }); +/** + * The expected dimension size for embeddings from the configured model. + * This must match the actual output dimension of the embedding model. + */ +export const EMBEDDING_DIMENSIONS = parseInt(process.env.GOOGLE_EMBEDDING_DIMENSIONS, 10); + /** * Generates a vector embedding for a given text using the configured Google embedding model. * diff --git a/lib/auth/oauth-client.ts b/lib/auth/oauth-client.ts index 1b840ce..f2be72d 100644 --- a/lib/auth/oauth-client.ts +++ b/lib/auth/oauth-client.ts @@ -30,12 +30,17 @@ export async function getOAuthClient(): Promise { const isDev = process.env.NODE_ENV === 'development'; const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; - const callbackUrl = `${appUrl}/api/auth/callback`; + + // Per RFC 8252 and ATproto OAuth spec: + // - client_id must use 'localhost' hostname (NOT an IP) + // - redirect_uris must use '127.0.0.1' loopback IP (NOT 'localhost') + const callbackUrl = isDev + ? 'http://127.0.0.1:3000/api/auth/callback' + : `${appUrl}/api/auth/callback`; if (isDev) { - // Development: Use localhost loopback client - // Per ATproto spec, we encode metadata in the client_id query params - // Request 'transition:generic' scope for repository write access + // Development: Use localhost loopback client exception + // Encode metadata in client_id query params as per spec const clientId = `http://localhost/?${new URLSearchParams({ redirect_uri: callbackUrl, scope: 'atproto transition:generic', @@ -43,6 +48,7 @@ export async function getOAuthClient(): Promise { console.log('[OAuth] Initializing development client with loopback exception'); console.log('[OAuth] client_id:', clientId); + console.log('[OAuth] redirect_uri:', callbackUrl); clientInstance = new NodeOAuthClient({ clientMetadata: { diff --git a/scripts/clear-nodes.ts b/scripts/clear-nodes.ts new file mode 100644 index 0000000..8858902 --- /dev/null +++ b/scripts/clear-nodes.ts @@ -0,0 +1,23 @@ +import Surreal from 'surrealdb'; + +async function clearNodes() { + const db = new Surreal(); + + try { + await db.connect('ws://localhost:8000/rpc'); + await db.signin({ username: 'root', password: 'root' }); + await db.use({ namespace: 'ponderants', database: 'main' }); + + console.log('Deleting all nodes...'); + const result = await db.query('DELETE node;'); + console.log('Result:', result); + + console.log('✓ All nodes deleted successfully'); + } catch (error) { + console.error('Error:', error); + } finally { + await db.close(); + } +} + +clearNodes();