Skip to content

Conversation

@jack-arturo
Copy link
Member

This pull request introduces several improvements to the Automem project, most notably adding support for an optional interactive Graph Viewer frontend, improving cross-origin API accessibility, and enhancing the graph API with a new UMAP-based projection endpoint. It also includes build process updates and developer workflow documentation enhancements.

Graph Viewer Integration and API Improvements:

  • Added a new viewer blueprint (automem/api/viewer.py) to serve the optional Graph Viewer React SPA at /viewer/, controlled by the ENABLE_GRAPH_VIEWER environment variable. The Dockerfile and application entrypoint (app.py) were updated to build and serve the frontend when enabled. [1] [2] [3] [4]
  • Implemented a new /graph/projected API endpoint that returns a 3D UMAP-projected layout of memory nodes based on their vector embeddings, supporting semantic visualization of memories. This includes lazy-loading of UMAP for performance and filtering options via query parameters. [1] [2]
  • Increased the maximum number of nodes returned by the /graph/snapshot and /graph/projected endpoints from 2000 to 5000 to support larger graphs.

API Accessibility and Security:

  • Enabled CORS for all API routes, allowing cross-origin requests from the Graph Viewer and other clients. Adjusted authentication middleware to allow unauthenticated access to static viewer assets and CORS preflight requests. [1] [2] [3]

Developer Experience and Build Process:

  • Added a .flake8 configuration file to standardize Python linting rules and exclude unnecessary directories.
  • Updated AGENTS.md with a clear task completion checklist for running builds, lints, and tests before declaring work complete.

Frontend Build Artifacts:

  • Added build output and configuration files for the Graph Viewer frontend to support the new SPA integration. (packages/graph-viewer/.vite/deps/_metadata.json, packages/graph-viewer/.vite/deps/package.json) [1] [2]

jack-arturo and others added 30 commits December 23, 2025 21:10
- Add server URL input field with default Railway URL
- Use setServerConfig to persist both URL and token
- Test connection before saving credentials
- Improve error display with styled alert box

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add flask-cors dependency
- Enable CORS on all routes for cross-origin browser access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Backend:
- Add viewer blueprint to serve React SPA from /viewer/
- Support hash-based token auth (#token=xxx) for embedded mode
- Enable CORS for cross-origin browser access
- Optional via ENABLE_GRAPH_VIEWER env (default: true)

Frontend (packages/graph-viewer/):
- 3D WebGL graph visualization with React Three Fiber
- Force-directed layout using d3-force-3d
- Search, filter, and community isolation features
- Inspector panel for memory details
- Configurable server URL and token

Infrastructure:
- Multi-stage Dockerfile (Node.js build + Python runtime)
- Frontend built during Docker build, not committed to repo

Access at: https://your-server/viewer/#token=YOUR_API_TOKEN

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The viewer serves static files that don't need API auth.
Authentication is handled client-side via URL hash fragment (#token=xxx).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Assets were loading from root (/) instead of /viewer/static/,
causing 401 errors since those paths require authentication.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements Meta Quest-style hand gesture controls using MediaPipe Hands:
- Two-hand spread/pinch for zoom in/out
- Two-hand rotation for orbiting camera
- Two-hand pan for moving view
- Hand skeleton wireframe overlay (cyan left, magenta right)
- Toggle button in header to enable/disable gesture control

Requires camera permission when enabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add GestureDebugOverlay component showing all hand tracking data:
  - FPS counter, tracking status, hands detected
  - Two-hand gesture values (distance, rotation, zoom/rotate/pan deltas)
  - Single-hand gestures (pinch strength, grab strength with progress bars)
  - Pinch ray data (origin, direction, strength) for both hands
  - Key landmark positions for each detected hand

- Implement Meta Quest-style pinch ray (laser pointer):
  - Ray origin at midpoint between thumb and index tips
  - Direction from wrist toward pinch point
  - Visual intensity based on pinch strength
  - Glow sphere and ring at origin when pinch active
  - Dashed line when not fully pinched, solid when engaged

- Integrate debug overlay into App with toggle button:
  - Debug button appears when gestures are enabled
  - Real-time gesture state updates via callback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Create Hand2DOverlay component using SVG for crisp rendering
- Hand appears as screen overlay (not inside 3D scene)
- Scale based on hand depth: closer to camera = larger hand
- Include pinch-to-laser with glow effects
- Mirror X axis to match natural hand movement
- Disable 3D HandSkeletonOverlay in favor of 2D version

The hand now appears life-size against the screen, matching
the user's actual hand position relative to the camera.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove X-axis mirroring so hand points same direction as user's
- Invert depth scaling: closer to camera = smaller (going into screen)
- Laser now points toward center of graph (the nexus)
- Hand shrinks as it "reaches into" the screen toward the memory graph

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
… and two-hand support

- Add position interpolation with SMOOTHING_FACTOR for fluid hand tracking
- Implement ghost effect with configurable fade when hand disappears
- Track both left (cyan) and right (magenta) hands
- Lasers always point toward center nexus
- Grip indicator: laser turns white/bright when pinched
- Connection line between hands when both gripping
- Impact 'warm spot' at center where laser hits
- SVG glow filters for visual effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When both hands are gripping (pinch valid), users can:
- SPREAD hands apart = zoom in (move camera closer)
- PINCH hands together = zoom out
- ROTATE hands around each other = orbit camera
- MOVE both hands together = pan view
- PULL both hands toward camera = dolly in (pull graph closer)
- PUSH both hands away = dolly out

Falls back to wrist-based gestures when hands are detected but not gripping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Performance optimizations implemented:
- Instanced mesh rendering for nodes (1 draw call for all nodes)
- Batched LineSegments for edges (1 draw call for all edges)
- Reduced sphere geometry from 32x32 to 12x12 segments
- LOD labels: only show labels for nearby/selected nodes (max 10)
- Performance mode toggle: disables Bloom/Vignette post-processing
- Single useFrame callback instead of per-node callbacks
- Reusable temp objects to avoid GC pressure
- Frustum culling enabled on instanced mesh

Before: ~100 draw calls, 200 useFrame callbacks, 100k vertices
After: ~3 draw calls, 3 useFrame callbacks, shared geometry

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Disable auto-rotate by default (start still, less disorienting)
- Add BUILD_VERSION indicator in header to verify deployments
- Current version: 2024-12-11-perf-v2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ost hand

UI improvements for hand gesture controls:
- Add gesture smoothing with lerp, deadzone, and max speed clamps
- Implement gentle recentering when not actively manipulating
- Make lasers default toward center (70%) with hand-based deviation (30%)
- Replace skeleton splines with translucent ghost hand effect
- Ghost hand features palm fill, finger paths, gradients, and glow effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Un-mirror hand X coordinates (webcam is mirrored, flip back for natural feel)
- Change gesture from camera manipulation to cloud translation
- Pinch + pull back = pull memory cloud closer toward you
- Spread + push forward = push memory cloud away
- More physically intuitive interaction model

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Gesture improvements:
- Single hand: pinch + pull/push now translates cloud in Z
- Two hands: compound forces create 3D rotation (X and Y axes)
  - Left hand X movement rotates Y, right hand X movement rotates opposite
  - Creates torque effect at laser intersection point
- Both hands contribute to Z translation additively
- Smooth recentering for both position and rotation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Visual improvements:
- Inverted depth scaling: hand closer to camera = smaller (farther in 3D)
- Puffy white glove appearance like Mario/cartoon hands
- Semi-transparent with soft edges and volumetric feel
- Rounded fingertips with highlight dots
- Knuckle bumps and wrist cuff details
- Layered strokes create puffy tube effect on fingers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Reimplemented hand rendering with Smash Bros Master Hand aesthetic:
- Filled volumetric finger shapes (capsule/sausage geometry)
- Proper palm shape connecting finger bases
- Radial gradient from white to soft lavender
- Ambient occlusion shadows in finger creases
- Knuckle definition shadows
- Rim lighting effect on edges
- Specular highlight dots on fingertips
- Soft drop shadow for depth
- Wrist cuff detail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Introduces stable pointer ray with arm model and One Euro Filter for accurate hand-based node selection and manipulation. Adds ExpandedNodeView for animated node expansion, LaserPointer for 3D laser visualization, and useHandInteraction hook for gesture-driven graph interaction. Updates GraphCanvas and Hand2DOverlay to support pinch-to-select, Z manipulation, two-hand rotation/zoom, and visual hit indicators.
Introduces a new WebSocket server for iPhone hand tracking with web visualization in packages/graph-viewer/scripts. Refines gesture and pointer logic in GraphCanvas, useHandInteraction, and related components for more accurate hand-based interactions. Updates AGENTS.md with a task completion checklist. Removes unused code and types for clarity.
Introduces a new hand lock and grab state machine for intentional gesture-based cloud manipulation. Adds the useHandLockAndGrab hook, HandControlOverlay UI, and integrates these into App and GraphCanvas. Updates useHandInteraction to allow disabling pinch-to-select when using grab controls, and enhances iPhone hand tracking to compute grab strength. This enables more robust and user-friendly hand gesture interactions for zooming and rotating the graph.
Updated Vite configuration to allow dynamic API target via environment variable, improving flexibility for local and production environments. Adjusted Hand2DOverlay to include hand lock state for visual feedback during interactions. Enhanced GraphCanvas with new aiming and pinch-click features, refining user experience for gesture-based interactions. Added support for URL parameters to override server settings for local development, streamlining testing against remote backends.
Adds a dev:all launcher for starting the iPhone bridge + Vite together, and upgrades the UI/bridge status reporting (browser↔bridge, phone↔bridge, LiDAR availability, ports/IPs) for easier debugging.
Plan to improve memory display and organization while keeping hand
gesture features. Key additions:
- Obsidian-style settings panel (filters, display, forces, clustering)
- Multi-layer relationship visualization with styled edges
- Smart clustering (by type, tags, or semantic similarity)
- Enhanced selection focus mode with context highlighting
- Keyboard shortcuts supplementing gesture navigation

Research sources included for graph visualization best practices.
Sprint 1 implementation:
- Add SettingsPanel with collapsible sections (Filters, Relationships,
  Display, Clustering, Forces)
- Add SliderControl and ToggleControl UI components
- Add ForceConfig, DisplayConfig, ClusterConfig types
- Wire force configuration to useForceLayout hook
- Add reheat button to restart force simulation
- Settings panel docked to right side with toggle button

This provides real-time control over graph visualization parameters
including node size, link thickness, force strengths, and filtering.
Sprint 2 implementation:
- Add edge styles with distinct colors per relationship type
  - Causal (blue): LEADS_TO, EVOLVED_INTO, DERIVED_FROM
  - Temporal (orange/gray): OCCURRED_BEFORE, INVALIDATED_BY
  - Associative (green): EXEMPLIFIES, REINFORCES, RELATES_TO
  - Conflict (red): CONTRADICTS
  - Hierarchical (violet/slate): PREFERS_OVER, PART_OF
- Filter edges by relationship type visibility
- Apply link thickness and opacity from display settings
- Apply node size scale from display settings
- Toggle label visibility and configure fade distance

Display settings now control:
- Edge colors based on relationship type
- Edge visibility per relationship type
- Link thickness and opacity
- Node size scaling
- Label visibility and fade distance
- Add useClusterDetection hook for detecting clusters by type, tags, or semantic similarity
- Add ClusterBoundaries component rendering dotted spheres around clusters
- Integrate clustering config through GraphCanvas to Scene
- Support three clustering modes: type (memory type), tags (first tag), semantic (connected components)
- Boundaries gently rotate for visual interest
- Fibonacci sphere point distribution for even dotted effect
- Add SelectionHighlight component with glowing ring around selected node
- Add ConnectedPathsHighlight with flowing particles along edges
- Pulsing animation on selection ring for visual feedback
- Three-ring display (XY and XZ planes) for 3D depth perception
- Particles flow from selected node to connected nodes
- Track selected node layout position for accurate highlight placement
claude and others added 16 commits December 23, 2025 21:59
- Add useKeyboardNavigation hook for Obsidian-style keyboard shortcuts
- Arrow keys navigate between nodes spatially (up/down/left/right)
- Shift+Arrow up/down for Z-axis navigation (forward/backward)
- Tab/Shift+Tab cycles through nodes sequentially
- Escape to deselect, R to reheat, L to toggle labels
- Comma to toggle settings panel
- Question mark shows help in console
- Ignores input when focus is in text fields
Added the 'ws' package to dependencies and updated npm scripts to use 'npx' for Vite commands in package.json for improved compatibility.
Introduces a UI toggle to switch between MediaPipe (webcam) and iPhone hand tracking sources, with initial source determined by URL parameters. Refactors tracking source state management, updates related props, and improves MediaPipe hand gesture cleanup to prevent errors during component unmount.
Revised iPhone hand landmark keys to match Vision framework abbreviations and updated all related calculations to use new keys. Added debug logging for tracking source changes, incoming iPhone messages, and landmark keys. Improved LiDAR depth normalization for MediaPipe compatibility and enhanced error handling and message logging in the WebSocket connection.
Refactors hand gesture controls to use displacement-based panning and depth for more intuitive graph manipulation, replacing velocity-based movement. Adds a 'Reset View' button to center the graph and reset rotation, with support for this callback throughout the app. Enhances hand overlay visuals with activation flash, improved opacity logic, and more accurate laser/pointing detection. Updates hand lock and grab logic for more robust pose detection and longer lock persistence.
Introduces several new interactive features to the graph viewer, including a bookmarks panel for saving and restoring camera positions, lasso selection for bulk node actions, animated edge particles, a pathfinding overlay, a timeline bar for time travel, a radial menu for node actions, and a tag cloud for filtering. Updates App.tsx and GraphCanvas.tsx to integrate these features and their state management, and adds supporting hooks and components. Also improves keyboard navigation and sound effects integration.
Introduces a new useHandCursor hook for simplified hand cursor tracking, replacing complex pointing and pinch logic in GraphCanvas. Refactors force layout logic in useForceLayout to use a module-level cache for improved stability and performance, and updates related components for more stable data references and improved event handling. Also improves lasso overlay usability and hand overlay depth feedback.
Simplifies hand gesture controls to only support fist grab for panning the graph. Removes hand cursor, laser pointer, expanded node selection, and related overlays and hooks. Updates overlays to provide only basic visual hand feedback without lasers or node selection.
Enhances hand tracking by refining hand lock acquisition, adding depth-aware pointing and pinch detection, and improving overlay visualization for both MediaPipe and iPhone LiDAR sources. The commit introduces more intentional hand lock gating, depth-based pointing heuristics, and visual feedback for hand state and depth. It also fixes candidate hand tracking, improves inertial panning, and updates the debug overlay to show world Z in meters.
Introduces hooks for recording and replaying hand gesture data, enabling automated testing and playback of hand interactions. Updates gesture state to include pinchPoint for direct pinch selection, refactors selection logic to use pinchPoint, and adds visual feedback for pinch pre-select. UI now displays recording and playback indicators, and exposes global automation APIs in test mode.
Introduces two-hand pinch gesture for pan/zoom/rotate in the graph viewer, with visual feedback and bimanual state tracking. Updates gesture handling hooks to support bimanual pinch, selection clearing via open palm, and consistent coordinate mirroring for both MediaPipe and iPhone hand tracking. Also updates UI overlays and node dimming for improved clarity. Adds ESLint and Flake8 config files for code quality.
Refines cluster detection to prioritize entity tags and group nodes by shared tags, with improved logic for tag selection. Lowers the semantic clustering edge strength threshold for tighter clusters. Updates force layout to cluster nodes by primary tag with deterministic positioning, and adjusts default force simulation parameters for denser, more cohesive clusters.
Introduces a new /graph/projected API endpoint in the backend that uses UMAP to project memory embeddings into 3D space, enabling semantic clustering of nodes. Updates the frontend to support toggling UMAP-based layouts, fetches projected positions, and bypasses force simulation when server-provided positions are available. Adds related UI controls, types, and caching logic. Also documents the implementation plan and adds umap-learn to requirements.
Adds server-side UMAP dimensionality reduction to position nodes by embedding
similarity rather than just tags/edges. Semantically related memories now
cluster together regardless of explicit relationships.

Backend:
- Add /graph/projected endpoint with UMAP 3D projection
- Lazy-load umap-learn to avoid slow startup
- Scale projections to 100-unit visualization range
- Add umap-learn>=0.5.5 to requirements.txt

Frontend:
- Add UMAP Layout toggle in Clustering settings
- New useProjectedGraph hook with 5-min cache
- useForceLayout skips simulation when server positions provided
- Show info banner when UMAP mode active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Backend and frontend now support up to 5000 nodes. Instanced rendering
handles this fine - bottleneck is UMAP computation time (~15-30s for 5k).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds WebXR/Quest VR support to the graph viewer using @react-three/xr, including a VR entry button and XR store. Refactors force layout seeding to support clustering by tags or type, and improves deterministic node placement. Updates API client to use a new default server URL and switches to API key in query string for requests. Also adds a 'Reset View' button to the UI and enhances CORS handling in the backend to support custom headers and preflight requests.
@coderabbitai
Copy link

coderabbitai bot commented Jan 5, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Full Graph Viewer SPA: 2D/3D visualization with UMAP projections, clustering, force layout, bookmarking, tag cloud, lasso selection, pathfinding, timeline/time-travel, inspector/expanded node views, hand-tracking controls, recording/playback, overlays and sound effects.
  • API

    • New projection endpoint returning 3D UMAP coordinates and metadata; optional viewer mount served at /viewer; CORS enabled for API.
  • Documentation

    • Visualizer roadmap and contributor checklist added.
  • Chores

    • Docker multi-stage frontend build, updated ignore patterns, lint/config files, and runtime dependency additions.

✏️ Tip: You can customize this high-level summary in your review settings.

Walkthrough

Adds a new Graph Viewer frontend (React + TypeScript + Three.js) with many UI components, hooks, and hand-gesture tooling; extends the Flask backend with CORS, a /viewer blueprint, and a new /graph/projected UMAP projection endpoint; adds Docker multi-stage frontend build and lint/build configs.

Changes

Cohort / File(s) Summary
Backend: app + graph + viewer
app.py, automem/api/graph.py, automem/api/viewer.py
Enable CORS, register optional /viewer blueprint, add lazy UMAP import and new GET /graph/projected endpoint that queries embeddings, runs 3D UMAP, normalizes coords, and returns nodes/edges + projection metadata.
Backend deps & Docker
requirements.txt, Dockerfile, .gitignore
Add umap-learn and flask-cors; update Dockerfile to multi-stage build that builds the graph-viewer and copies artifacts to /automem/static/viewer/; add graph-viewer build/node ignores to .gitignore.
Project config & docs
.flake8, AGENTS.md, VISUALIZER_SIMPLIFICATION_PLAN.md, packages/graph-viewer/IMPLEMENTATION_PLAN.md, CHANGELOG.md
Add Flake8 config, task checklist, visualizer roadmap/implementation plans, and changelog entries describing Graph Viewer items.
Graph-viewer package manifest & tooling
packages/graph-viewer/package.json, vite.config.ts, tsconfig.json, eslint.config.js, postcss.config.js, tailwind.config.js, packages/graph-viewer/.vite/*
New package setup (Vite/React/TS/Tailwind), ESLint/postcss/tailwind configs, Vite dev proxy/alias and deps metadata.
Frontend entry & styles
packages/graph-viewer/index.html, packages/graph-viewer/src/main.tsx, packages/graph-viewer/src/index.css, packages/graph-viewer/src/vite-env.d.ts
Add HTML entry, React + React Query provider, global Tailwind/CSS, and Vite typings.
Top-level app
packages/graph-viewer/src/App.tsx
New App component wiring authentication, data sources (snapshot vs projected), panels, overlays, gestures, bookmarks, pathfinding, time travel, and settings.
API client
packages/graph-viewer/src/api/client.ts
Add environment-aware client, token handling, and new fetchProjectedGraph / fetchGraphSnapshot / neighbors / stats methods and types.
Hooks (data, layout, gestures, utilities)
packages/graph-viewer/src/hooks/* (many files)
Add numerous hooks: useGraphSnapshot/useProjectedGraph/useGraphNeighbors/useGraphStats, useForceLayout, useClusterDetection, useFocusMode, useCameraState/useCameraNavigation, useBookmarks, useLassoSelection, usePathfinding, useTimeTravel, useTagCloud, useKeyboardNavigation, useHandGestures, useIPhoneHandTracking, useHandLockAndGrab, useHandRecording/playback, useHandPlayback, useSoundEffects, etc.
Core canvas & visualization
packages/graph-viewer/src/components/GraphCanvas.tsx
New high-performance 3D GraphCanvas with instanced nodes, batched edges, LOD labels, XR/hand-tracking integration, force layout and many interaction features.
UI components
packages/graph-viewer/src/components/* (BookmarksPanel, Inspector, FilterPanel, SettingsPanel + settings/*, SearchBar, StatsBar, TokenPrompt, MiniMap, TimelineBar, TagCloud, PathfindingOverlay, RadialMenu, SelectionActions, LassoOverlay, etc.)
Many new UI panels and controls for inspection, filtering, selection, pathfinding, timeline, bookmarks, and settings.
Visualization primitives
packages/graph-viewer/src/components/* (Hand2DOverlay, HandControlOverlay, GestureDebugOverlay, EdgeParticles, ClusterBoundaries, ExpandedNodeView, SelectionHighlight, ClusterBoundaries, EdgeParticles, etc.)
New visual primitives for hand ghosting, gesture debug, animated edge particles, cluster boundaries, expanded node view, highlights, and particles.
Utilities & libs
packages/graph-viewer/src/lib/*, packages/graph-viewer/src/utils/*, packages/graph-viewer/src/types/d3-force-3d.d.ts, packages/graph-viewer/src/lib/types.ts
Add OneEuro filters (filter implementations duplicated in utils/lib), PointerRay filter, edgeStyles, sound manager, comprehensive TypeScript types, and d3-force-3d typings.
Dev scripts
packages/graph-viewer/scripts/*
Add hand-tracking WebSocket bridge server, dev-all launcher script, and scripts package.json.
Small scaffolding & metadata
packages/graph-viewer/.vite/deps/*, packages/graph-viewer/index.html, packages/graph-viewer/IMPLEMENTATION_PLAN.md, packages/graph-viewer/.vite/deps/package.json
Vite metadata, index HTML, and implementation/phase notes added.

Sequence Diagram(s)

mermaid
sequenceDiagram
autonumber
participant Viewer as Graph Viewer (Browser)
participant API as AutoMem API (Flask)
participant VecDB as Vector Store (Qdrant)
participant GraphDB as Graph DB (Falkor/Qdrant edges)
participant UMAP as UMAP (server lib)

Viewer->>API: GET /graph/projected?limit=&n_neighbors=&min_dist=&spread=
API->>VecDB: Query embeddings (paged, filters)
VecDB-->>API: Return embeddings + metadata
API->>UMAP: Compute 3D UMAP projection (n_neighbors,min_dist,spread)
UMAP-->>API: Return 3D coordinates
API->>GraphDB: Fetch edges among visible node IDs
GraphDB-->>API: Return graph edges
API-->>Viewer: Respond { nodes(with x,y,z,color,radius), edges, projection_meta }
Viewer->>Viewer: Normalize & render projection in GraphCanvas

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title is about 'graph visualization clustering improvements' but the primary changes focus on adding a Graph Viewer SPA, UMAP projection API endpoint, and CORS support—not clustering improvements per se. Consider updating the title to reflect the actual main changes: 'Add Graph Viewer SPA and UMAP-based projection API' or similar.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly outlines the main changes: Graph Viewer integration, UMAP projection API, CORS enablement, increased node limits, and developer experience improvements. All descriptions relate to the actual changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/graph-visualization-clustering-improvements

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Fix all issues with AI Agents 🤖
In @.gitignore:
- Around line 27-29: The .gitignore update excludes Graph Viewer build artifacts
(packages/graph-viewer/dist/, packages/graph-viewer/node_modules/,
automem/static/viewer/) but omits Vite's cache directory; add a Vite cache
ignore pattern such as .vite/ (or the broader **/.vite/) to the .gitignore so
packages/graph-viewer/.vite/deps/ and any other Vite cache folders are not
committed.

In @automem/api/graph.py:
- Around line 322-330: The n_neighbors value passed into UMAP lacks a
lower-bound check so a user-provided 1 can slip through and cause UMAP
validation errors; locate where n_neighbors is read/processed (the variable used
when constructing reducer = umap_mod.UMAP(...) and in the min(n_neighbors,
len(embeddings) - 1) expression) and enforce a minimum of 2 when parsing the
request (e.g., replace the current parsing with one that casts to int, clamps to
50, and then applies max(2, ...)); ensure the downstream min(...,
len(embeddings) - 1) still uses this validated n_neighbors value before passing
it into UMAP.
- Around line 314-316: Remove the unused variable by deleting the assignment to
memory_ids in the embeddings extraction block: the code that creates memory_ids
= [p.id for p in filtered_points] is unused and should be removed from the
function where embeddings = np.array([p.vector for p in filtered_points],
dtype=np.float32) is computed (refer to the local variable names embeddings and
filtered_points); ensure there are no other references to memory_ids elsewhere
in the module before removing it.

In @packages/graph-viewer/.vite/deps/package.json:
- Around line 1-3: Remove the committed Vite build artifacts: delete the
auto-generated Vite pre-bundling cache directory inside the graph-viewer package
(and its package.json) from the repo, add a rule to .gitignore to ignore Vite
cache/pre-bundle outputs (so these files are not committed again), and run git
rm -r on the committed artifact directory followed by a commit to remove them
from version control.

In @packages/graph-viewer/index.html:
- Line 5: The HTML references a non-existent favicon via the link tag
(href="/vite.svg"); fix by either adding the actual favicon file to a
public/static location and updating the href to that committed path, or change
the href to point at an existing committed SVG/ICO in the repo; update the <link
rel="icon" ...> element in packages/graph-viewer/index.html accordingly so it
points to a valid asset.

In @packages/graph-viewer/package.json:
- Line 36: Move the "ws" entry out of the package.json "dependencies" section
and add it under "devDependencies" (the package name is "ws" with version
"^8.18.3"); this keeps it available for the development-only script
packages/graph-viewer/scripts/hand-tracking-server.js while preventing it from
being bundled for the frontend. After editing package.json, update the lockfile
by running your package manager install (npm install / pnpm install / yarn
install) to reflect the change. Ensure no other production code imports "ws" and
that only the hand-tracking-server.js script requires it.

In @packages/graph-viewer/scripts/hand-tracking-server.js:
- Around line 371-372: The empty catch block after attempting to send the
initial bridge status to newly connected web clients silently swallows errors;
update the catch on the try that wraps the initial status send so it logs the
caught error (e.g., console.error or the existing logger) with a clear message
like "Failed to send initial bridge status to new client" and include the error
object to preserve stack/diagnostics; keep the rest of the error handling
unchanged.
- Around line 205-207: The info text shows the wrong port (8765) while the
actual default PHONE_PORT is 8768; update the HTML string inside the info div to
show 8768 or, better, interpolate/insert the PHONE_PORT constant so the
displayed port always matches the actual PHONE_PORT value (replace the hardcoded
"8765" in the div with PHONE_PORT or its value).

In @packages/graph-viewer/src/api/client.ts:
- Around line 53-60: The addTokenToUrl function exposes the API token in the URL
(via api_key query) — stop appending the token to URLs and instead send it in an
Authorization header (e.g., Bearer <token>) when making HTTP requests; update
callers of addTokenToUrl (or replace its usage) so they obtain the token with
getToken and set the Authorization header on the request object, leaving URL
composition unchanged; keep query-token only for unavoidable cases (initial
health check or CORS preflight) and ensure getToken still throws if missing so
callers can handle that error path.

In @packages/graph-viewer/src/App.tsx:
- Around line 841-854: The error display assumes error is an Error instance;
change the rendering in the block that references error and refetch to extract
the message defensively (e.g. compute a safeMessage from typeof error ===
'string' ? error : (error && (error as Error).message) ?? String(error) or
similar) and render safeMessage instead of (error as Error).message so non-Error
values and null/undefined are handled without crashing; keep the Retry button
wired to refetch.

In @packages/graph-viewer/src/components/EdgeParticles.tsx:
- Around line 143-159: The update loop can use a different per-edge count than
was used when creating geometry; set and reuse the actualParticlesPerEdge
computed during geometry creation (store it in a ref like
actualParticlesPerEdgeRef) and replace usages of particlesPerEdge in the
EdgeParticles update logic with actualParticlesPerEdgeRef.current, and also
ensure the inner loop bounds check uses edgeDataRef.current.length to prevent
edgeIndex from ever exceeding the buffer (e.g., for loop condition: i <
actualParticlesPerEdgeRef.current && edgeIndex < edgeDataRef.current.length).

In @packages/graph-viewer/src/components/ExpandedNodeView.tsx:
- Around line 273-275: In ExpandedNodeView, avoid unconditionally slicing
connNode.content to 25 chars; instead check its length before truncating and
appending "..." so short content is not modified. Locate the JSX that renders
{connNode.content.slice(0, 25)}... (inside the Text element) and replace it with
logic that returns connNode.content as-is when connNode.content.length <= 25,
otherwise returns connNode.content.slice(0, 25) + "..." (also handle
null/undefined content defensively if needed).
- Around line 72-85: The animation start time is taken from performance.now()
but elapsed is computed from Three.js's state.clock.elapsedTime, causing drift;
stop setting animationStartRef.current in useEffect using performance.now(), and
instead initialize animationStartRef.current from state.clock.elapsedTime inside
the first call to useFrame when isExpanding becomes true (set
isAnimatingRef.current = true and setAnimationProgress(0) there), leaving the
existing elapsed / EXPAND_DURATION logic unchanged; update/remove the
performance.now() assignment in useEffect and rely on the state.clock-based
start time to keep timing consistent for groupRef, isAnimatingRef,
animationStartRef, and setAnimationProgress.

In @packages/graph-viewer/src/components/GraphCanvas.tsx:
- Around line 868-888: Inside the per-frame node loop you allocate a new
THREE.Vector3 for every node; instead preallocate two temp vectors (e.g.,
tempWorldPos and tempProjected) outside the loop (or in enclosing scope used by
useFrame), then inside the loop call tempWorldPos.set(n.x ?? 0, n.y ?? 0, n.z ??
0), run group.localToWorld(tempWorldPos), copy to tempProjected
(tempProjected.copy(tempWorldPos)) and call tempProjected.project(camera) (since
project mutates the vector), compute screenX/screenY from tempProjected, and use
those to compute dx/dy/dist and update nearestDist/nearestNode; this avoids
per-iteration allocations while preserving existing logic and symbols
(layoutNodes, group.localToWorld, project, camera, PINCH_SELECT_RADIUS,
nearestDist, nearestNode).
- Around line 293-306: The VR button onClick handler contains debug console.log
calls; remove the console.log('VR button clicked') and console.log('immersive-vr
supported:', supported') statements inside the onClick async function (leaving
the navigator.xr checks, alerts, and xrStore.enterVR() behavior intact) so the
handler no longer emits development console output in production.
- Around line 1509-1514: The code creates a new THREE.Color('#3b82f6') inside
the per-frame/per-node branch (the useFrame loop) for lasso-selected nodes which
allocates on every frame; hoist that color creation out of the loop (e.g.,
define a const blueColor = new THREE.Color('#3b82f6') at module scope or in the
GraphCanvas component body) and reuse it when calling tempColor.lerp(blueColor,
0.35) in the isLassoSelected branch so only tempColor and baseNodeColor are
mutated per-frame.

In @packages/graph-viewer/src/components/Hand2DOverlay.tsx:
- Around line 76-78: The closure reads leftSmoothed/rightSmoothed directly which
can be stale; replace the external conditional and state-read with a functional
updater that inspects the current state inside setLeftSmoothed and
setRightSmoothed (e.g., call setLeftSmoothed(prev => { if (prev &&
!prev.isGhost) return { ...prev, isGhost: true, lastSeen: now }; return prev; })
and do the analogous change for setRightSmoothed) so the ghost transition uses
the latest state atomically.

In @packages/graph-viewer/src/components/HandControlOverlay.tsx:
- Line 41: The ternary assigning m is redundant—`const m = lock.mode === 'idle'
? lock.metrics : lock.metrics` always yields lock.metrics; replace it with a
direct assignment `const m = lock.metrics` or, if the original intent was to
choose different metrics based on lock.mode, update the alternate branch to the
correct property (e.g., `lock.idleMetrics` vs `lock.activeMetrics`) so the
conditional uses the intended values; locate the assignment to m in
HandControlOverlay (the `m` variable and `lock.mode` / `lock.metrics`
references) and apply the change.

In @packages/graph-viewer/src/components/MiniMap.tsx:
- Around line 77-98: The worldToCanvas and canvasToWorld callbacks can divide by
zero when bounds.maxX==bounds.minX and bounds.maxY==bounds.minY (scale === 0);
modify both functions to guard against scale being 0 by computing scale =
Math.max(rangeX, rangeY) and if scale === 0 set a safe fallback (e.g., 1 or a
small non-zero value) or return a centered coordinate, then proceed with the
existing calculations using that non-zero scale so conversions never perform
division by zero; update references in worldToCanvas and canvasToWorld to use
this guarded scale.

In @packages/graph-viewer/src/components/SearchBar.tsx:
- Around line 14-20: The debounce in the SearchBar useEffect resets whenever the
parent passes a new onChange reference; stabilize the callback by creating a ref
(e.g., onChangeRef) and assign onChange to onChangeRef.current whenever onChange
changes, then change the debouncing effect to depend only on localValue and call
onChangeRef.current(localValue) inside the timer; this preserves debouncing even
if the parent recreates the onChange function while still reacting to the latest
onChange implementation.

In @packages/graph-viewer/src/components/settings/SettingsPanel.tsx:
- Around line 17-29: RELATIONSHIP_INFO duplicates data from EDGE_STYLES; replace
the hardcoded map by importing EDGE_STYLES from lib/edgeStyles.ts and derive
label and color directly and compute style by mapping each edgeStyle.dashPattern
(e.g., [] -> 'solid', [number,...] -> 'dashed' or 'dotted' depending on pattern
length or values) so SettingsPanel uses EDGE_STYLES as the single source of
truth; update usages to reference the transformed object (keep the
RELATIONSHIP_INFO name if convenient) and remove the duplicated literals.

In @packages/graph-viewer/src/components/settings/ToggleControl.tsx:
- Around line 8-38: The label element in ToggleControl is not programmatically
associated with the switch button, so update ToggleControl to associate them:
either wrap the clickable row (including the existing label text and the button
with role="switch") inside a single <label> element (so clicks on the text
toggle the switch and screen readers announce it), or assign the button an id
and replace the <label> with a proper <label htmlFor="{thatId}"> to link the
text to the control; ensure the button keeps role="switch" and
aria-checked={checked} so accessibility semantics remain intact.

In @packages/graph-viewer/src/components/TagCloud.tsx:
- Around line 17-32: The TagCloudProps interface declares a tags prop that is
never used by the TagCloud component; remove the unused tags property from
TagCloudProps (or, if tags was intended to be used, update the TagCloud
component to consume tags instead of filteredTags where appropriate). Locate the
interface named TagCloudProps and either delete the line "tags: TagData[]" or
modify the TagCloud component to reference the tags prop (and adjust logic in
functions like onToggleTag, filtering, and rendering) so the prop is actually
used; ensure all usages and tests are updated accordingly.

In @packages/graph-viewer/src/components/TokenPrompt.tsx:
- Around line 22-34: The code stores the serverUrl/token after only calling
checkHealth, which doesn't validate the API token; update the submit flow in
TokenPrompt so that after await checkHealth(serverUrl) you perform an
authenticated lightweight request (e.g., GET /graph/stats or another existing
auth-guard endpoint) using the provided token and only call
setServerConfig(serverUrl, token) and onSubmit(token) if that authenticated
check succeeds; on failure setError with the auth error message and avoid
storing credentials; keep the existing error/finally handling and use the same
err-to-message logic as currently used.

In @packages/graph-viewer/src/hooks/useAuth.ts:
- Around line 46-47: The current hook computes isAuthenticated once via
checkAuth() on mount so it doesn't update when token or serverUrl change; update
the hook to compute isAuthenticated reactively (e.g., replace the one-time call
with a derived value using useMemo(() => checkAuth(), [token, serverUrl]) or
maintain isAuthenticated in state and run an effect that calls checkAuth()
whenever token or serverUrl change), and ensure calls to setToken() and
clearAuth() update those dependencies so the UI reflects the new authentication
state.

In @packages/graph-viewer/src/hooks/useCameraState.ts:
- Around line 67-106: The hook useCameraNavigation starts requestAnimationFrame
loops and stores the id in animationRef but never cancels them on unmount; add a
cleanup effect that cancels any pending frame by calling
cancelAnimationFrame(animationRef.current) and setting animationRef.current =
null in a useEffect return cleanup. Locate animationRef and navigateTo inside
useCameraNavigation and implement useEffect(() => () => { if
(animationRef.current) cancelAnimationFrame(animationRef.current);
animationRef.current = null; }, []) so any running animation is cancelled when
the component unmounts.

In @packages/graph-viewer/src/hooks/useClusterDetection.ts:
- Around line 93-96: Update the mismatched comment in useClusterDetection: the
loop building the adjacency list (variable adj iterating over edges) uses a
threshold of 0.25, so change the comment that currently says "strength >= 0.3"
to "strength >= 0.25" (or alternatively change the literal to 0.3 if the
intended threshold is 0.3) so the comment and the check on edge.strength remain
consistent.

In @packages/graph-viewer/src/hooks/useForceLayout.ts:
- Around line 298-304: reheat currently calls setIsSimulating(true) but never
flips it back when the d3 simulation stops; update reheat to attach an "end"
listener to layoutCache.simulation that calls setIsSimulating(false) (e.g.,
layoutCache.simulation.on('end', () => setIsSimulating(false))) and ensure you
remove any previous listener before adding a new one to avoid leaks (or add the
listener once when the simulation is created); reference the reheat function,
layoutCache.simulation, and setIsSimulating, and if using useCallback adjust
dependencies or add a cleanup to detach the "end" handler when appropriate.
- Around line 43-46: The current createDataSignature only uses length and
first/last IDs and can miss changes to middle nodes; update createDataSignature
to produce a signature that reflects all node identities (e.g., combine
nodes.map(n => n.id) into a deterministic string or compute a compact hash of
that array) so any ID change or reorder invalidates the cache; for very large
graphs implement a sampling + hash strategy or use a fast non-cryptographic hash
(e.g., FNV or xxhash) over the joined IDs to keep the signature size small.

In @packages/graph-viewer/src/hooks/useHandGestures.ts:
- Around line 126-130: The normalizeAngle function can infinite-loop for
NaN/Infinity; first guard that case by checking Number.isFinite(angle) and
return a safe default (e.g., 0) or throw a clear error if not finite, then
replace the while-loops with a robust modulo-based normalization using 2*Math.PI
(i.e., reduce angle via remainder/modulo into the [-PI, PI] range) so very large
inputs don't loop; update the function normalizeAngle to perform the finite
check and then the modulo normalization.
- Around line 389-398: There are two useEffect cleanup blocks duplicating
teardown for cameraRef, handsRef, and videoRef; remove the redundant effect (the
one returning stop()/close()/remove() without the isCleaningUpRef guard) so only
the existing cleanup that uses isCleaningUpRef runs, or consolidate them into a
single effect that checks isCleaningUpRef before calling
cameraRef.current.stop(), handsRef.current.close(), and
videoRef.current.remove(); ensure only one teardown path references cameraRef,
handsRef, videoRef and uses isCleaningUpRef to avoid double-cleanup.

In @packages/graph-viewer/src/hooks/useHandLockAndGrab.ts:
- Line 273: In useHandLockAndGrab the const nowMs = performance.now() is
included in the useMemo dependency which forces recomputation every render;
either remove useMemo and compute the value inline (replace the useMemo(...)
that produces next with an IIFE so next is recalculated each frame), or
implement a time-throttling ref strategy: add lastComputeRef and prevResultRef,
call performance.now() locally, check if now - lastComputeRef.current <
desiredInterval (e.g., 16ms) then return prevResultRef.current, otherwise
perform the heavy computation, update lastComputeRef.current and
prevResultRef.current and return the new result; ensure the useMemo dependency
array only contains stable flags (like enabled) if you keep memoization.

In @packages/graph-viewer/src/hooks/useHandPlayback.ts:
- Around line 374-382: The useEffect that starts/cancels requestAnimationFrame
can schedule a duplicate loop because play() and tick() already manage
scheduling; remove this effect (the useEffect block that checks state.isPlaying
&& !state.isPaused and calls requestAnimationFrame(tick) /
cancelAnimationFrame(animationFrameRef.current) ) or change it to only cancel on
unmount (so it doesn't start frames), ensuring play(), tick(), and
animationFrameRef are the single source of truth for starting/stopping the
animation loop and avoiding double requests.

In @packages/graph-viewer/src/hooks/useHandRecording.ts:
- Line 92: The id generation in useHandRecording.ts uses the deprecated
String.prototype.substr; update the id line that defines const id =
`rec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` to use a
non-deprecated method—replace .substr(2, 9) with either .substring(2, 11) or
.slice(2, 11) so the resulting alphanumeric segment remains 9 characters; keep
the rest of the template literal unchanged and ensure the symbol id remains used
the same way elsewhere.
- Line 115: The avgFps calculation can divide by zero when duration is 0; update
the avgFps computation in useHandRecording (the line computing avgFps from
frames.length and duration) to guard against duration === 0 and return 0 (or a
sensible default) instead of performing the division; locate the avgFps
expression and change it to a conditional that only divides when duration > 0
(e.g., avgFps = duration > 0 ? (frames.length / duration) * 1000 : 0) so
stopRecording()/startRecording() back-to-back won’t produce Infinity.

In @packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts:
- Around line 382-397: The bug is that the same OneEuroFilter instances
(filters.landmarks[idx]) are used to filter both screen and world landmark
streams in the same frame, causing state corruption; change the world-landmark
path in the mapping that produces worldLandmarks so it uses a separate filter
collection (e.g., filters.worldLandmarks[idx]) instead of
filters.landmarks[idx], and if filters.worldLandmarks does not exist ensure you
initialize a parallel array of filters (same length/structure as
filters.landmarks) when filters are created so each landmark stream has its own
stateful filters.

In @packages/graph-viewer/src/hooks/useLassoSelection.ts:
- Around line 216-224: The selection change callback is being invoked during
render; move the logic that compares prevSelectionRef.current and
state.selectedIds and calls onSelectionChange(selectedNodes) into a useEffect so
it runs after render. Specifically, create a useEffect that depends on
[state.isDrawing, state.selectedIds, onSelectionChange, selectedNodes], perform
the areSetsEqual(prevSelectionRef.current, state.selectedIds) check inside it,
update prevSelectionRef.current = new Set(state.selectedIds) when they differ,
and call onSelectionChange(selectedNodes) only from within that effect to avoid
render-side effects.

In @packages/graph-viewer/src/lib/sounds.ts:
- Around line 78-81: The getContext logic currently calls
this.audioContext.resume() without awaiting the Promise, so playback calls that
follow may run before the context is resumed; update getContext (in
packages/graph-viewer/src/lib/sounds.ts) to await this.audioContext.resume()
when state === 'suspended' and propagate the async nature (make getContext
return a Promise and update callers), or alternatively make the play methods
async and await getContext/resume before scheduling/starting sounds (adjust
methods like playSound / any play* functions to await getContext()). Ensure
resume errors are caught/logged if you choose fire-and-forget.

In @packages/graph-viewer/src/main.tsx:
- Line 16: ReactDOM.createRoot(document.getElementById('root')!) uses a non-null
assertion which will throw if the 'root' element is missing; change it to first
retrieve const rootEl = document.getElementById('root') and add a runtime check:
if rootEl is null, log a clear error (or render a fallback UI) and return,
otherwise call ReactDOM.createRoot(rootEl). Ensure you reference the existing
ReactDOM.createRoot call and the 'root' id when making this change.

In @packages/graph-viewer/src/utils/OneEuroFilter.ts:
- Around line 254-262: The filter method in HandLandmarkFilter assumes
this.filters has an entry for every landmark, causing an out-of-bounds error
when landmarks.length !== this.filters.length; update the filter(landmarks,
timestamp) implementation to guard accesses to this.filters[i] (or to compare
landmarks.length to this.filters.length) and handle missing filters by either
truncating/ignoring extra landmarks or returning the original landmark unchanged
for indices where this.filters[i] is undefined; modify the loop in filter
(and/or initialize/resize this.filters elsewhere) so references to
this.filters[i].filter(...) are only invoked when this.filters[i] exists.
♻️ Duplicate comments (1)
packages/graph-viewer/.vite/deps/_metadata.json (1)

1-8: Build artifacts should not be committed to version control.

This metadata file is part of Vite's build cache (same issue as package.json in the same directory). Both files should be excluded from version control.

🟡 Minor comments (23)
packages/graph-viewer/src/components/Hand2DOverlay.tsx-76-78 (1)

76-78: Potential stale closure when checking ghost transition.

The condition reads leftSmoothed directly from the closure, but this state isn't in the dependency array. If gestureState changes rapidly, the closure may capture a stale reference. Consider using the functional updater to access current state:

🔎 Suggested approach using functional updater
-    } else if (leftSmoothed && !leftSmoothed.isGhost) {
-      // Hand disappeared - start ghost mode
-      setLeftSmoothed(prev => prev ? { ...prev, isGhost: true, lastSeen: now } : null)
-    }
+    } else {
+      // Hand disappeared - start ghost mode if applicable
+      setLeftSmoothed(prev => {
+        if (prev && !prev.isGhost) {
+          return { ...prev, isGhost: true, lastSeen: now }
+        }
+        return prev
+      })
+    }

Apply the same pattern to rightSmoothed on lines 100-102.

packages/graph-viewer/src/lib/sounds.ts-78-81 (1)

78-81: audioContext.resume() returns a Promise that isn't awaited.

When the audio context is suspended, resume() is called but not awaited. Sounds triggered immediately after may fail to play. Consider returning a Promise from getContext or handling this asynchronously.

🔎 Possible fix
- private getContext(): AudioContext | null {
+ private async ensureContext(): Promise<AudioContext | null> {
    if (!this.audioContext) {
      try {
        this.audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
      } catch {
        console.warn('Web Audio API not supported')
        return null
      }
    }

    // Resume if suspended (browser autoplay policy)
    if (this.audioContext.state === 'suspended') {
-     this.audioContext.resume()
+     await this.audioContext.resume()
    }

    return this.audioContext
  }

Note: This would require making play methods async or using a fire-and-forget pattern. Given the sound feedback use case, the current approach may be acceptable as resume typically completes quickly.

Committable suggestion skipped: line range outside the PR's diff.

automem/api/graph.py-314-316 (1)

314-316: Remove unused variable memory_ids.

The variable memory_ids is assigned but never used. It appears to be left over from refactoring.

🔎 Proposed fix
             # Extract embeddings matrix
             embeddings = np.array([p.vector for p in filtered_points], dtype=np.float32)
-            memory_ids = [p.id for p in filtered_points]
packages/graph-viewer/src/hooks/useHandRecording.ts-92-92 (1)

92-92: Replace deprecated substr with substring or slice.

String.prototype.substr is deprecated. Use substring or slice instead.

🔎 Proposed fix
-    const id = `rec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
+    const id = `rec_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
packages/graph-viewer/src/hooks/useHandRecording.ts-115-115 (1)

115-115: Division by zero possible if recording stopped immediately.

If stopRecording() is called immediately after startRecording(), duration could be 0, leading to division by zero (Infinity FPS).

🔎 Proposed fix
-    const avgFps = frames.length > 0 ? (frames.length / duration) * 1000 : 0
+    const avgFps = frames.length > 0 && duration > 0 ? (frames.length / duration) * 1000 : 0
packages/graph-viewer/src/components/ExpandedNodeView.tsx-273-275 (1)

273-275: Content truncation without length check.

The content is unconditionally sliced to 25 characters and appended with "...", even if the content is shorter than 25 characters.

🔎 Proposed fix
               <Text
                 position={[0, connNode.radius + 1.5, 0]}
                 fontSize={1.8}
                 color="#f1f5f9"
                 anchorX="center"
                 anchorY="bottom"
                 outlineWidth={0.08}
                 outlineColor="#000000"
                 fillOpacity={satProgress}
               >
-                {connNode.content.slice(0, 25)}...
+                {connNode.content.length > 25 
+                  ? `${connNode.content.slice(0, 25)}...` 
+                  : connNode.content}
               </Text>
packages/graph-viewer/src/components/ExpandedNodeView.tsx-72-85 (1)

72-85: Potential timing drift between performance.now() and Three.js clock.

animationStartRef.current is set using performance.now() / 1000 (line 74), but the elapsed time is calculated using state.clock.elapsedTime (line 84). These clocks may not be synchronized, especially if the Three.js clock was started at a different time, causing animation timing issues.

🔎 Proposed fix - use consistent timing source
   useEffect(() => {
     if (isExpanding) {
-      animationStartRef.current = performance.now() / 1000
+      animationStartRef.current = -1 // Signal to capture on first frame
       isAnimatingRef.current = true
       setAnimationProgress(0)
     }
   }, [isExpanding])

   useFrame((state) => {
     if (!isAnimatingRef.current || !groupRef.current) return

-    const elapsed = state.clock.elapsedTime - animationStartRef.current
+    if (animationStartRef.current < 0) {
+      animationStartRef.current = state.clock.elapsedTime
+    }
+    const elapsed = state.clock.elapsedTime - animationStartRef.current
packages/graph-viewer/src/hooks/useCameraState.ts-67-106 (1)

67-106: Missing cleanup for animation frame on unmount.

The useCameraNavigation hook stores an animation frame ID but doesn't cancel it on unmount, which could cause state updates on an unmounted component.

🔎 Proposed fix
+import { useRef, useCallback, useEffect } from 'react'
+
 export function useCameraNavigation() {
   const { camera, controls } = useThree()
   const animationRef = useRef<number | null>(null)

+  useEffect(() => {
+    return () => {
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current)
+      }
+    }
+  }, [])
+
   const navigateTo = useCallback((targetX: number, targetY: number, duration = 500) => {
     // ... existing code
   }, [camera, controls])

   return { navigateTo }
 }
packages/graph-viewer/scripts/hand-tracking-server.js-371-372 (1)

371-372: Empty catch block silently swallows errors.

The empty catch {} block on line 372 hides potential issues when sending the initial bridge status to newly connected web clients. At minimum, log the error.

Proposed fix
     try {
         ws.send(JSON.stringify({
             type: 'bridge_status',
             phonePort: PHONE_PORT,
             webPort: WEB_PORT,
             phoneConnected: phoneClients.size > 0,
             ips: getLocalIps(),
             lastHandFrameAt: latestHandData?.frameTimestamp || null,
         }));
-    } catch {}
+    } catch (err) {
+        console.error('Failed to send bridge status:', err.message);
+    }
packages/graph-viewer/src/components/MiniMap.tsx-77-98 (1)

77-98: Potential division by zero in coordinate conversion.

If all nodes have the same position, rangeX and rangeY would both be 0, making scale = 0. This would cause division by zero in the conversion calculations.

Proposed fix
 const worldToCanvas = useCallback((x: number, y: number) => {
   const rangeX = bounds.maxX - bounds.minX
   const rangeY = bounds.maxY - bounds.minY
-  const scale = Math.max(rangeX, rangeY)
+  const scale = Math.max(rangeX, rangeY) || 1 // Prevent division by zero

   const canvasX = ((x - bounds.minX) / scale) * size
   const canvasY = ((bounds.maxY - y) / scale) * size // Flip Y axis

   return { x: canvasX, y: canvasY }
 }, [bounds, size])

 const canvasToWorld = useCallback((canvasX: number, canvasY: number) => {
   const rangeX = bounds.maxX - bounds.minX
   const rangeY = bounds.maxY - bounds.minY
-  const scale = Math.max(rangeX, rangeY)
+  const scale = Math.max(rangeX, rangeY) || 1 // Prevent division by zero

   const x = (canvasX / size) * scale + bounds.minX
   const y = bounds.maxY - (canvasY / size) * scale // Flip Y axis

   return { x, y }
 }, [bounds, size])
packages/graph-viewer/scripts/hand-tracking-server.js-205-207 (1)

205-207: Incorrect port in HTML documentation.

The HTML info section displays 8765 but the actual default PHONE_PORT is 8768. This could confuse users trying to connect their iPhone.

Proposed fix
     <div class="info">
-        iPhone should connect to: ws://&lt;your-mac-ip&gt;:8765
+        iPhone should connect to: ws://&lt;your-mac-ip&gt;:${PHONE_PORT}
     </div>
packages/graph-viewer/src/components/HandControlOverlay.tsx-41-41 (1)

41-41: Redundant ternary expression.

The expression lock.mode === 'idle' ? lock.metrics : lock.metrics always evaluates to lock.metrics. This appears to be incomplete or a copy-paste error.

🔎 Proposed fix
-  const m = lock.mode === 'idle' ? lock.metrics : lock.metrics
+  const m = lock.metrics
packages/graph-viewer/src/components/TagCloud.tsx-17-32 (1)

17-32: Unused tags prop in interface.

The tags prop is declared in TagCloudProps but never used in the component body. The component only uses filteredTags. Either remove the unused prop or document its intended purpose.

🔎 Proposed fix
 interface TagCloudProps {
-  tags: TagData[]
   filteredTags: TagData[]
   selectedTags: Set<string>
   // ... rest of props
 }
packages/graph-viewer/src/components/TokenPrompt.tsx-22-34 (1)

22-34: Token is not validated before being stored.

The checkHealth call only verifies server reachability—it doesn't validate the API token. Users may successfully "connect" but then encounter auth failures on subsequent API calls. Consider adding a lightweight authenticated endpoint check (e.g., /graph/stats) to validate the token before storing.

🔎 Suggested approach
     try {
       // Test connection to the server
       await checkHealth(serverUrl)
+      
+      // Store config temporarily to test authentication
+      setServerConfig(serverUrl, token)
+      
+      // Verify token works with an authenticated endpoint
+      await fetchGraphStats()

-      // Store config and notify parent
-      setServerConfig(serverUrl, token)
       onSubmit(token)
     } catch (err) {
+      // Clear invalid config on failure
+      localStorage.removeItem('automem_server')
+      localStorage.removeItem('automem_token')
       setError((err as Error).message || 'Connection failed')
     } finally {

Committable suggestion skipped: line range outside the PR's diff.

packages/graph-viewer/src/hooks/useClusterDetection.ts-93-96 (1)

93-96: Comment-code mismatch on strength threshold.

The comment on line 93 states "strength >= 0.3" but the actual threshold on line 96 is 0.25. This inconsistency could confuse future maintainers.

🔎 Proposed fix
-      // Build adjacency list from edges with strength >= 0.3 (lowered for tighter clusters)
+      // Build adjacency list from edges with strength >= 0.25 (lowered for tighter clusters)
       const adj = new Map<string, string[]>()
       for (const edge of edges) {
         if (edge.strength >= 0.25) {
packages/graph-viewer/src/utils/OneEuroFilter.ts-254-262 (1)

254-262: Potential index out of bounds if landmarks array length !== 21.

HandLandmarkFilter assumes exactly 21 landmarks. If the input array has more than 21 elements, this.filters[i] will be undefined for i >= 21, causing a runtime error.

🔎 Add bounds check
   filter(
     landmarks: Array<{ x: number; y: number; z: number; visibility?: number }>,
     timestamp?: number
   ): Array<{ x: number; y: number; z: number; visibility?: number }> {
-    return landmarks.map((lm, i) => ({
-      ...this.filters[i].filter(lm, timestamp),
-      visibility: lm.visibility,
-    }))
+    return landmarks.slice(0, 21).map((lm, i) => ({
+      ...this.filters[i].filter(lm, timestamp),
+      visibility: lm.visibility,
+    }))
   }
packages/graph-viewer/src/hooks/useForceLayout.ts-298-304 (1)

298-304: Reheat doesn't update isSimulating to false when simulation stops.

reheat sets isSimulating(true) but there's no mechanism to set it back to false when the simulation completes. The simulation runs detached from React's lifecycle after reheat.

🔎 Consider tracking simulation end
   const reheat = useCallback(() => {
     if (layoutCache.simulation) {
-      layoutCache.simulation.alpha(0.5).restart()
+      layoutCache.simulation
+        .alpha(0.5)
+        .on('end', () => setIsSimulating(false))
+        .restart()
       setIsSimulating(true)
     }
   }, [])
packages/graph-viewer/src/hooks/useForceLayout.ts-43-46 (1)

43-46: Weak data signature may miss node changes.

createDataSignature only considers array length and first/last node IDs. Changes to middle nodes (additions/removals that maintain length, or ID changes) won't invalidate the cache, potentially returning stale layouts.

🔎 Consider a more robust signature
 function createDataSignature(nodes: GraphNode[]): string {
   if (nodes.length === 0) return ''
-  return `${nodes.length}-${nodes[0]?.id}-${nodes[nodes.length - 1]?.id}`
+  // Include a hash of all node IDs for better change detection
+  const idHash = nodes.map(n => n.id).join(',')
+  return `${nodes.length}-${idHash}`
 }

For very large graphs, consider a sampling approach or hash function to avoid long strings.

packages/graph-viewer/src/components/GraphCanvas.tsx-1287-1288 (1)

1287-1288: Remove debug console.log statements before production.

Multiple console.log statements for debugging mouse interactions should be removed or gated behind a debug flag for production builds.

🔎 Lines containing debug logs
  • Line 1287: console.log('🖱️ DOM PointerUp - distance:', ...)
  • Line 1302: console.log('🖱️ Raycast - NDC:', ...)
  • Line 1306: console.log('🖱️ Raycast hit instanceId:', ...)
  • Line 1309: console.log('🖱️ Found node:', ...)
  • Line 1613: console.log('🖱️ R3F onClick triggered')
  • Line 1618: console.log('🖱️ R3F onClick - intersects:', ...)
  • Line 1622: console.log('🖱️ R3F onClick hit instanceId:', ...)
  • Line 1625: console.log('🖱️ R3F onClick found node:', ...)

Also applies to: 1302-1303, 1306-1307, 1309-1310, 1613-1614, 1618-1619, 1622-1623, 1625-1626

packages/graph-viewer/src/components/GraphCanvas.tsx-293-306 (1)

293-306: Console logging in VR button click handler.

Debug logging in the VR button handler should be removed for production.

🔎 Proposed fix
         onClick={async () => {
-          console.log('VR button clicked')
           if (!navigator.xr) {
             alert('WebXR not supported. Need HTTPS?')
             return
           }
           const supported = await navigator.xr.isSessionSupported('immersive-vr')
-          console.log('immersive-vr supported:', supported)
           if (!supported) {
packages/graph-viewer/src/hooks/useHandGestures.ts-389-398 (1)

389-398: Duplicate cleanup logic may cause issues.

There are two useEffect hooks with cleanup logic for the same resources (lines 375-386 and 389-398). The second effect (lines 389-398) runs on every render due to the empty dependency array, potentially calling stop()/close() on already-cleaned resources.

The first cleanup (lines 375-386) already handles everything properly with the isCleaningUpRef guard.

🔎 Proposed fix - remove duplicate cleanup
-  // Cleanup on unmount
-  useEffect(() => {
-    return () => {
-      cameraRef.current?.stop()
-      handsRef.current?.close()
-      if (videoRef.current) {
-        videoRef.current.remove()
-      }
-    }
-  }, [])
-
   return {
packages/graph-viewer/src/components/GraphCanvas.tsx-1509-1514 (1)

1509-1514: Creating new THREE.Color inside useFrame loop.

A new THREE.Color('#3b82f6') is created for each lasso-selected node on every frame. This should be hoisted outside the loop.

🔎 Proposed fix
+  // Constant colors (defined outside useFrame to avoid recreation)
+  const LASSO_BLUE = useMemo(() => new THREE.Color('#3b82f6'), [])

   // Inside useFrame:
   } else if (isLassoSelected) {
     tempColor.set(baseNodeColor)
-    const blueColor = new THREE.Color('#3b82f6')
-    tempColor.lerp(blueColor, 0.35)
+    tempColor.lerp(LASSO_BLUE, 0.35)
   }

Committable suggestion skipped: line range outside the PR's diff.

packages/graph-viewer/src/App.tsx-841-854 (1)

841-854: Handle error type more defensively.

The error from the query hook may not always be an Error instance. Consider safer error message extraction.

🔎 Proposed fix
               {error && (
                 <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
                   <div className="glass p-6 rounded-xl max-w-md text-center">
                     <div className="text-red-400 text-lg mb-2">Connection Error</div>
-                    <div className="text-slate-400 text-sm mb-4">{(error as Error).message}</div>
+                    <div className="text-slate-400 text-sm mb-4">
+                      {error instanceof Error ? error.message : 'An unexpected error occurred'}
+                    </div>
                     <button
                       onClick={() => refetch()}
                       className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors"
                     >
                       Retry
                     </button>
                   </div>
                 </div>
               )}

Comment on lines +27 to +29
packages/graph-viewer/dist/
packages/graph-viewer/node_modules/
automem/static/viewer/
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add .vite/ pattern to prevent committing Vite build cache.

The three new patterns correctly exclude Graph Viewer build artifacts and dependencies. However, Vite's build cache directory (.vite/) should also be ignored, as evidenced by the build artifacts in packages/graph-viewer/.vite/deps/ that were committed in this PR.

🔎 Recommended addition
 packages/graph-viewer/dist/
 packages/graph-viewer/node_modules/
+packages/graph-viewer/.vite/
 automem/static/viewer/

Or more broadly to catch all Vite caches:

 packages/graph-viewer/dist/
 packages/graph-viewer/node_modules/
+**/.vite/
 automem/static/viewer/
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
packages/graph-viewer/dist/
packages/graph-viewer/node_modules/
automem/static/viewer/
packages/graph-viewer/dist/
packages/graph-viewer/node_modules/
packages/graph-viewer/.vite/
automem/static/viewer/
🤖 Prompt for AI Agents
In @.gitignore around lines 27-29, The .gitignore update excludes Graph Viewer
build artifacts (packages/graph-viewer/dist/,
packages/graph-viewer/node_modules/, automem/static/viewer/) but omits Vite's
cache directory; add a Vite cache ignore pattern such as .vite/ (or the broader
**/.vite/) to the .gitignore so packages/graph-viewer/.vite/deps/ and any other
Vite cache folders are not committed.

Comment on lines +322 to +330
reducer = umap_mod.UMAP(
n_components=3,
n_neighbors=min(n_neighbors, len(embeddings) - 1),
min_dist=min_dist,
spread=spread,
metric="cosine",
random_state=42, # Reproducible projections
)
projected_coords = reducer.fit_transform(embeddings)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

UMAP umap-learn minimum n_neighbors parameter constraint version 0.5.5

💡 Result:

Short answer: in umap-learn 0.5.x (including 0.5.5) n_neighbors must be >= 2 (the code raises "n_neighbors must be greater than 1"); also n_neighbors should be chosen ≤ number of samples (and precomputed_knn, if used, must contain at least n_neighbors). See the UMAP source/docs for the checks and recommendations. [1][2]

Sources:

  • UMAP implementation (parameter validation: raises if n_neighbors < 2). [1]
  • UMAP parameter docs (recommended range and usage notes). [2]

🏁 Script executed:

# Let's examine the actual code context in the repository
find . -name "graph.py" -path "*/automem/api/*" -type f

Repository: verygoodplugins/automem

Length of output: 89


🏁 Script executed:

# Check for UMAP usage and context around lines 322-330
find . -name "graph.py" -path "*/automem/api/*" -type f -exec cat -n {} \;

Repository: verygoodplugins/automem

Length of output: 32905


Add explicit validation for UMAP n_neighbors minimum constraint.

The n_neighbors parameter lacks a lower bound check. Line 249 defaults to 15 and caps at 50, but doesn't enforce the UMAP requirement of n_neighbors >= 2. If a user passes n_neighbors=1, the clamping at line 324 (min(n_neighbors, len(embeddings) - 1)) would still yield 1, causing UMAP to fail with a validation error.

Add a lower bound: n_neighbors = max(2, min(int(request.args.get("n_neighbors", 15)), 50))

🤖 Prompt for AI Agents
In @automem/api/graph.py around lines 322-330, The n_neighbors value passed into
UMAP lacks a lower-bound check so a user-provided 1 can slip through and cause
UMAP validation errors; locate where n_neighbors is read/processed (the variable
used when constructing reducer = umap_mod.UMAP(...) and in the min(n_neighbors,
len(embeddings) - 1) expression) and enforce a minimum of 2 when parsing the
request (e.g., replace the current parsing with one that casts to int, clamps to
50, and then applies max(2, ...)); ensure the downstream min(...,
len(embeddings) - 1) still uses this validated n_neighbors value before passing
it into UMAP.

Comment on lines +1 to +3
{
"type": "module"
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Build artifacts should not be committed to version control.

The .vite/deps/ directory contains Vite's dependency pre-bundling cache and build metadata, which are auto-generated during development and change frequently. Committing these files creates unnecessary version control noise and potential merge conflicts.

🔎 Recommended fix

Remove this file from the repository and update .gitignore:

+# Vite build cache
+**/.vite/

Then remove the committed artifacts:

git rm -r packages/graph-viewer/.vite/

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @packages/graph-viewer/.vite/deps/package.json around lines 1-3, Remove the
committed Vite build artifacts: delete the auto-generated Vite pre-bundling
cache directory inside the graph-viewer package (and its package.json) from the
repo, add a rule to .gitignore to ignore Vite cache/pre-bundle outputs (so these
files are not committed again), and run git rm -r on the committed artifact
directory followed by a commit to remove them from version control.

<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for vite.svg existence in graph-viewer package
fd -t f "vite.svg" packages/graph-viewer/

Repository: verygoodplugins/automem

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check for vite.svg in common asset locations
find packages/graph-viewer -type f -name "vite.svg" 2>/dev/null

Repository: verygoodplugins/automem

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check the public directory structure in graph-viewer
find packages/graph-viewer -type d -name "public" 2>/dev/null
ls -la packages/graph-viewer/public/ 2>/dev/null || echo "No public directory found"

Repository: verygoodplugins/automem

Length of output: 92


🏁 Script executed:

#!/bin/bash
# Check all SVG files in graph-viewer
fd -t f "\.svg$" packages/graph-viewer/

Repository: verygoodplugins/automem

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check if graph-viewer has a vite config that might handle favicon
find packages/graph-viewer -type f \( -name "vite.config.*" -o -name "index.html" \) -exec cat {} \;

Repository: verygoodplugins/automem

Length of output: 2169


Remove or replace the favicon reference.

The vite.svg file does not exist in the repository, and there is no public/ directory to serve it from. Either create a public/ directory with the actual favicon asset or update the href attribute to reference a committed SVG file.

🤖 Prompt for AI Agents
In @packages/graph-viewer/index.html around line 5, The HTML references a
non-existent favicon via the link tag (href="/vite.svg"); fix by either adding
the actual favicon file to a public/static location and updating the href to
that committed path, or change the href to point at an existing committed
SVG/ICO in the repo; update the <link rel="icon" ...> element in
packages/graph-viewer/index.html accordingly so it points to a valid asset.

"react-resizable-panels": "^2.1.7",
"tailwind-merge": "^2.5.5",
"three": "^0.170.0",
"ws": "^8.18.3"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n packages/graph-viewer/package.json

Repository: verygoodplugins/automem

Length of output: 190


🏁 Script executed:

git ls-files packages/graph-viewer/ | head -20

Repository: verygoodplugins/automem

Length of output: 995


🏁 Script executed:

cat -n packages/graph-viewer/package.json

Repository: verygoodplugins/automem

Length of output: 2145


🏁 Script executed:

rg "import.*\bws\b" packages/graph-viewer/src/

Repository: verygoodplugins/automem

Length of output: 49


🏁 Script executed:

rg "ws\b" packages/graph-viewer/src/ --type ts --type tsx

Repository: verygoodplugins/automem

Length of output: 94


🏁 Script executed:

rg "ws\b" packages/graph-viewer/src/ -A 2 -B 2

Repository: verygoodplugins/automem

Length of output: 11257


🏁 Script executed:

grep -n "\"ws\"" packages/graph-viewer/package-lock.json | head -5

Repository: verygoodplugins/automem

Length of output: 93


🏁 Script executed:

cat packages/graph-viewer/package-lock.json | grep -A 10 '"ws":' | head -20

Repository: verygoodplugins/automem

Length of output: 412


🏁 Script executed:

python3 << 'EOF'
import json

with open('packages/graph-viewer/package-lock.json', 'r') as f:
    lock_data = json.load(f)

# Check if ws is in direct dependencies
if 'ws' in lock_data.get('dependencies', {}):
    ws_entry = lock_data['dependencies']['ws']
    print("ws is a DIRECT dependency:")
    print(f"  Version: {ws_entry.get('version', 'N/A')}")
    print(f"  Resolved: {ws_entry.get('resolved', 'N/A')}")
    print()

# Check what packages depend on ws
print("Packages that depend on ws:")
for pkg_name, pkg_info in lock_data.get('dependencies', {}).items():
    if 'dependencies' in pkg_info and 'ws' in pkg_info['dependencies']:
        print(f"  - {pkg_name}")
EOF

Repository: verygoodplugins/automem

Length of output: 153


🏁 Script executed:

rg '"ws"' packages/graph-viewer/package-lock.json | head -10

Repository: verygoodplugins/automem

Length of output: 90


🏁 Script executed:

rg "require.*ws|import.*from.*['\"]ws['\"]" packages/graph-viewer/ --type js --type json

Repository: verygoodplugins/automem

Length of output: 174


🏁 Script executed:

cat -n packages/graph-viewer/scripts/hand-tracking-server.js | head -50

Repository: verygoodplugins/automem

Length of output: 1983


🏁 Script executed:

cat packages/graph-viewer/package.json | grep -A 10 '"scripts"'

Repository: verygoodplugins/automem

Length of output: 417


🏁 Script executed:

cat -n packages/graph-viewer/scripts/dev-all.mjs

Repository: verygoodplugins/automem

Length of output: 2749


Move ws from dependencies to devDependencies.

The ws package is used exclusively in packages/graph-viewer/scripts/hand-tracking-server.js, which is a Node.js development utility for the iPhone hand-tracking bridge. The browser React app correctly uses the native WebSocket API. Since ws is only needed for development tooling and not for the frontend bundle, it should be in devDependencies instead of main dependencies.

🤖 Prompt for AI Agents
In @packages/graph-viewer/package.json around line 36, Move the "ws" entry out
of the package.json "dependencies" section and add it under "devDependencies"
(the package name is "ws" with version "^8.18.3"); this keeps it available for
the development-only script
packages/graph-viewer/scripts/hand-tracking-server.js while preventing it from
being bundled for the frontend. After editing package.json, update the lockfile
by running your package manager install (npm install / pnpm install / yarn
install) to reflect the change. Ensure no other production code imports "ws" and
that only the hand-tracking-server.js script requires it.

Comment on lines +374 to +382
// Effect to continue playback loop
useEffect(() => {
if (state.isPlaying && !state.isPaused) {
animationFrameRef.current = requestAnimationFrame(tick)
}
return () => {
cancelAnimationFrame(animationFrameRef.current)
}
}, [state.isPlaying, state.isPaused, tick])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential double animation frame scheduling.

This effect starts a new animation frame when isPlaying becomes true, but tick() (line 241) also schedules the next frame via requestAnimationFrame(tick). When play() is called (line 267), it also schedules a frame. This can lead to multiple concurrent animation loops.

🔎 Proposed fix - remove the redundant effect
-  // Effect to continue playback loop
-  useEffect(() => {
-    if (state.isPlaying && !state.isPaused) {
-      animationFrameRef.current = requestAnimationFrame(tick)
-    }
-    return () => {
-      cancelAnimationFrame(animationFrameRef.current)
-    }
-  }, [state.isPlaying, state.isPaused, tick])

The play() function already handles starting the animation loop. This effect appears redundant and could cause timing issues.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Effect to continue playback loop
useEffect(() => {
if (state.isPlaying && !state.isPaused) {
animationFrameRef.current = requestAnimationFrame(tick)
}
return () => {
cancelAnimationFrame(animationFrameRef.current)
}
}, [state.isPlaying, state.isPaused, tick])
// Previous code...
🤖 Prompt for AI Agents
In @packages/graph-viewer/src/hooks/useHandPlayback.ts around lines 374-382, The
useEffect that starts/cancels requestAnimationFrame can schedule a duplicate
loop because play() and tick() already manage scheduling; remove this effect
(the useEffect block that checks state.isPlaying && !state.isPaused and calls
requestAnimationFrame(tick) / cancelAnimationFrame(animationFrameRef.current) )
or change it to only cancel on unmount (so it doesn't start frames), ensuring
play(), tick(), and animationFrameRef are the single source of truth for
starting/stopping the animation loop and avoiding double requests.

Comment on lines +382 to +397
const landmarks: NormalizedLandmarkList = rawLandmarks.map((lm, idx) => {
const filtered = filters.landmarks[idx].filter(
{ x: lm.x, y: lm.y, z: lm.z ?? 0 },
now
)
return { ...filtered, visibility: lm.visibility }
})

const worldLandmarks: NormalizedLandmarkList = rawWorldLandmarks.map((lm, idx) => {
// World landmarks use same filter as screen landmarks for consistency
const filtered = filters.landmarks[idx].filter(
{ x: lm.x, y: lm.y, z: lm.z ?? 0 },
now
)
return { ...filtered, visibility: lm.visibility }
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Critical: Same filter instances used twice per frame corrupts filter state.

rawLandmarks and rawWorldLandmarks are both filtered through filters.landmarks[idx] in the same frame. Since OneEuroFilter is stateful (each filter() call updates internal state based on the previous value), the second call on line 392-393 overwrites the state from the first call on line 383-384.

This causes worldLandmarks to receive incorrect smoothing because the filter sees the already-filtered screen coordinates before processing world coordinates.

🔎 Suggested fix: Use separate filter sets for world landmarks
 interface HandFilters {
   landmarks: OneEuroFilter3D[]  // 21 landmarks
+  worldLandmarks: OneEuroFilter3D[]  // 21 world landmarks (separate set)
   pinch: OneEuroFilter
   grab: OneEuroFilter
   pointerDirection: OneEuroFilter3D
 }

 function createHandFilters(): HandFilters {
   return {
     landmarks: Array.from({ length: 21 }, () => new OneEuroFilter3D(ONE_EURO_PRESETS.landmark)),
+    worldLandmarks: Array.from({ length: 21 }, () => new OneEuroFilter3D(ONE_EURO_PRESETS.landmark)),
     pinch: new OneEuroFilter(ONE_EURO_PRESETS.gesture),
     grab: new OneEuroFilter(ONE_EURO_PRESETS.gesture),
     pointerDirection: new OneEuroFilter3D(ONE_EURO_PRESETS.pointer),
   }
 }

Then use filters.worldLandmarks[idx] for filtering rawWorldLandmarks.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @packages/graph-viewer/src/hooks/useIPhoneHandTracking.ts around lines
382-397, The bug is that the same OneEuroFilter instances
(filters.landmarks[idx]) are used to filter both screen and world landmark
streams in the same frame, causing state corruption; change the world-landmark
path in the mapping that produces worldLandmarks so it uses a separate filter
collection (e.g., filters.worldLandmarks[idx]) instead of
filters.landmarks[idx], and if filters.worldLandmarks does not exist ensure you
initialize a parallel array of filters (same length/structure as
filters.landmarks) when filters are created so each landmark stream has its own
stateful filters.

Comment on lines +216 to +224
const prevSelectionRef = useRef<Set<string>>(new Set())
if (
onSelectionChange &&
!state.isDrawing &&
!areSetsEqual(prevSelectionRef.current, state.selectedIds)
) {
prevSelectionRef.current = new Set(state.selectedIds)
onSelectionChange(selectedNodes)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Side effect during render - move to useEffect.

The selection change callback is executed during render, which can cause issues with React's concurrent mode and may lead to unexpected behavior. This should be moved to a useEffect.

🔎 Proposed fix using useEffect
-  // Notify parent when selection changes
-  const prevSelectionRef = useRef<Set<string>>(new Set())
-  if (
-    onSelectionChange &&
-    !state.isDrawing &&
-    !areSetsEqual(prevSelectionRef.current, state.selectedIds)
-  ) {
-    prevSelectionRef.current = new Set(state.selectedIds)
-    onSelectionChange(selectedNodes)
-  }
+  // Notify parent when selection changes
+  const prevSelectionRef = useRef<Set<string>>(new Set())
+  useEffect(() => {
+    if (
+      onSelectionChange &&
+      !state.isDrawing &&
+      !areSetsEqual(prevSelectionRef.current, state.selectedIds)
+    ) {
+      prevSelectionRef.current = new Set(state.selectedIds)
+      onSelectionChange(selectedNodes)
+    }
+  }, [state.isDrawing, state.selectedIds, selectedNodes, onSelectionChange])

Also update the import:

-import { useState, useCallback, useRef, useMemo } from 'react'
+import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const prevSelectionRef = useRef<Set<string>>(new Set())
if (
onSelectionChange &&
!state.isDrawing &&
!areSetsEqual(prevSelectionRef.current, state.selectedIds)
) {
prevSelectionRef.current = new Set(state.selectedIds)
onSelectionChange(selectedNodes)
}
const prevSelectionRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (
onSelectionChange &&
!state.isDrawing &&
!areSetsEqual(prevSelectionRef.current, state.selectedIds)
) {
prevSelectionRef.current = new Set(state.selectedIds)
onSelectionChange(selectedNodes)
}
}, [state.isDrawing, state.selectedIds, selectedNodes, onSelectionChange])
Suggested change
const prevSelectionRef = useRef<Set<string>>(new Set())
if (
onSelectionChange &&
!state.isDrawing &&
!areSetsEqual(prevSelectionRef.current, state.selectedIds)
) {
prevSelectionRef.current = new Set(state.selectedIds)
onSelectionChange(selectedNodes)
}
import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
🤖 Prompt for AI Agents
In @packages/graph-viewer/src/hooks/useLassoSelection.ts around lines 216-224,
The selection change callback is being invoked during render; move the logic
that compares prevSelectionRef.current and state.selectedIds and calls
onSelectionChange(selectedNodes) into a useEffect so it runs after render.
Specifically, create a useEffect that depends on [state.isDrawing,
state.selectedIds, onSelectionChange, selectedNodes], perform the
areSetsEqual(prevSelectionRef.current, state.selectedIds) check inside it,
update prevSelectionRef.current = new Set(state.selectedIds) when they differ,
and call onSelectionChange(selectedNodes) only from within that effect to avoid
render-side effects.

},
})

ReactDOM.createRoot(document.getElementById('root')!).render(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add runtime check for root element.

The non-null assertion operator (!) assumes the 'root' element exists in the DOM. If the element is missing (e.g., due to malformed HTML or build issues), this will throw an uncaught error at runtime rather than providing a graceful fallback.

🔎 Proposed fix with runtime check
-ReactDOM.createRoot(document.getElementById('root')!).render(
+const rootElement = document.getElementById('root')
+if (!rootElement) {
+  throw new Error('Failed to find the root element')
+}
+ReactDOM.createRoot(rootElement).render(
   <React.StrictMode>
     <QueryClientProvider client={queryClient}>
       <App />
     </QueryClientProvider>
   </React.StrictMode>,
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ReactDOM.createRoot(document.getElementById('root')!).render(
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Failed to find the root element')
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
🤖 Prompt for AI Agents
In @packages/graph-viewer/src/main.tsx around line 16,
ReactDOM.createRoot(document.getElementById('root')!) uses a non-null assertion
which will throw if the 'root' element is missing; change it to first retrieve
const rootEl = document.getElementById('root') and add a runtime check: if
rootEl is null, log a clear error (or render a fallback UI) and return,
otherwise call ReactDOM.createRoot(rootEl). Ensure you reference the existing
ReactDOM.createRoot call and the 'root' id when making this change.

Introduces support for the VITE_API_TARGET environment variable to configure the API base URL in both the API client and TokenPrompt component. This allows deployments to specify the API endpoint via environment configuration, improving flexibility.
@railway-app railway-app bot temporarily deployed to automem / production January 6, 2026 22:32 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI Agents
In @packages/graph-viewer/src/components/TokenPrompt.tsx:
- Line 11: VITE_API_TARGET is a Vite build-time env var (not a runtime deploy
var); update the frontend development/build docs to state that VITE_API_TARGET
must be set before running dev/build, reference the symbol VITE_API_TARGET and
its use in vite.config.ts and TokenPrompt.tsx, show the default fallback URL
(https://automem.up.railway.app), document how to set it in local .env,
.env.local or CI pipeline env settings, and note that changing it requires
re-running the Vite dev server or rebuilding the app so the new value is baked
into the bundle.
🧹 Nitpick comments (4)
packages/graph-viewer/src/api/client.ts (2)

55-58: Token priority favors less secure query parameter.

The priority order places URL query parameter first, which is the least secure method (sent to server, logged). Hash-based tokens are more secure (client-side only). Consider reordering to: hash > query > localStorage, or document why query param must have highest priority.

🔎 Proposed reordering for better security
 function getToken(): string | null {
-  // Priority: URL query param > URL hash > localStorage
-  return getTokenFromQuery() || getTokenFromHash() || localStorage.getItem('automem_token')
+  // Priority: URL hash > URL query param > localStorage
+  // Hash is more secure (client-side only), query param kept for backward compatibility
+  return getTokenFromHash() || getTokenFromQuery() || localStorage.getItem('automem_token')
 }

47-47: Document the hardcoded fallback URL.

Line 47 falls back to a hardcoded Railway deployment URL. Consider documenting this choice or making it configurable via environment variable to avoid surprises in different deployment contexts.

packages/graph-viewer/src/components/TokenPrompt.tsx (2)

10-12: Code duplication with getEnvApiTarget().

The environment variable trimming logic here duplicates the getEnvApiTarget() function in client.ts (lines 11-17). Consider reusing that utility to reduce duplication.

🔎 Proposed refactor to reuse existing utility
+import { checkHealth, setServerConfig, getEnvApiTarget } from '../api/client'

 export function TokenPrompt({ onSubmit }: TokenPromptProps) {
-  const defaultServerUrl =
-    (import.meta.env.VITE_API_TARGET && import.meta.env.VITE_API_TARGET.trim()) ||
-    'https://automem.up.railway.app'
+  const defaultServerUrl = getEnvApiTarget() || 'https://automem.up.railway.app'
   const [serverUrl, setServerUrl] = useState(defaultServerUrl)

Note: This requires exporting getEnvApiTarget from client.ts.


66-73: Consider moving autoFocus to the first input.

Line 94 sets autoFocus on the API Token field, but users might need to modify the Server URL first. Consider placing autoFocus on the Server URL input (line 66) for better UX flow, or omit it entirely since both fields are visible.

Also applies to: 88-96

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b9e47ae and 7d466a5.

📒 Files selected for processing (2)
  • packages/graph-viewer/src/api/client.ts
  • packages/graph-viewer/src/components/TokenPrompt.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
packages/graph-viewer/src/components/TokenPrompt.tsx (1)
packages/graph-viewer/src/api/client.ts (2)
  • checkHealth (228-232)
  • setServerConfig (75-78)
packages/graph-viewer/src/api/client.ts (1)
packages/graph-viewer/src/lib/types.ts (3)
  • GraphSnapshot (57-73)
  • GraphNeighbors (75-84)
  • GraphStats (86-107)
🔇 Additional comments (5)
packages/graph-viewer/src/api/client.ts (3)

122-136: Well-structured parameter interfaces.

The parameter interfaces (SnapshotParams, ProjectedParams, NeighborsParams) are clearly defined with optional fields and appropriate types. The camelCase-to-snake_case parameter mapping in the API functions is correct.

Also applies to: 177-181


228-232: Health check correctly unauthenticated.

The checkHealth function appropriately doesn't require authentication, making it suitable for connection validation before credentials are configured.


66-73: Token still exposed in URL query parameters (unresolved from previous review).

The addTokenToUrl function continues to append the API token as a query parameter (api_key=...) across all API calls. Query parameters appear in browser history, server logs, and proxy logs, creating a security risk.

As noted in the previous review, switching to Authorization: Bearer <token> headers (now that getAuthHeaders exists) would be more secure. This requires confirming the backend accepts Bearer token authentication.

Verify backend support for Bearer token authentication:

#!/bin/bash
# Check if the backend API endpoints support Authorization header with Bearer tokens
rg -n -C3 "Authorization|Bearer|api_key" automem/api/

Also applies to: 146-146, 172-172, 195-195, 201-201, 210-210, 220-220

⛔ Skipped due to learnings
Learnt from: CR
Repo: verygoodplugins/automem PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-09T02:15:22.291Z
Learning: Applies to automem/app.py : Support authentication via Bearer token, X-API-Key header, or query parameter for API endpoints
packages/graph-viewer/src/components/TokenPrompt.tsx (2)

18-37: Form submission logic is well-structured.

The handler properly validates inputs, tests connectivity via checkHealth() before storing credentials, and provides clear error feedback. The flow is secure and user-friendly.


39-144: UI implementation is polished and accessible.

The form provides clear labels, visual feedback (loading spinner, error messages), proper input types (password for token, url for server), and helpful privacy/documentation notes. The gradient styling and responsive layout are well-executed.


export function TokenPrompt({ onSubmit }: TokenPromptProps) {
const defaultServerUrl =
(import.meta.env.VITE_API_TARGET && import.meta.env.VITE_API_TARGET.trim()) ||
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Comprehensive search for VITE_API_TARGET documentation and configuration
echo "=== Searching for VITE_API_TARGET references ==="
rg "VITE_API_TARGET" -t js -t ts -t json -t md --no-heading -i

echo ""
echo "=== Looking for .env files and templates ==="
fd -t f "\.(env|example|template)" 

echo ""
echo "=== Searching for Docker configuration ==="
fd -t f "Dockerfile|docker-compose"

echo ""
echo "=== Looking for build/deployment documentation ==="
fd -t f "README|DEPLOY|BUILD|CONTRIBUTING" -i

Repository: verygoodplugins/automem

Length of output: 1216


🏁 Script executed:

#!/bin/bash
# Check documentation and configuration files for VITE_API_TARGET

echo "=== Contents of vite.config.ts (VITE_API_TARGET sections) ==="
rg -A 2 -B 2 "VITE_API_TARGET" packages/graph-viewer/vite.config.ts

echo ""
echo "=== Check Dockerfile for environment variable setup ==="
cat Dockerfile

echo ""
echo "=== Check docker-compose.yml ==="
cat docker-compose.yml

echo ""
echo "=== Check RAILWAY_DEPLOYMENT.md ==="
cat docs/RAILWAY_DEPLOYMENT.md

echo ""
echo "=== Check main README.md for build/deployment sections ==="
head -100 README.md

Repository: verygoodplugins/automem

Length of output: 22232


🏁 Script executed:

#!/bin/bash
# Search more specifically for VITE_API_TARGET in deployment docs and env templates

echo "=== Check if VITE_API_TARGET mentioned in RAILWAY_DEPLOYMENT.md ==="
rg "VITE_API_TARGET" docs/RAILWAY_DEPLOYMENT.md

echo ""
echo "=== Search for all .env* files anywhere ==="
fd -t f "\.env"

echo ""
echo "=== Check if there's frontend build docs ==="
fd -t f "README" packages/graph-viewer/

echo ""
echo "=== Full vite.config.ts to see complete documentation ==="
head -50 packages/graph-viewer/vite.config.ts

echo ""
echo "=== Check api/client.ts usage context ==="
rg -B 3 -A 3 "import.meta.env.VITE_API_TARGET" packages/graph-viewer/src/api/client.ts

Repository: verygoodplugins/automem

Length of output: 2026


🏁 Script executed:

#!/bin/bash
# Final check: verify the complete picture of VITE_API_TARGET documentation

echo "=== Check all files mentioning VITE_API_TARGET ==="
rg "VITE_API_TARGET" -l

echo ""
echo "=== Check Dockerfile for any env var documentation ==="
grep -i "ENV\|ARG" Dockerfile | head -20

echo ""
echo "=== Check if docker-compose mentions VITE_API_TARGET ==="
rg "VITE_API_TARGET" docker-compose.yml

echo ""
echo "=== Check frontend package.json for build script ==="
cat packages/graph-viewer/package.json | grep -A 5 '"scripts"'

Repository: verygoodplugins/automem

Length of output: 629


Clarify VITE_API_TARGET is a build-time variable and document it in development guides.

VITE_API_TARGET at line 11 is a build-time environment variable (used by Vite during development and build), not a runtime deployment variable. It currently has only an inline comment in vite.config.ts. Document it in frontend development/build documentation so developers know to set it when building locally or in CI/CD pipelines to override the default API endpoint (https://automem.up.railway.app).

🤖 Prompt for AI Agents
In @packages/graph-viewer/src/components/TokenPrompt.tsx at line 11,
VITE_API_TARGET is a Vite build-time env var (not a runtime deploy var); update
the frontend development/build docs to state that VITE_API_TARGET must be set
before running dev/build, reference the symbol VITE_API_TARGET and its use in
vite.config.ts and TokenPrompt.tsx, show the default fallback URL
(https://automem.up.railway.app), document how to set it in local .env,
.env.local or CI pipeline env settings, and note that changing it requires
re-running the Vite dev server or rebuilding the app so the new value is baked
into the bundle.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app.py (1)

161-164: Refine path matching to prevent unintended bypasses.

The current implementation uses request.path.startswith("/viewer") which could match unintended paths like /viewer-admin, /viewerdata, or /viewer2. This could inadvertently bypass authentication for routes that weren't intended to be public.

🔧 Recommended fix: Use precise path matching
-    # Allow unauthenticated access to the graph viewer (static files)
-    # Token is passed via URL hash fragment (client-side only)
-    if request.path.startswith("/viewer"):
-        return
+    # Allow unauthenticated access to the graph viewer (static files)
+    # Token is passed via URL hash fragment (client-side only)
+    if request.path == "/viewer" or request.path.startswith("/viewer/"):
+        return

This ensures only /viewer and /viewer/* paths bypass authentication, preventing false matches.

🤖 Fix all issues with AI agents
In @app.py:
- Around line 115-120: The CORS setup currently uses a wildcard origin ("*")
which is unsafe for production; update the CORS initialization (where CORS(app,
resources=... ) is called) to read allowed origins from an environment variable
(e.g., CORS_ORIGINS) and pass those specific domains instead of "*", parsing a
comma-separated list into the origins value and falling back to a safe default
(like localhost-only) for development; ensure the updated logic only enables "*"
in non-production environments and uses explicit origins in production.
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d466a5 and 6233ebe.

📒 Files selected for processing (3)
  • .gitignore
  • AGENTS.md
  • app.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • AGENTS.md
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Run 'black .' to format Python code before committing
Run 'flake8' to lint Python code for quality issues

**/*.py: Python files must use type hints
Indent Python code with 4 spaces; maintain line length of 100 characters (enforced by Black)
Use snake_case for module and function names
Use PascalCase for class names
Use UPPER_SNAKE_CASE for constants
Use Black for code formatting
Use Isort with profile=black for import sorting
Use Flake8 for linting Python code

Files:

  • app.py
🧠 Learnings (1)
📚 Learning: 2025-12-09T02:15:22.291Z
Learnt from: CR
Repo: verygoodplugins/automem PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-09T02:15:22.291Z
Learning: Applies to automem/app.py : Support authentication via Bearer token, X-API-Key header, or query parameter for API endpoints

Applied to files:

  • app.py
🧬 Code graph analysis (1)
app.py (1)
automem/api/viewer.py (2)
  • create_viewer_blueprint (21-65)
  • is_viewer_enabled (68-70)
🔇 Additional comments (6)
.gitignore (1)

30-32: LGTM!

The new entries correctly ignore the Graph Viewer frontend build artifacts (dist/, node_modules/) and the served static assets directory (automem/static/viewer/), aligning with the Dockerfile's multi-stage build that outputs frontend assets to automem/static/viewer/. While the specific graph-viewer patterns are technically covered by existing global patterns (lines 23 and 27), being explicit improves clarity and makes the .gitignore self-documenting for the new frontend subsystem.

app.py (5)

31-31: LGTM: CORS import added.

The import is correctly placed with other Flask imports and necessary for the CORS configuration below.


152-154: LGTM: CORS preflight bypass implemented correctly.

Allowing OPTIONS requests to bypass authentication is required by the CORS specification for preflight requests. This is standard practice and necessary for cross-origin requests to work properly.


3633-3633: LGTM: Viewer blueprint imports added correctly.

The imports for create_viewer_blueprint and is_viewer_enabled from automem.api.viewer are appropriately placed with other blueprint imports.


3746-3750: LGTM: Viewer blueprint registration implemented correctly.

The conditional registration of the viewer blueprint follows the established pattern and includes helpful logging. The feature flag approach allows the Graph Viewer to be optionally enabled via the ENABLE_GRAPH_VIEWER environment variable.


3147-3148: Add return type hint for coding guidelines compliance.

The require_api_token function is missing a return type hint. According to the coding guidelines, Python files must use type hints.

📝 Proposed fix
 @app.before_request
-def require_api_token() -> None:
+def require_api_token() -> None:
     if not API_TOKEN:
         return

Wait, checking the current code... Line 3148 shows:

def require_api_token() -> None:

Actually, the return type hint is already present in the provided code. Let me re-check...

Looking at line 3148 in the annotated code:

3147 @app.before_request
3148 def require_api_token() -> None:

The type hint -> None is already there. This is correct.

Actually, upon closer inspection, the return type hint is already present at line 3148. No changes needed.

Comment on lines +115 to +120
CORS(
app,
resources={
r"/*": {"origins": "*", "allow_headers": ["Content-Type", "X-API-Key", "Authorization"]}
},
) # Enable CORS for all routes
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict CORS origins to known domains in production.

The wildcard origin "*" allows any website to make cross-origin requests to this API, potentially exposing sensitive memory data to malicious sites. While convenient for development, this poses a significant security risk in production.

🔒 Recommended fix: Use environment-based origin configuration
+# Parse allowed origins from environment or use secure defaults
+CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173").split(",")
+
 CORS(
     app,
     resources={
-        r"/*": {"origins": "*", "allow_headers": ["Content-Type", "X-API-Key", "Authorization"]}
+        r"/*": {
+            "origins": CORS_ORIGINS,
+            "allow_headers": ["Content-Type", "X-API-Key", "Authorization"],
+        }
     },
-)  # Enable CORS for all routes
+)

For production deployments, set CORS_ORIGINS to your frontend domain(s):

CORS_ORIGINS="https://yourdomain.com,https://app.yourdomain.com"
🤖 Prompt for AI Agents
In @app.py around lines 115 - 120, The CORS setup currently uses a wildcard
origin ("*") which is unsafe for production; update the CORS initialization
(where CORS(app, resources=... ) is called) to read allowed origins from an
environment variable (e.g., CORS_ORIGINS) and pass those specific domains
instead of "*", parsing a comma-separated list into the origins value and
falling back to a safe default (like localhost-only) for development; ensure the
updated logic only enables "*" in non-production environments and uses explicit
origins in production.

Disabled the focus/spotlight mode UI and related logic throughout the Graph Viewer app, including keyboard shortcuts and radial menu options. The 'Enter XR' button now only appears when WebXR is supported in a secure context. Improved bimanual pinch activation and smoothing for pan/zoom/rotate gestures, and updated hand overlays and control badges for clearer feedback. Also fixed the reset view callback wiring so Reset View works again.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@packages/graph-viewer/src/components/HandControlOverlay.tsx`:
- Line 41: In HandControlOverlay replace the no-op ternary that sets const m =
lock.mode === 'idle' ? lock.metrics : lock.metrics with a direct assignment to
const m = lock.metrics; locate the initialization of m in that component (the
variable/constant named m and the lock object) and simplify it to the single
direct assignment, or if different behavior was intended for 'idle' vs other
modes, implement the correct branch logic instead of the duplicate ternary.

In `@packages/graph-viewer/src/components/RadialMenu.tsx`:
- Around line 89-94: The handleCopyId callback currently calls
navigator.clipboard.writeText(node.id) without handling rejections; update
handleCopyId (the useCallback function) to handle the returned Promise by making
it async or chaining .then/.catch so clipboard failures are caught and do not
cause unhandled rejections. On success, continue to call onCopyId?.(node.id) and
onClose(); on failure, suppress or log the error (e.g., call process/log
function or noop) and still call onClose() if appropriate so the UI remains
responsive. Ensure references to node.id, onCopyId, onClose, and
navigator.clipboard.writeText are used to locate and modify the code.

In `@packages/graph-viewer/src/hooks/useKeyboardNavigation.ts`:
- Around line 169-213: The arrow-key handlers in the shortcuts map (ArrowUp,
ArrowDown, ArrowLeft, ArrowRight inside useKeyboardNavigation.ts) should call
event.preventDefault() at the start of each action to stop the browser from
scrolling while navigating nodes; update the action functions for ArrowUp,
ArrowDown, ArrowLeft and ArrowRight to call event.preventDefault() before
computing findNodeInDirection or calling onNodeSelect so navigation keys only
affect node selection.
♻️ Duplicate comments (1)
packages/graph-viewer/src/components/GraphCanvas.tsx (1)

863-870: Avoid per-frame Vector3 allocations in the pinch-selection loop.
This is the same per-node allocation hot-spot as before and can add heavy GC pressure at 5k nodes.

🔧 Suggested reuse pattern
+  // Reusable vectors for pinch selection (avoid per-node allocations)
+  const tempWorldPos = useMemo(() => new THREE.Vector3(), [])
+  const tempProjected = useMemo(() => new THREE.Vector3(), [])
  ...
   for (const n of layoutNodes) {
-    const worldPos = new THREE.Vector3(n.x ?? 0, n.y ?? 0, n.z ?? 0)
-    group.localToWorld(worldPos)
-    const projected = worldPos.project(camera)
+    tempWorldPos.set(n.x ?? 0, n.y ?? 0, n.z ?? 0)
+    group.localToWorld(tempWorldPos)
+    tempProjected.copy(tempWorldPos).project(camera)
-    const screenX = ((projected.x + 1) / 2) * rect.width
-    const screenY = ((-projected.y + 1) / 2) * rect.height
+    const screenX = ((tempProjected.x + 1) / 2) * rect.width
+    const screenY = ((-tempProjected.y + 1) / 2) * rect.height
🧹 Nitpick comments (2)
packages/graph-viewer/src/components/RadialMenu.tsx (1)

197-245: Add aria labels for icon-only buttons.
The close button and radial items are icon-only; screen readers will miss intent. Consider aria-label (and aria-disabled) on buttons.

packages/graph-viewer/src/components/GraphCanvas.tsx (1)

1282-1305: Consider gating/removing debug console logs.
The pointer handlers log on every click path, which can flood production consoles. A debug flag or dev-only guard would keep signal without noise.

Also applies to: 1608-1621

? { text: `ACQUIRING (${lock.frames})`, color: 'bg-yellow-500/20 text-yellow-200 border-yellow-400/30' }
: { text: 'IDLE', color: 'bg-slate-500/20 text-slate-200 border-slate-400/30' }

const m = lock.mode === 'idle' ? lock.metrics : lock.metrics
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

No-op ternary expression — both branches return the same value.

This conditional serves no purpose as lock.metrics is returned regardless of the mode. Simplify to a direct assignment:

Suggested fix
-  const m = lock.mode === 'idle' ? lock.metrics : lock.metrics
+  const m = lock.metrics
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const m = lock.mode === 'idle' ? lock.metrics : lock.metrics
const m = lock.metrics
🤖 Prompt for AI Agents
In `@packages/graph-viewer/src/components/HandControlOverlay.tsx` at line 41, In
HandControlOverlay replace the no-op ternary that sets const m = lock.mode ===
'idle' ? lock.metrics : lock.metrics with a direct assignment to const m =
lock.metrics; locate the initialization of m in that component (the
variable/constant named m and the lock object) and simplify it to the single
direct assignment, or if different behavior was intended for 'idle' vs other
modes, implement the correct branch logic instead of the duplicate ternary.

Comment on lines +89 to +94
// Copy ID to clipboard
const handleCopyId = useCallback(() => {
navigator.clipboard.writeText(node.id)
onCopyId?.(node.id)
onClose()
}, [node.id, onCopyId, onClose])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd packages/graph-viewer && find . -name "RadialMenu.tsx" -type f

Repository: verygoodplugins/automem

Length of output: 98


🏁 Script executed:

cd packages/graph-viewer && head -150 src/components/RadialMenu.tsx | tail -100

Repository: verygoodplugins/automem

Length of output: 2521


🏁 Script executed:

cd packages/graph-viewer && cat -n src/components/RadialMenu.tsx | sed -n '80,110p'

Repository: verygoodplugins/automem

Length of output: 1051


🏁 Script executed:

cd packages/graph-viewer && rg "clipboard" --type ts --type tsx -i -B 2 -A 2

Repository: verygoodplugins/automem

Length of output: 94


🏁 Script executed:

cd packages/graph-viewer && cat -n src/components/RadialMenu.tsx | sed -n '1,50p'

Repository: verygoodplugins/automem

Length of output: 1495


🏁 Script executed:

cd packages/graph-viewer && grep -n "aria-label\|title=" src/components/RadialMenu.tsx | head -20

Repository: verygoodplugins/automem

Length of output: 103


🏁 Script executed:

cd packages/graph-viewer && cat -n src/components/RadialMenu.tsx | sed -n '200,280p'

Repository: verygoodplugins/automem

Length of output: 3213


🏁 Script executed:

cd packages/graph-viewer && wc -l src/components/RadialMenu.tsx

Repository: verygoodplugins/automem

Length of output: 100


🏁 Script executed:

cd packages/graph-viewer && cat -n src/components/RadialMenu.tsx | sed -n '230,260p'

Repository: verygoodplugins/automem

Length of output: 1409


🏁 Script executed:

cd packages/graph-viewer && rg "clipboard" -t ts -B 2 -A 2

Repository: verygoodplugins/automem

Length of output: 431


🏁 Script executed:

cd packages/graph-viewer && cat -n src/components/RadialMenu.tsx | sed -n '195,250p'

Repository: verygoodplugins/automem

Length of output: 2569


🏁 Script executed:

find . -name "package.json" -type f | head -5 | xargs grep -l "automem\|graph-viewer"

Repository: verygoodplugins/automem

Length of output: 133


🏁 Script executed:

cd packages/graph-viewer && cat -n src/components/RadialMenu.tsx | sed -n '89,95p'

Repository: verygoodplugins/automem

Length of output: 301


🏁 Script executed:

cd packages && grep -r "catch\|\.then" --include="*.tsx" --include="*.ts" | grep -i "clipboard\|writeText" | head -5

Repository: verygoodplugins/automem

Length of output: 49


🏁 Script executed:

cd packages/graph-viewer && cat -n src/components/RadialMenu.tsx | sed -n '195,212p'

Repository: verygoodplugins/automem

Length of output: 813


Handle clipboard API rejections to prevent unhandled promise rejections.
navigator.clipboard.writeText() returns a Promise that can reject due to permissions, insecure contexts, or unavailable clipboard. The current code doesn't handle these failures, risking unhandled promise rejections. Add error handling to catch and suppress or gracefully handle failures.

🤖 Prompt for AI Agents
In `@packages/graph-viewer/src/components/RadialMenu.tsx` around lines 89 - 94,
The handleCopyId callback currently calls navigator.clipboard.writeText(node.id)
without handling rejections; update handleCopyId (the useCallback function) to
handle the returned Promise by making it async or chaining .then/.catch so
clipboard failures are caught and do not cause unhandled rejections. On success,
continue to call onCopyId?.(node.id) and onClose(); on failure, suppress or log
the error (e.g., call process/log function or noop) and still call onClose() if
appropriate so the UI remains responsive. Ensure references to node.id,
onCopyId, onClose, and navigator.clipboard.writeText are used to locate and
modify the code.

Comment on lines +169 to +213
const shortcuts: KeyboardShortcuts = {
// Navigation
ArrowUp: {
description: 'Navigate up',
action: () => {
const node = event.shiftKey
? findNodeInDirection('backward')
: findNodeInDirection('up')
if (node) onNodeSelect(node)
},
},
ArrowDown: {
description: 'Navigate down',
action: () => {
const node = event.shiftKey
? findNodeInDirection('forward')
: findNodeInDirection('down')
if (node) onNodeSelect(node)
},
},
ArrowLeft: {
description: 'Navigate left',
action: () => {
const node = findNodeInDirection('left')
if (node) onNodeSelect(node)
},
},
ArrowRight: {
description: 'Navigate right',
action: () => {
const node = findNodeInDirection('right')
if (node) onNodeSelect(node)
},
},
Tab: {
description: 'Next/previous node',
action: () => {
event.preventDefault()
const node = event.shiftKey
? navigateSequential('previous')
: navigateSequential('next')
if (node) onNodeSelect(node)
},
},

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd "useKeyboardNavigation.ts" --type f

Repository: verygoodplugins/automem

Length of output: 123


🏁 Script executed:

cat -n packages/graph-viewer/src/hooks/useKeyboardNavigation.ts | head -250

Repository: verygoodplugins/automem

Length of output: 8556


🏁 Script executed:

cat -n packages/graph-viewer/src/hooks/useKeyboardNavigation.ts | sed -n '250,300p'

Repository: verygoodplugins/automem

Length of output: 2090


🏁 Script executed:

cat -n packages/graph-viewer/src/hooks/useKeyboardNavigation.ts | sed -n '290,310p'

Repository: verygoodplugins/automem

Length of output: 857


Prevent default browser scrolling for arrow-key navigation.

Arrow keys can scroll the page while you're navigating nodes. Add event.preventDefault() to the arrow key handlers to prevent this interference.

🔧 Suggested fix
        ArrowUp: {
          description: 'Navigate up',
          action: () => {
+           event.preventDefault()
            const node = event.shiftKey
              ? findNodeInDirection('backward')
              : findNodeInDirection('up')
            if (node) onNodeSelect(node)
          },
        },
        ArrowDown: {
          description: 'Navigate down',
          action: () => {
+           event.preventDefault()
            const node = event.shiftKey
              ? findNodeInDirection('forward')
              : findNodeInDirection('down')
            if (node) onNodeSelect(node)
          },
        },
        ArrowLeft: {
          description: 'Navigate left',
          action: () => {
+           event.preventDefault()
            const node = findNodeInDirection('left')
            if (node) onNodeSelect(node)
          },
        },
        ArrowRight: {
          description: 'Navigate right',
          action: () => {
+           event.preventDefault()
            const node = findNodeInDirection('right')
            if (node) onNodeSelect(node)
          },
        },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const shortcuts: KeyboardShortcuts = {
// Navigation
ArrowUp: {
description: 'Navigate up',
action: () => {
const node = event.shiftKey
? findNodeInDirection('backward')
: findNodeInDirection('up')
if (node) onNodeSelect(node)
},
},
ArrowDown: {
description: 'Navigate down',
action: () => {
const node = event.shiftKey
? findNodeInDirection('forward')
: findNodeInDirection('down')
if (node) onNodeSelect(node)
},
},
ArrowLeft: {
description: 'Navigate left',
action: () => {
const node = findNodeInDirection('left')
if (node) onNodeSelect(node)
},
},
ArrowRight: {
description: 'Navigate right',
action: () => {
const node = findNodeInDirection('right')
if (node) onNodeSelect(node)
},
},
Tab: {
description: 'Next/previous node',
action: () => {
event.preventDefault()
const node = event.shiftKey
? navigateSequential('previous')
: navigateSequential('next')
if (node) onNodeSelect(node)
},
},
const shortcuts: KeyboardShortcuts = {
// Navigation
ArrowUp: {
description: 'Navigate up',
action: () => {
event.preventDefault()
const node = event.shiftKey
? findNodeInDirection('backward')
: findNodeInDirection('up')
if (node) onNodeSelect(node)
},
},
ArrowDown: {
description: 'Navigate down',
action: () => {
event.preventDefault()
const node = event.shiftKey
? findNodeInDirection('forward')
: findNodeInDirection('down')
if (node) onNodeSelect(node)
},
},
ArrowLeft: {
description: 'Navigate left',
action: () => {
event.preventDefault()
const node = findNodeInDirection('left')
if (node) onNodeSelect(node)
},
},
ArrowRight: {
description: 'Navigate right',
action: () => {
event.preventDefault()
const node = findNodeInDirection('right')
if (node) onNodeSelect(node)
},
},
Tab: {
description: 'Next/previous node',
action: () => {
event.preventDefault()
const node = event.shiftKey
? navigateSequential('previous')
: navigateSequential('next')
if (node) onNodeSelect(node)
},
},
🤖 Prompt for AI Agents
In `@packages/graph-viewer/src/hooks/useKeyboardNavigation.ts` around lines 169 -
213, The arrow-key handlers in the shortcuts map (ArrowUp, ArrowDown, ArrowLeft,
ArrowRight inside useKeyboardNavigation.ts) should call event.preventDefault()
at the start of each action to stop the browser from scrolling while navigating
nodes; update the action functions for ArrowUp, ArrowDown, ArrowLeft and
ArrowRight to call event.preventDefault() before computing findNodeInDirection
or calling onNodeSelect so navigation keys only affect node selection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants