Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f15695c3f | |||
| c49533c250 | |||
| 4f49cb1bc9 | |||
| 874726b193 | |||
| 5244a12422 | |||
| 5b30764fa8 |
@@ -1,248 +1,470 @@
|
||||
# 🧠 Claude Memory System (claude-mem)
|
||||
<div align="center">
|
||||
|
||||
A real-time memory system for Claude Code that captures, compresses, and retrieves conversation context across sessions using semantic search and vector embeddings.
|
||||
<img src="claude-mem-logo-lm.webp#gh-light-mode-only" alt="claude-mem logo" width="360" height="auto" />
|
||||
<img src="claude-mem-logo-dm.webp#gh-dark-mode-only" alt="claude-mem logo" width="360" height="auto" />
|
||||
|
||||
## ⚡️ Quick Start
|
||||
<p>
|
||||
Memory compression and persistence system for Claude Code conversations
|
||||
</p>
|
||||
|
||||
|
||||
<!-- Badges -->
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/claude-mem">
|
||||
<img src="https://img.shields.io/npm/v/claude-mem.svg" alt="npm version" />
|
||||
</a>
|
||||
<a href="https://github.com/thedotmack/claude-mem/graphs/contributors">
|
||||
<img src="https://img.shields.io/github/contributors/thedotmack/claude-mem" alt="contributors" />
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/thedotmack/claude-mem" alt="last update" />
|
||||
</a>
|
||||
<a href="https://github.com/thedotmack/claude-mem/network/members">
|
||||
<img src="https://img.shields.io/github/forks/thedotmack/claude-mem" alt="forks" />
|
||||
</a>
|
||||
<a href="https://github.com/thedotmack/claude-mem/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/thedotmack/claude-mem" alt="stars" />
|
||||
</a>
|
||||
<a href="https://github.com/thedotmack/claude-mem/issues/">
|
||||
<img src="https://img.shields.io/github/issues/thedotmack/claude-mem" alt="open issues" />
|
||||
</a>
|
||||
<a href="https://github.com/thedotmack/claude-mem/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="license" />
|
||||
</a>
|
||||
<a href="https://nodejs.org/">
|
||||
<img src="https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg" alt="node version" />
|
||||
</a>
|
||||
<a href="https://modelcontextprotocol.io">
|
||||
<img src="https://img.shields.io/badge/MCP-compatible-purple.svg" alt="MCP compatible" />
|
||||
</a>
|
||||
<a href="https://claude.com/claude-code">
|
||||
<img src="https://img.shields.io/badge/Claude%20Code-enabled-orange.svg" alt="Claude Code enabled" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
<a href="https://github.com/thedotmack/claude-mem">Documentation</a>
|
||||
<span> · </span>
|
||||
<a href="https://github.com/thedotmack/claude-mem/issues/">Report Bug</a>
|
||||
<span> · </span>
|
||||
<a href="https://github.com/thedotmack/claude-mem/issues/">Request Feature</a>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<!-- Table of Contents -->
|
||||
# :notebook_with_decorative_cover: Table of Contents
|
||||
|
||||
- [About the Project](#star2-about-the-project)
|
||||
* [Tech Stack](#space_invader-tech-stack)
|
||||
* [Features](#dart-features)
|
||||
- [Getting Started](#toolbox-getting-started)
|
||||
* [Prerequisites](#bangbang-prerequisites)
|
||||
* [Installation](#gear-installation)
|
||||
* [Running Tests](#test_tube-running-tests)
|
||||
- [Usage](#eyes-usage)
|
||||
* [Basic Commands](#basic-commands)
|
||||
* [Hook System](#hook-system)
|
||||
* [Memory Operations](#memory-operations)
|
||||
* [ChromaDB MCP Tools](#chromadb-mcp-tools)
|
||||
* [Advanced Usage](#advanced-usage)
|
||||
- [Architecture](#building_construction-architecture)
|
||||
- [Configuration](#wrench-configuration)
|
||||
- [Roadmap](#compass-roadmap)
|
||||
- [Contributing](#wave-contributing)
|
||||
- [License](#warning-license)
|
||||
- [Contact](#handshake-contact)
|
||||
- [Acknowledgements](#gem-acknowledgements)
|
||||
|
||||
|
||||
|
||||
<!-- About the Project -->
|
||||
## :star2: About the Project
|
||||
|
||||
claude-mem automatically captures, compresses, and retrieves context across Claude Code sessions, enabling true long-term memory through semantic search and intelligent compression.
|
||||
|
||||
Perfect for developers who want their AI assistant to remember project context, past decisions, and conversation history across sessions without manual context management.
|
||||
|
||||
<!-- TechStack -->
|
||||
### :space_invader: Tech Stack
|
||||
|
||||
<details>
|
||||
<summary>Core Technologies</summary>
|
||||
<ul>
|
||||
<li><a href="https://www.typescriptlang.org/">TypeScript</a></li>
|
||||
<li><a href="https://nodejs.org/">Node.js</a></li>
|
||||
<li><a href="https://bun.sh/">Bun</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Storage & Memory</summary>
|
||||
<ul>
|
||||
<li><a href="https://www.trychroma.com/">ChromaDB</a> - Vector database for semantic search</li>
|
||||
<li><a href="https://www.sqlite.org/">SQLite</a> - Metadata and session tracking</li>
|
||||
<li><a href="https://github.com/WiseLibs/better-sqlite3">better-sqlite3</a> - Fast SQLite bindings</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>AI & Integration</summary>
|
||||
<ul>
|
||||
<li><a href="https://github.com/anthropics/anthropic-sdk-typescript">Anthropic Agent SDK</a> - Async compression</li>
|
||||
<li><a href="https://modelcontextprotocol.io">Model Context Protocol (MCP)</a> - Tool integration</li>
|
||||
<li><a href="https://claude.com/claude-code">Claude Code</a> - Streaming hooks</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Features -->
|
||||
### :dart: Features
|
||||
|
||||
- :brain: **Automatic Memory Compression** - Real-time conversation capture and intelligent summarization
|
||||
- :mag: **Semantic Search** - ChromaDB-powered vector search for intelligent context retrieval
|
||||
- :package: **Project Isolation** - Memories segregated by project with multi-project support
|
||||
- :arrows_counterclockwise: **Session Persistence** - Context loads automatically at session start and `/clear` command
|
||||
- :dart: **MCP Integration** - 15+ ChromaDB tools via Model Context Protocol
|
||||
- :floppy_disk: **SQLite Storage** - Fast metadata and session tracking with embedded database
|
||||
- :wastebasket: **Smart Trash** - Safe file deletion with recovery capabilities
|
||||
- :zap: **Streaming Hooks** - Sub-50ms overhead for real-time event capture
|
||||
- :robot: **Agent SDK Compression** - Async transcript processing without blocking conversations
|
||||
- :bar_chart: **Session Overviews** - Automatic session summaries with temporal context
|
||||
|
||||
<!-- Getting Started -->
|
||||
## :toolbox: Getting Started
|
||||
|
||||
<!-- Prerequisites -->
|
||||
### :bangbang: Prerequisites
|
||||
|
||||
This project requires Node.js and works best with Claude Code
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- Claude Code with MCP support
|
||||
- macOS/Linux (POSIX-compliant system)
|
||||
- Bun >= 1.0.0 (optional, for development)
|
||||
|
||||
<!-- Installation -->
|
||||
### :gear: Installation
|
||||
|
||||
Install claude-mem globally via npm
|
||||
|
||||
```bash
|
||||
npm install -g claude-mem
|
||||
claude-mem install
|
||||
```
|
||||
|
||||
Restart Claude Code. Memory capture starts automatically.
|
||||
The interactive installer will guide you through three installation scopes:
|
||||
|
||||
## ✨ What It Does
|
||||
- **User** - Install for current user (default, recommended)
|
||||
- **Project** - Install for current project only
|
||||
- **Local** - Install to custom directory
|
||||
|
||||
**Real-Time Memory Capture**
|
||||
- Captures every conversation turn as it happens via streaming hooks
|
||||
- User prompts stored immediately in ChromaDB with atomic facts
|
||||
- Tool responses compressed asynchronously via Agent SDK
|
||||
- Project-based memory isolation with hierarchical metadata
|
||||
- Automatic context loading at session start and `/clear`
|
||||
<!-- Running Tests -->
|
||||
### :test_tube: Running Tests
|
||||
|
||||
**Semantic Search**
|
||||
- Vector embeddings for intelligent retrieval via ChromaDB
|
||||
- Find relevant context from past conversations
|
||||
- Project-aware memory queries with temporal filtering
|
||||
- Date-based search using query text (not metadata)
|
||||
- 15+ MCP tools for memory operations
|
||||
|
||||
**Invisible Operation**
|
||||
- Zero user configuration required
|
||||
- Memory compression happens in background via SDK
|
||||
- SDK transcripts auto-deleted from UI history
|
||||
- Session overviews generated automatically
|
||||
- Live memory viewer with SSE streaming
|
||||
|
||||
**Smart Trash™**
|
||||
- Safe deletion with easy recovery
|
||||
- Timestamped trash entries
|
||||
- One-command restore
|
||||
- Located at `~/.claude-mem/trash/`
|
||||
|
||||
## 🎯 Core Features
|
||||
|
||||
- **Streaming Hooks**: Real-time capture with minimal overhead (<50ms)
|
||||
- **Agent SDK Integration**: Async compression without blocking conversation
|
||||
- **MCP Server**: 15+ ChromaDB tools for memory operations
|
||||
- **Project Isolation**: Memories segregated by project context
|
||||
- **Zero Configuration**: Works out of the box after install
|
||||
- **Embedded Databases**: ChromaDB and SQLite, no external dependencies
|
||||
- **Invisible UX**: Memory operations don't pollute conversation UI
|
||||
- **Live Memory Viewer**: Real-time slideshow of memories via SSE
|
||||
|
||||
## 🧭 Commands
|
||||
To run tests, use the following commands
|
||||
|
||||
```bash
|
||||
# Setup & Status
|
||||
claude-mem install # Install/repair hooks and MCP integration
|
||||
claude-mem status # Check installation and memory stats
|
||||
claude-mem doctor # Run environment and pipeline diagnostics
|
||||
claude-mem uninstall # Remove all hooks
|
||||
# Run all tests
|
||||
bun test
|
||||
|
||||
# Memory Operations
|
||||
claude-mem load-context # View current session context
|
||||
claude-mem logs # View operation logs
|
||||
claude-mem changelog # Generate CHANGELOG.md from memories
|
||||
# Run integration tests
|
||||
npm run test:integration
|
||||
|
||||
# Storage Operations (Used by hooks/SDK)
|
||||
claude-mem store-memory # Store a memory to ChromaDB + SQLite
|
||||
claude-mem store-overview # Store a session overview
|
||||
|
||||
# Smart Trash™
|
||||
claude-mem trash # View trash contents
|
||||
claude-mem restore # Restore from trash
|
||||
claude-mem trash-empty # Permanently delete trash
|
||||
|
||||
# ChromaDB Tools (15+ MCP tools available)
|
||||
claude-mem chroma_* # Direct ChromaDB operations
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 📁 Storage Structure
|
||||
<!-- Usage -->
|
||||
## :eyes: Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
```bash
|
||||
# Check installation status
|
||||
claude-mem status
|
||||
|
||||
# View operation logs
|
||||
claude-mem logs
|
||||
|
||||
# Load context for current project
|
||||
claude-mem load-context --project my-project
|
||||
|
||||
# View compressed memories (interactive)
|
||||
claude-mem restore
|
||||
|
||||
# Manage trash bin
|
||||
claude-mem trash view
|
||||
claude-mem restore
|
||||
claude-mem trash empty
|
||||
```
|
||||
|
||||
### Hook System
|
||||
|
||||
claude-mem integrates with Claude Code via streaming hooks that capture conversation events:
|
||||
|
||||
- **user-prompt-submit** - Captures user prompts in real-time
|
||||
- **post-tool-use** - Spawns Agent SDK for async compression
|
||||
- **stop-streaming** - Generates session overview and cleanup
|
||||
- **session-start** - Loads relevant context automatically
|
||||
|
||||
Hooks are configured during installation with a 180-second timeout and run transparently in the background.
|
||||
|
||||
### Memory Operations
|
||||
|
||||
#### Manual Compression
|
||||
|
||||
```bash
|
||||
claude-mem compress
|
||||
```
|
||||
|
||||
Compress Claude Code transcripts into searchable memories with semantic embeddings.
|
||||
|
||||
#### Context Loading
|
||||
|
||||
```bash
|
||||
# Load last 10 memories for current project
|
||||
claude-mem load-context
|
||||
|
||||
# Load specific number of memories
|
||||
claude-mem load-context --count 20
|
||||
|
||||
# Filter by project
|
||||
claude-mem load-context --project my-app
|
||||
|
||||
# Output raw JSON
|
||||
claude-mem load-context --raw
|
||||
```
|
||||
|
||||
#### Trash Management
|
||||
|
||||
claude-mem includes Smart Trash for safe file operations:
|
||||
|
||||
```bash
|
||||
# Move files to trash
|
||||
claude-mem trash file.txt
|
||||
claude-mem trash -r directory/
|
||||
|
||||
# View trash contents
|
||||
claude-mem trash view
|
||||
|
||||
# Restore files interactively
|
||||
claude-mem restore
|
||||
|
||||
# Empty trash permanently
|
||||
claude-mem trash empty
|
||||
```
|
||||
|
||||
### ChromaDB MCP Tools
|
||||
|
||||
claude-mem exposes 15+ ChromaDB operations via MCP:
|
||||
|
||||
```bash
|
||||
# List collections
|
||||
claude-mem chroma-list-collections
|
||||
|
||||
# Create collection
|
||||
claude-mem chroma-create-collection --collection-name memories
|
||||
|
||||
# Query documents semantically
|
||||
claude-mem chroma-query-documents \
|
||||
--collection-name memories \
|
||||
--query-texts '["authentication implementation"]' \
|
||||
--n-results 5
|
||||
|
||||
# Add documents
|
||||
claude-mem chroma-add-documents \
|
||||
--collection-name memories \
|
||||
--documents '["content here"]' \
|
||||
--ids '["mem-001"]'
|
||||
|
||||
# Get documents by ID
|
||||
claude-mem chroma-get-documents \
|
||||
--collection-name memories \
|
||||
--ids '["mem-001"]'
|
||||
|
||||
# Update documents
|
||||
claude-mem chroma-update-documents \
|
||||
--collection-name memories \
|
||||
--ids '["mem-001"]' \
|
||||
--documents '["updated content"]'
|
||||
|
||||
# Delete documents
|
||||
claude-mem chroma-delete-documents \
|
||||
--collection-name memories \
|
||||
--ids '["mem-001"]'
|
||||
```
|
||||
|
||||
See all available Chroma MCP commands with `claude-mem --help`.
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
#### Session Title Generation
|
||||
|
||||
```bash
|
||||
# Generate title and subtitle from prompt
|
||||
claude-mem generate-title "implemented authentication with OAuth"
|
||||
|
||||
# Output as JSON
|
||||
claude-mem generate-title "fixed bug in checkout" --json
|
||||
|
||||
# Save to database
|
||||
claude-mem generate-title "added feature" --session-id abc123 --save
|
||||
```
|
||||
|
||||
#### Diagnostics
|
||||
|
||||
```bash
|
||||
# Run environment diagnostics
|
||||
claude-mem doctor
|
||||
|
||||
# Output as JSON
|
||||
claude-mem doctor --json
|
||||
```
|
||||
|
||||
#### Changelog Generation
|
||||
|
||||
```bash
|
||||
# Generate changelog from memories
|
||||
claude-mem changelog
|
||||
|
||||
# Preview without saving
|
||||
claude-mem changelog --preview
|
||||
|
||||
# Generate for specific version
|
||||
claude-mem changelog --generate 3.9.0
|
||||
|
||||
# Search historical versions
|
||||
claude-mem changelog --historical 5
|
||||
```
|
||||
|
||||
## :building_construction: Architecture
|
||||
|
||||
### Storage Structure
|
||||
|
||||
```
|
||||
~/.claude-mem/
|
||||
├── archives/ # Compressed transcript backups
|
||||
├── chroma/ # ChromaDB vector database
|
||||
├── archives/ # Compressed transcript backups
|
||||
├── index/ # Legacy JSONL memory indices
|
||||
├── hooks/ # Hook configuration files
|
||||
├── trash/ # Smart Trash™ with recovery
|
||||
├── trash/ # Smart Trash with recovery
|
||||
├── hooks/ # Hook configurations
|
||||
├── logs/ # Operation logs
|
||||
└── claude-mem.db # SQLite metadata database
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
### Memory System
|
||||
|
||||
**Storage Layers**
|
||||
- **ChromaDB**: Vector database for semantic search with embeddings
|
||||
- **SQLite**: Metadata index (`~/.claude-mem/claude-mem.db`) with sessions, memories, overviews
|
||||
- **Archives**: Compressed transcript backups in `~/.claude-mem/archives/`
|
||||
**Rolling Memory** - Real-time conversation turn capture via hooks with immediate ChromaDB storage
|
||||
|
||||
**Hook System** (`hook-templates/`)
|
||||
- `user-prompt-submit.js`: Captures user prompts immediately, stores in ChromaDB
|
||||
- `post-tool-use.js`: Spawns Agent SDK for async compression of tool responses
|
||||
- `stop.js`: Generates session overview, cleans up SDK transcripts from UI
|
||||
- `session-start.js`: Loads relevant context on startup and `/clear`
|
||||
- Shared utilities: `hook-helpers.js`, `hook-prompt-renderer.js`, `config-loader.js`, `path-resolver.js`
|
||||
**TranscriptCompressor** - Intelligent chunking and compression of large conversations
|
||||
|
||||
**CLI Commands** (`src/commands/`)
|
||||
- Installation, status, and diagnostics
|
||||
- Memory storage and retrieval
|
||||
- Changelog generation from memories
|
||||
- Smart Trash™ management
|
||||
- 15+ dynamic ChromaDB MCP tool wrappers
|
||||
**MCP Server** - 15+ ChromaDB tools for memory operations and semantic search
|
||||
|
||||
**Services** (`src/services/`)
|
||||
- SQLite stores: Session, Memory, Overview, Diagnostics, TranscriptEvent
|
||||
- Path discovery for project detection
|
||||
- Rolling settings and logs
|
||||
**SQLite Backend** - Session tracking, metadata management, and diagnostics storage
|
||||
|
||||
## 🔍 How Memory Search Works
|
||||
### Hook Integration
|
||||
|
||||
**Semantic Search Best Practices**:
|
||||
```typescript
|
||||
// ALWAYS include project name to avoid cross-contamination
|
||||
mcp__claude-mem__chroma_query_documents({
|
||||
collection_name: "claude_memories",
|
||||
query_texts: ["claude-mem authentication bug"],
|
||||
n_results: 10
|
||||
})
|
||||
Hooks communicate via JSON stdin/stdout and run with minimal overhead:
|
||||
|
||||
// Include dates for temporal search (dates in query text, not metadata)
|
||||
mcp__claude-mem__chroma_query_documents({
|
||||
collection_name: "claude_memories",
|
||||
query_texts: ["project-name 2025-10-02 feature implementation"],
|
||||
n_results: 5
|
||||
})
|
||||
1. **user-prompt-submit** - Stores user prompt immediately in ChromaDB
|
||||
2. **post-tool-use** - Spawns Agent SDK subprocess for async compression
|
||||
3. **stop-streaming** - Generates session overview, deletes SDK transcript
|
||||
4. **session-start** - Loads project-specific context invisibly
|
||||
|
||||
// Intent-based queries work better than keyword matching
|
||||
mcp__claude-mem__chroma_query_documents({
|
||||
collection_name: "claude_memories",
|
||||
query_texts: ["implementing oauth flow"],
|
||||
n_results: 10
|
||||
})
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── bin/ # CLI entry point
|
||||
├── commands/ # Command implementations
|
||||
├── core/ # Core compression logic
|
||||
├── services/ # SQLite, ChromaDB, path discovery
|
||||
├── shared/ # Configuration and utilities
|
||||
└── mcp-server.ts # MCP server implementation
|
||||
|
||||
hook-templates/ # Hook source files
|
||||
dist/ # Minified production bundle
|
||||
test/ # Unit and integration tests
|
||||
```
|
||||
|
||||
**What Doesn't Work** (Avoid These!)
|
||||
- ❌ Complex `where` filters with `$and`/`$or` - causes errors
|
||||
- ❌ Timestamp comparisons (`$gte`, `$lt`) - stored as strings
|
||||
- ❌ Mixing project filters in where clause - causes "Error finding id"
|
||||
## :wrench: Configuration
|
||||
|
||||
**Storage Collection**: `claude_memories`
|
||||
- Metadata: `project`, `session_id`, `date`, `type`, `concepts`, `files`
|
||||
- Embeddings: Semantic vectors for similarity search
|
||||
- Documents: Atomic facts + full narrative with hierarchical structure
|
||||
### Hook Timeout
|
||||
|
||||
## ✅ Requirements
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- Bun >= 1.0.0 (for development)
|
||||
- Claude Code with MCP support
|
||||
- macOS/Linux (POSIX-compliant)
|
||||
|
||||
## 🛠️ Development
|
||||
Default hook timeout is 180 seconds. Configure during installation:
|
||||
|
||||
```bash
|
||||
# Development mode
|
||||
bun run dev
|
||||
|
||||
# Build production bundle
|
||||
bun run build
|
||||
|
||||
# Build and update hooks (RECOMMENDED for hook changes)
|
||||
bun run build && bun link && claude-mem install --force
|
||||
|
||||
# Run tests
|
||||
bun test # All tests
|
||||
npm run test:integration # Integration tests
|
||||
bun run test:unit # Unit tests only
|
||||
|
||||
# Install from source
|
||||
bun run dev:install
|
||||
|
||||
# Live Memory Viewer
|
||||
npm run memory-stream:server # Start SSE server on :3001
|
||||
|
||||
# Code quality
|
||||
bun run lint
|
||||
bun run format
|
||||
claude-mem install --timeout 300000 # 5 minutes
|
||||
```
|
||||
|
||||
## 🎨 Live Memory Viewer
|
||||
### MCP Server
|
||||
|
||||
Real-time slideshow of memories with SSE streaming:
|
||||
|
||||
1. Start the server: `npm run memory-stream:server`
|
||||
2. Open the viewer at `src/ui/memory-stream/`
|
||||
3. Auto-connects to `~/.claude-mem/claude-mem.db`
|
||||
4. New memories appear instantly as they're created
|
||||
|
||||
Features:
|
||||
- 📡 Live SSE streaming from SQLite WAL changes
|
||||
- 🎬 Auto-slideshow (5s intervals)
|
||||
- ⏸️ Pause/Resume with Space bar
|
||||
- ⌨️ Keyboard navigation (←/→)
|
||||
- 🎨 Cyberpunk neural network aesthetic
|
||||
|
||||
## 🔑 Key Design Decisions
|
||||
|
||||
**Storage Architecture**
|
||||
- Direct ChromaDB writes in `store-memory.ts` command (no async syncing)
|
||||
- Each atomic fact stored as separate document + full narrative document
|
||||
- Hierarchical metadata: project, session, date, type, concepts, files
|
||||
- SQLite for fast metadata queries, ChromaDB for semantic search
|
||||
|
||||
**Hook Infrastructure**
|
||||
- Streaming hooks (<50ms overhead) capture real-time events
|
||||
- Shared utilities in `hook-templates/shared/` for consistency
|
||||
- Force overwrite on install to ensure latest hook code deploys
|
||||
- Milliseconds in `config.json`, seconds in Claude settings
|
||||
|
||||
**Memory Compression**
|
||||
- Agent SDK spawned asynchronously for tool response compression
|
||||
- User prompts stored immediately without blocking
|
||||
- SDK transcripts auto-deleted to keep UI clean
|
||||
- 100:1 compression ratio maintained
|
||||
|
||||
**Search Strategy**
|
||||
- Semantic search via query text (dates embedded in queries)
|
||||
- Avoid complex metadata filters (causes ChromaDB errors)
|
||||
- Always include project name in queries for isolation
|
||||
- Multiple query phrasings for better coverage
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
Skip MCP server installation if needed:
|
||||
|
||||
```bash
|
||||
claude-mem status # Check installation health
|
||||
claude-mem doctor # Run full diagnostics
|
||||
claude-mem install --force # Repair installation
|
||||
claude-mem logs # View recent operations
|
||||
claude-mem install --skip-mcp
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
### Force Reinstall
|
||||
|
||||
AGPL-3.0 - See LICENSE file for details
|
||||
```bash
|
||||
claude-mem install --force
|
||||
```
|
||||
|
||||
<!-- Roadmap -->
|
||||
## :compass: Roadmap
|
||||
|
||||
* [x] Real-time conversation capture with streaming hooks
|
||||
* [x] ChromaDB vector storage for semantic search
|
||||
* [x] SQLite metadata and session tracking
|
||||
* [x] MCP server with 15+ ChromaDB tools
|
||||
* [x] Smart Trash for safe file deletion
|
||||
* [x] Automatic session overviews
|
||||
* [ ] Web UI for memory visualization
|
||||
* [ ] Cross-platform Windows support
|
||||
* [ ] Memory analytics and insights
|
||||
|
||||
<!-- Contributing -->
|
||||
## :wave: Contributing
|
||||
|
||||
<a href="https://github.com/thedotmack/claude-mem/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=thedotmack/claude-mem" />
|
||||
</a>
|
||||
|
||||
Contributions are always welcome!
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
<!-- License -->
|
||||
## :warning: License
|
||||
|
||||
Distributed under the AGPL-3.0 License. See [LICENSE](LICENSE) for more information.
|
||||
|
||||
<!-- Contact -->
|
||||
## :handshake: Contact
|
||||
|
||||
Alex Newman - [@thedotmack](https://github.com/thedotmack)
|
||||
|
||||
Project Link: [https://github.com/thedotmack/claude-mem](https://github.com/thedotmack/claude-mem)
|
||||
|
||||
NPM Package: [https://www.npmjs.com/package/claude-mem](https://www.npmjs.com/package/claude-mem)
|
||||
|
||||
<!-- Acknowledgments -->
|
||||
## :gem: Acknowledgements
|
||||
|
||||
- [ChromaDB](https://www.trychroma.com/) - Vector database for AI applications
|
||||
- [Anthropic](https://www.anthropic.com/) - Claude AI and Agent SDK
|
||||
- [Model Context Protocol](https://modelcontextprotocol.io) - Standardized AI tool integration
|
||||
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) - Fast SQLite bindings
|
||||
- [Shields.io](https://shields.io/) - Beautiful README badges
|
||||
- [Awesome README Template](https://github.com/Louis3797/awesome-readme-template) - Template inspiration
|
||||
|
||||
---
|
||||
|
||||
**Remember more. Repeat less.** 🧠✨
|
||||
**Philosophy**: claude-mem follows the **Make It Work First** approach - direct execution over defensive validation, natural failures instead of artificial guards, and memory as a living, evolving system. Context improves with use through semantic search, project isolation, and temporal relevance.
|
||||
|
||||
**Built with TypeScript, ChromaDB, SQLite, and the Anthropic Agent SDK**
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# Windows Installation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Claude-mem now includes **experimental Windows support** as of v3.10.0 (October 2025). The cross-platform architecture uses the [Platform utility](/src/utils/platform.ts) for handling Windows-specific differences.
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Windows 10/11** (64-bit recommended)
|
||||
- **Node.js** >= 18.0.0
|
||||
- **PowerShell** 5.1 or later (for hook execution)
|
||||
- **Claude Code** with MCP support
|
||||
- **npm** package manager
|
||||
|
||||
**Note**: Bun (>=1.0.0) is only required for development work on claude-mem itself, not for running it.
|
||||
|
||||
## Known Windows-Specific Differences
|
||||
|
||||
### 1. Hook Execution
|
||||
- **Unix/macOS**: Hooks execute via `/bin/sh`
|
||||
- **Windows**: Hooks execute via `powershell`
|
||||
|
||||
All hooks are `.js` files that work on both platforms through Node.js.
|
||||
|
||||
### 2. Path Handling
|
||||
Windows paths use backslashes (`\`) but Node.js normalizes these automatically. The data directory is located at:
|
||||
```
|
||||
C:\Users\YourUsername\.claude-mem\
|
||||
```
|
||||
|
||||
### 3. File Permissions
|
||||
- **Unix/macOS**: Hook files get `chmod 755` permissions
|
||||
- **Windows**: Permission setting is a no-op (not needed)
|
||||
|
||||
### 4. Smart Trash™ Alias
|
||||
The Smart Trash feature creates shell aliases differently:
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
```powershell
|
||||
# claude-mem smart trash alias
|
||||
function rm { claude-mem trash $args }
|
||||
```
|
||||
|
||||
PowerShell profiles are located at:
|
||||
- `Documents\PowerShell\Microsoft.PowerShell_profile.ps1`
|
||||
- `Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1`
|
||||
|
||||
**Unix/macOS (Bash/Zsh)**:
|
||||
```bash
|
||||
# claude-mem smart trash alias
|
||||
alias rm='claude-mem trash'
|
||||
```
|
||||
|
||||
### 5. UV Package Manager Installation
|
||||
The installer automatically installs `uv` using platform-specific methods:
|
||||
|
||||
**Windows**:
|
||||
```powershell
|
||||
powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**Unix/macOS**:
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Install claude-mem globally
|
||||
|
||||
```powershell
|
||||
npm install -g claude-mem
|
||||
```
|
||||
|
||||
### 2. Run the installer
|
||||
|
||||
```powershell
|
||||
claude-mem install
|
||||
```
|
||||
|
||||
The installer will:
|
||||
1. ✅ Create directory structure at `C:\Users\YourUsername\.claude-mem\`
|
||||
2. ✅ Install UV package manager via PowerShell
|
||||
3. ✅ Install Chroma MCP server for vector database
|
||||
4. ✅ Add CLAUDE.md instructions to `C:\Users\YourUsername\.claude\`
|
||||
5. ✅ Install slash commands (save.md, remember.md, claude-mem.md)
|
||||
6. ✅ Install memory hooks with PowerShell execution support
|
||||
7. ✅ Configure Claude Code settings
|
||||
|
||||
### 3. Restart Claude Code
|
||||
|
||||
After installation, restart Claude Code to activate the memory system.
|
||||
|
||||
## Windows-Specific Caveats
|
||||
|
||||
### PowerShell Execution Policy
|
||||
If hooks fail to execute, you may need to adjust your PowerShell execution policy:
|
||||
|
||||
```powershell
|
||||
# Check current policy
|
||||
Get-ExecutionPolicy
|
||||
|
||||
# Allow local scripts (choose one):
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
# OR for more security:
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
|
||||
```
|
||||
|
||||
### Path Environment Variable
|
||||
After installing UV, you may need to restart PowerShell or update your PATH:
|
||||
|
||||
```powershell
|
||||
# Add to PATH if needed
|
||||
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
|
||||
```
|
||||
|
||||
The installer attempts to set this automatically, but it only affects the current process.
|
||||
|
||||
### Better-SQLite3 Native Module
|
||||
The hooks use `better-sqlite3` for database access, which requires native compilation:
|
||||
- The installer runs `npm install` in the hooks directory
|
||||
- If this fails, hooks may still work if `better-sqlite3` is globally available
|
||||
- Build errors are silently ignored to prevent installation failure
|
||||
|
||||
If you see database errors in logs:
|
||||
```powershell
|
||||
cd $env:USERPROFILE\.claude-mem\hooks
|
||||
npm install better-sqlite3 --build-from-source
|
||||
```
|
||||
|
||||
## File Locations on Windows
|
||||
|
||||
| Component | Windows Path |
|
||||
|-----------|-------------|
|
||||
| Data directory | `C:\Users\YourUsername\.claude-mem\` |
|
||||
| ChromaDB | `C:\Users\YourUsername\.claude-mem\chroma\` |
|
||||
| SQLite database | `C:\Users\YourUsername\.claude-mem\claude-mem.db` |
|
||||
| Hooks | `C:\Users\YourUsername\.claude-mem\hooks\` |
|
||||
| Archives | `C:\Users\YourUsername\.claude-mem\archives\` |
|
||||
| Trash | `C:\Users\YourUsername\.claude-mem\trash\` |
|
||||
| CLAUDE.md | `C:\Users\YourUsername\.claude\CLAUDE.md` |
|
||||
| Settings | `C:\Users\YourUsername\AppData\Roaming\Claude\settings.json` |
|
||||
| Commands | `C:\Users\YourUsername\.claude\commands\` |
|
||||
|
||||
## Testing Your Installation
|
||||
|
||||
### 1. Check installation status
|
||||
```powershell
|
||||
claude-mem status
|
||||
```
|
||||
|
||||
### 2. Run diagnostics
|
||||
```powershell
|
||||
claude-mem doctor
|
||||
```
|
||||
|
||||
This will verify:
|
||||
- ✅ Directory structure
|
||||
- ✅ Hook configuration
|
||||
- ✅ Database accessibility
|
||||
- ✅ MCP server integration
|
||||
|
||||
### 3. Test memory storage
|
||||
Start Claude Code and have a brief conversation. Then check:
|
||||
```powershell
|
||||
claude-mem status
|
||||
```
|
||||
|
||||
You should see memory counts increase.
|
||||
|
||||
### 4. Search memories
|
||||
In Claude Code, ask:
|
||||
```
|
||||
Search my memories for [your topic]
|
||||
```
|
||||
|
||||
Claude should use the `mcp__claude-mem__chroma_query_documents` tool automatically.
|
||||
|
||||
## Known Issues & Limitations
|
||||
|
||||
### ⚠️ PowerShell Profile Creation
|
||||
- **Issue**: PowerShell profiles may not exist by default
|
||||
- **Impact**: Smart Trash alias installation creates profile directories
|
||||
- **Status**: Installer handles this automatically
|
||||
|
||||
### ⚠️ Native Module Compilation
|
||||
- **Issue**: `better-sqlite3` requires build tools
|
||||
- **Impact**: May need Visual Studio Build Tools installed
|
||||
- **Workaround**: Installer attempts silent installation; manually rebuild if needed
|
||||
|
||||
### ⚠️ Path Case Sensitivity
|
||||
- **Issue**: Windows paths are case-insensitive but ChromaDB metadata stores exact case
|
||||
- **Impact**: Path comparisons may fail in edge cases
|
||||
- **Status**: Generally works fine; use consistent casing
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter Windows-specific issues:
|
||||
|
||||
1. **Run diagnostics**:
|
||||
```powershell
|
||||
claude-mem doctor
|
||||
```
|
||||
|
||||
2. **Check logs**:
|
||||
```powershell
|
||||
claude-mem logs
|
||||
```
|
||||
Or view directly:
|
||||
```powershell
|
||||
type $env:USERPROFILE\.claude-mem\logs\*.log
|
||||
```
|
||||
|
||||
3. **Verify hook execution**:
|
||||
Check Claude Code's hook output for errors during session start/stop
|
||||
|
||||
4. **Report issues**:
|
||||
- Include `claude-mem doctor` output
|
||||
- Include relevant logs
|
||||
- Specify Windows version and PowerShell version
|
||||
- File at: https://github.com/thedotmack/claude-mem/issues
|
||||
|
||||
## Development on Windows
|
||||
|
||||
If you're developing claude-mem on Windows:
|
||||
|
||||
### Prerequisites
|
||||
- Install Bun (Windows support is experimental): https://bun.sh/
|
||||
- Install Git for Windows
|
||||
- Install Visual Studio Build Tools for native modules
|
||||
|
||||
### Build Commands
|
||||
```powershell
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build minified bundle
|
||||
npm run build
|
||||
|
||||
# Link for local testing
|
||||
bun link
|
||||
|
||||
# Reinstall with latest build
|
||||
claude-mem install --force
|
||||
```
|
||||
|
||||
**Note**: Some npm scripts use Unix-style paths and may not work on Windows. Core development is recommended on Unix/macOS systems.
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **Works on Windows**:
|
||||
- Memory capture via streaming hooks
|
||||
- ChromaDB semantic search
|
||||
- SQLite metadata storage
|
||||
- MCP server integration
|
||||
- All CLI commands
|
||||
- Smart Trash™ system
|
||||
- Automatic context loading
|
||||
|
||||
⚠️ **May Require Extra Setup**:
|
||||
- PowerShell execution policy
|
||||
- Visual Studio Build Tools for native modules
|
||||
- Manual PATH configuration
|
||||
|
||||
The core claude-mem functionality is fully operational on Windows, with the memory capture, storage, and retrieval systems working identically to Unix/macOS.
|
||||
Vendored
+201
-171
File diff suppressed because one or more lines are too long
@@ -13,11 +13,11 @@ import { fileURLToPath } from 'url';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { renderToolMessage, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
|
||||
import { getProjectName } from './shared/path-resolver.js';
|
||||
import { initializeDatabase, getActiveStreamingSessionsForProject } from './shared/hook-helpers.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
|
||||
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
|
||||
|
||||
function debugLog(message, data = {}) {
|
||||
@@ -61,15 +61,18 @@ process.stdin.on('end', async () => {
|
||||
console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
|
||||
|
||||
try {
|
||||
// Load SDK session info
|
||||
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
|
||||
if (!fs.existsSync(sessionFile)) {
|
||||
// Load SDK session info from database
|
||||
const db = initializeDatabase();
|
||||
|
||||
const sessions = getActiveStreamingSessionsForProject(db, project);
|
||||
if (!sessions || sessions.length === 0) {
|
||||
debugLog('PostToolUse: No streaming session found', { project });
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||
const sdkSessionId = sessionData.sdkSessionId;
|
||||
const sessionData = sessions[0];
|
||||
const sdkSessionId = sessionData.sdk_session_id;
|
||||
|
||||
// Convert tool response to string
|
||||
const toolResponseStr = typeof tool_response === 'string'
|
||||
@@ -135,6 +138,9 @@ process.stdin.on('end', async () => {
|
||||
}
|
||||
|
||||
debugLog('PostToolUse: SDK finished processing', { tool_name, sdkSessionId });
|
||||
|
||||
// Close database connection
|
||||
db.close();
|
||||
} catch (error) {
|
||||
debugLog('PostToolUse: Error sending to SDK', { error: error.message });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* Hook Helper Functions
|
||||
*
|
||||
*
|
||||
* This module provides JavaScript wrappers around the TypeScript PromptOrchestrator
|
||||
* and HookTemplates system, making them accessible to the JavaScript hook scripts.
|
||||
*/
|
||||
@@ -10,6 +10,9 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import Database from 'better-sqlite3';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -235,3 +238,193 @@ export function debugLog(message, data = {}) {
|
||||
console.error(`[${timestamp}] HOOK DEBUG: ${message}`, data);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DATABASE HELPERS (inline SQL to avoid 'claude-mem' import issues)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get the claude-mem data directory path
|
||||
*/
|
||||
function getDataDirectory() {
|
||||
return join(os.homedir(), '.claude-mem');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the database connection
|
||||
*/
|
||||
function getDatabase() {
|
||||
const dataDir = getDataDirectory();
|
||||
const dbPath = join(dataDir, 'claude-mem.db');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Apply optimized SQLite settings
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('temp_store = memory');
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the streaming_sessions table exists
|
||||
*/
|
||||
function ensureStreamingSessionsTable(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS streaming_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT UNIQUE NOT NULL,
|
||||
sdk_session_id TEXT,
|
||||
project TEXT NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
updated_at TEXT,
|
||||
updated_at_epoch INTEGER,
|
||||
completed_at TEXT,
|
||||
completed_at_epoch INTEGER,
|
||||
status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'failed'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indices if they don't exist
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_claude_id
|
||||
ON streaming_sessions(claude_session_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_sdk_id
|
||||
ON streaming_sessions(sdk_session_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_project_status
|
||||
ON streaming_sessions(project, status)
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new streaming session record
|
||||
*/
|
||||
export function createStreamingSession(db, { claude_session_id, project, user_prompt, started_at }) {
|
||||
ensureStreamingSessionsTable(db);
|
||||
|
||||
const timestamp = started_at || new Date().toISOString();
|
||||
const epoch = new Date(timestamp).getTime();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO streaming_sessions (
|
||||
claude_session_id, project, user_prompt, started_at, started_at_epoch, status
|
||||
) VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`);
|
||||
|
||||
const info = stmt.run(claude_session_id, project, user_prompt || null, timestamp, epoch);
|
||||
|
||||
return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?').get(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a streaming session by internal ID
|
||||
*/
|
||||
export function updateStreamingSession(db, id, updates) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const epoch = Date.now();
|
||||
|
||||
const parts = [];
|
||||
const values = [];
|
||||
|
||||
if (updates.sdk_session_id !== undefined) {
|
||||
parts.push('sdk_session_id = ?');
|
||||
values.push(updates.sdk_session_id);
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
parts.push('title = ?');
|
||||
values.push(updates.title);
|
||||
}
|
||||
if (updates.subtitle !== undefined) {
|
||||
parts.push('subtitle = ?');
|
||||
values.push(updates.subtitle);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
parts.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.completed_at !== undefined) {
|
||||
const completedTimestamp = typeof updates.completed_at === 'string'
|
||||
? updates.completed_at
|
||||
: new Date(updates.completed_at).toISOString();
|
||||
const completedEpoch = new Date(completedTimestamp).getTime();
|
||||
parts.push('completed_at = ?', 'completed_at_epoch = ?');
|
||||
values.push(completedTimestamp, completedEpoch);
|
||||
}
|
||||
|
||||
// Always update the updated_at timestamp
|
||||
parts.push('updated_at = ?', 'updated_at_epoch = ?');
|
||||
values.push(timestamp, epoch);
|
||||
|
||||
values.push(id);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE streaming_sessions
|
||||
SET ${parts.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(...values);
|
||||
|
||||
return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active streaming sessions for a project
|
||||
*/
|
||||
export function getActiveStreamingSessionsForProject(db, project) {
|
||||
ensureStreamingSessionsTable(db);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM streaming_sessions
|
||||
WHERE project = ? AND status = 'active'
|
||||
ORDER BY started_at_epoch DESC
|
||||
`);
|
||||
|
||||
return stmt.all(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as completed
|
||||
*/
|
||||
export function markStreamingSessionCompleted(db, id) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const epoch = Date.now();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE streaming_sessions
|
||||
SET status = ?,
|
||||
completed_at = ?,
|
||||
completed_at_epoch = ?,
|
||||
updated_at = ?,
|
||||
updated_at_epoch = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run('completed', timestamp, epoch, timestamp, epoch, id);
|
||||
|
||||
return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database with migrations and return connection
|
||||
*/
|
||||
export function initializeDatabase() {
|
||||
const db = getDatabase();
|
||||
ensureStreamingSessionsTable(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
+23
-10
@@ -12,8 +12,8 @@ import fs from 'fs';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { renderEndMessage, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
|
||||
import { getProjectName } from './shared/path-resolver.js';
|
||||
import { initializeDatabase, getActiveStreamingSessionsForProject, markStreamingSessionCompleted } from './shared/hook-helpers.js';
|
||||
|
||||
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
|
||||
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
|
||||
|
||||
function debugLog(message, data = {}) {
|
||||
@@ -50,20 +50,31 @@ process.stdin.on('end', async () => {
|
||||
const { cwd } = payload;
|
||||
const project = cwd ? getProjectName(cwd) : 'unknown';
|
||||
|
||||
// Immediately clear activity flag for UI indicator
|
||||
const activityFlagPath = path.join(process.env.HOME || '', '.claude-mem', 'activity.flag');
|
||||
try {
|
||||
fs.writeFileSync(activityFlagPath, JSON.stringify({ active: false, timestamp: Date.now() }));
|
||||
} catch (error) {
|
||||
// Silent fail - non-critical
|
||||
}
|
||||
|
||||
// Return immediately with async mode
|
||||
console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
|
||||
|
||||
try {
|
||||
// Load SDK session info
|
||||
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
|
||||
if (!fs.existsSync(sessionFile)) {
|
||||
// Load SDK session info from database
|
||||
const db = initializeDatabase();
|
||||
|
||||
const sessions = getActiveStreamingSessionsForProject(db, project);
|
||||
if (!sessions || sessions.length === 0) {
|
||||
debugLog('Stop: No streaming session found', { project });
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||
const sdkSessionId = sessionData.sdkSessionId;
|
||||
const claudeSessionId = sessionData.claudeSessionId;
|
||||
const sessionData = sessions[0];
|
||||
const sdkSessionId = sessionData.sdk_session_id;
|
||||
const claudeSessionId = sessionData.claude_session_id;
|
||||
|
||||
debugLog('Stop: Ending SDK session', { sdkSessionId, claudeSessionId });
|
||||
|
||||
@@ -108,10 +119,12 @@ process.stdin.on('end', async () => {
|
||||
debugLog('Stop: Cleaned up memories transcript', { memoriesTranscriptPath });
|
||||
}
|
||||
|
||||
// Clean up session file
|
||||
fs.unlinkSync(sessionFile);
|
||||
debugLog('Stop: Session ended and cleaned up', { project });
|
||||
// Mark session as completed in database
|
||||
markStreamingSessionCompleted(db, sessionData.id);
|
||||
debugLog('Stop: Session ended and marked complete', { project, sessionId: sessionData.id });
|
||||
|
||||
// Close database connection
|
||||
db.close();
|
||||
} catch (error) {
|
||||
debugLog('Stop: Error ending session', { error: error.message });
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ import { fileURLToPath } from 'url';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { renderSystemPrompt, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
|
||||
import { getProjectName } from './shared/path-resolver.js';
|
||||
import { initializeDatabase, createStreamingSession, updateStreamingSession } from './shared/hook-helpers.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
|
||||
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
|
||||
|
||||
function debugLog(message, data = {}) {
|
||||
@@ -60,6 +60,14 @@ process.stdin.on('end', async () => {
|
||||
|
||||
debugLog('UserPromptSubmit: Starting streaming session', { project, session_id });
|
||||
|
||||
// Immediately signal activity start for UI indicator
|
||||
const activityFlagPath = path.join(process.env.HOME || '', '.claude-mem', 'activity.flag');
|
||||
try {
|
||||
fs.writeFileSync(activityFlagPath, JSON.stringify({ active: true, project, timestamp: Date.now() }));
|
||||
} catch (error) {
|
||||
// Silent fail - non-critical
|
||||
}
|
||||
|
||||
// Generate title and subtitle non-blocking
|
||||
if (prompt && session_id && project) {
|
||||
import('child_process').then(({ spawn }) => {
|
||||
@@ -80,6 +88,22 @@ process.stdin.on('end', async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize database and create session record FIRST
|
||||
const db = initializeDatabase();
|
||||
|
||||
// Create session record immediately - this gives us a tracking ID
|
||||
const sessionRecord = createStreamingSession(db, {
|
||||
claude_session_id: session_id,
|
||||
project,
|
||||
user_prompt: prompt,
|
||||
started_at: timestamp
|
||||
});
|
||||
|
||||
debugLog('UserPromptSubmit: Created session record', {
|
||||
internalId: sessionRecord.id,
|
||||
claudeSessionId: session_id
|
||||
});
|
||||
|
||||
// Build system prompt using centralized config
|
||||
const systemPrompt = renderSystemPrompt({
|
||||
project,
|
||||
@@ -110,19 +134,19 @@ process.stdin.on('end', async () => {
|
||||
}
|
||||
|
||||
if (sdkSessionId) {
|
||||
// Save session info for other hooks
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
|
||||
fs.writeFileSync(sessionFile, JSON.stringify({
|
||||
sdkSessionId,
|
||||
claudeSessionId: session_id,
|
||||
project,
|
||||
startedAt: timestamp,
|
||||
date
|
||||
}, null, 2));
|
||||
// Update session record with SDK session ID
|
||||
updateStreamingSession(db, sessionRecord.id, {
|
||||
sdk_session_id: sdkSessionId
|
||||
});
|
||||
|
||||
debugLog('UserPromptSubmit: SDK session started', { sdkSessionId, sessionFile });
|
||||
debugLog('UserPromptSubmit: SDK session started', {
|
||||
internalId: sessionRecord.id,
|
||||
sdkSessionId
|
||||
});
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
db.close();
|
||||
} catch (error) {
|
||||
debugLog('UserPromptSubmit: Error starting SDK session', { error: error.message });
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "3.9.9",
|
||||
"version": "3.9.16",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -51,6 +51,7 @@
|
||||
"commands",
|
||||
"src",
|
||||
".mcp.json",
|
||||
"CHANGELOG.md"
|
||||
"CHANGELOG.md",
|
||||
"README_WINDOWS.md"
|
||||
]
|
||||
}
|
||||
|
||||
+8
-3
@@ -224,9 +224,8 @@ program
|
||||
.description('Generate a session title and subtitle from a prompt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--oneline', 'Output as single line (title - subtitle)')
|
||||
.option('--save', 'Save title and subtitle to session metadata')
|
||||
.option('--project <name>', 'Project name (required with --save)')
|
||||
.option('--session <id>', 'Session ID (required with --save)')
|
||||
.option('--session-id <id>', 'Claude session ID to update')
|
||||
.option('--save', 'Save the generated title to the database (requires --session-id)')
|
||||
.action(generateTitle);
|
||||
|
||||
// </Block> =======================================
|
||||
@@ -268,3 +267,9 @@ try {
|
||||
// Parse arguments and execute
|
||||
program.parse();
|
||||
// </Block> =======================================
|
||||
|
||||
// <Block> 1.12 ===================================
|
||||
// Module Exports for Programmatic Use
|
||||
// Export database and utility classes for hooks and external consumers
|
||||
export { DatabaseManager, StreamingSessionStore, migrations, initializeDatabase, getDatabase } from '../services/sqlite/index.js';
|
||||
// </Block> =======================================
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { getClaudePath } from '../shared/settings.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
|
||||
import { DatabaseManager } from '../services/sqlite/Database.js';
|
||||
import { StreamingSessionStore } from '../services/sqlite/StreamingSessionStore.js';
|
||||
import { migrations } from '../services/sqlite/migrations.js';
|
||||
|
||||
/**
|
||||
* Generate a session title and subtitle from a user prompt
|
||||
* CLI command that uses Agent SDK (like changelog.ts)
|
||||
*
|
||||
* Can be called in two modes:
|
||||
* 1. Standalone: generate-title "user prompt" --json
|
||||
* 2. With session: generate-title "user prompt" --session-id <id> --save
|
||||
*/
|
||||
export async function generateTitle(prompt: string, options: OptionValues): Promise<void> {
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
@@ -19,6 +22,36 @@ export async function generateTitle(prompt: string, options: OptionValues): Prom
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --session-id provided, validate that session exists
|
||||
let streamingStore: StreamingSessionStore | null = null;
|
||||
let sessionRecord = null;
|
||||
|
||||
if (options.sessionId) {
|
||||
try {
|
||||
const dbManager = DatabaseManager.getInstance();
|
||||
for (const migration of migrations) {
|
||||
dbManager.registerMigration(migration);
|
||||
}
|
||||
const db = await dbManager.initialize();
|
||||
streamingStore = new StreamingSessionStore(db);
|
||||
|
||||
sessionRecord = streamingStore.getByClaudeSessionId(options.sessionId);
|
||||
if (!sessionRecord) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Session not found: ${options.sessionId}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Database error: ${error.message}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a title and subtitle generator for claude-mem session metadata.
|
||||
|
||||
Your job is to analyze a user's request and generate:
|
||||
@@ -107,49 +140,17 @@ Now generate the title and subtitle (two lines exactly):`;
|
||||
const title = lines[0].trim();
|
||||
const subtitle = lines[1].trim();
|
||||
|
||||
// Save to session metadata if --save flag is provided
|
||||
if (options.save) {
|
||||
if (!options.project || !options.session) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: '--project and --session are required when using --save'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --save and we have a session, update the database
|
||||
if (options.save && streamingStore && sessionRecord) {
|
||||
try {
|
||||
const sessionFile = path.join(SESSION_DIR, `${options.project}_streaming.json`);
|
||||
|
||||
if (!fs.existsSync(sessionFile)) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Session file not found: ${sessionFile}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let sessionData: any = {};
|
||||
try {
|
||||
sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to parse session file'
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
sessionData.promptTitle = title;
|
||||
sessionData.promptSubtitle = subtitle;
|
||||
sessionData.updatedAt = new Date().toISOString();
|
||||
|
||||
// Write back to file
|
||||
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
|
||||
streamingStore.update(sessionRecord.id, {
|
||||
title,
|
||||
subtitle
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: `Failed to save metadata: ${error.message}`
|
||||
error: `Failed to save title: ${error.message}`
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -160,7 +161,8 @@ Now generate the title and subtitle (two lines exactly):`;
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
title,
|
||||
subtitle
|
||||
subtitle,
|
||||
sessionId: sessionRecord?.claude_session_id
|
||||
}, null, 2));
|
||||
} else if (options.oneline) {
|
||||
console.log(`${title} - ${subtitle}`);
|
||||
|
||||
+176
-764
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
import { Database } from 'better-sqlite3';
|
||||
import { getDatabase } from './Database.js';
|
||||
import { normalizeTimestamp } from './types.js';
|
||||
|
||||
/**
|
||||
* Represents a streaming session row in the database
|
||||
*/
|
||||
export interface StreamingSessionRow {
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
sdk_session_id?: string;
|
||||
project: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
user_prompt?: string;
|
||||
started_at: string;
|
||||
started_at_epoch: number;
|
||||
updated_at?: string;
|
||||
updated_at_epoch?: number;
|
||||
completed_at?: string;
|
||||
completed_at_epoch?: number;
|
||||
status: 'active' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for creating a new streaming session
|
||||
*/
|
||||
export interface StreamingSessionInput {
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
user_prompt?: string;
|
||||
started_at?: string | Date | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for updating a streaming session
|
||||
*/
|
||||
export interface StreamingSessionUpdate {
|
||||
sdk_session_id?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
status?: 'active' | 'completed' | 'failed';
|
||||
completed_at?: string | Date | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Access Object for streaming session records
|
||||
* Handles real-time session tracking during SDK compression
|
||||
*/
|
||||
export class StreamingSessionStore {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor(db?: Database.Database) {
|
||||
this.db = db || getDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new streaming session record
|
||||
* This should be called immediately when the hook receives a user prompt
|
||||
*/
|
||||
create(input: StreamingSessionInput): StreamingSessionRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.started_at);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO streaming_sessions (
|
||||
claude_session_id, project, user_prompt, started_at, started_at_epoch, status
|
||||
) VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
input.claude_session_id,
|
||||
input.project,
|
||||
input.user_prompt || null,
|
||||
isoString,
|
||||
epoch
|
||||
);
|
||||
|
||||
return this.getById(info.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a streaming session by internal ID
|
||||
* Uses atomic transaction to prevent race conditions
|
||||
*/
|
||||
update(id: number, updates: StreamingSessionUpdate): StreamingSessionRow {
|
||||
const { isoString: updatedAt, epoch: updatedEpoch } = normalizeTimestamp(new Date());
|
||||
|
||||
const existing = this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Streaming session with id ${id} not found`);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.sdk_session_id !== undefined) {
|
||||
parts.push('sdk_session_id = ?');
|
||||
values.push(updates.sdk_session_id);
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
parts.push('title = ?');
|
||||
values.push(updates.title);
|
||||
}
|
||||
if (updates.subtitle !== undefined) {
|
||||
parts.push('subtitle = ?');
|
||||
values.push(updates.subtitle);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
parts.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.completed_at !== undefined) {
|
||||
const { isoString, epoch } = normalizeTimestamp(updates.completed_at);
|
||||
parts.push('completed_at = ?', 'completed_at_epoch = ?');
|
||||
values.push(isoString, epoch);
|
||||
}
|
||||
|
||||
// Always update the updated_at timestamp
|
||||
parts.push('updated_at = ?', 'updated_at_epoch = ?');
|
||||
values.push(updatedAt, updatedEpoch);
|
||||
|
||||
values.push(id);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE streaming_sessions
|
||||
SET ${parts.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(...values);
|
||||
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a streaming session by Claude session ID
|
||||
* Convenience method for hooks that only have the Claude session ID
|
||||
*/
|
||||
updateByClaudeSessionId(claudeSessionId: string, updates: StreamingSessionUpdate): StreamingSessionRow | null {
|
||||
const session = this.getByClaudeSessionId(claudeSessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return this.update(session.id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by internal ID
|
||||
*/
|
||||
getById(id: number): StreamingSessionRow | null {
|
||||
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE id = ?');
|
||||
return stmt.get(id) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by Claude session ID
|
||||
*/
|
||||
getByClaudeSessionId(claudeSessionId: string): StreamingSessionRow | null {
|
||||
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE claude_session_id = ?');
|
||||
return stmt.get(claudeSessionId) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming session by SDK session ID
|
||||
*/
|
||||
getBySdkSessionId(sdkSessionId: string): StreamingSessionRow | null {
|
||||
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE sdk_session_id = ?');
|
||||
return stmt.get(sdkSessionId) as StreamingSessionRow || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a streaming session exists by Claude session ID
|
||||
*/
|
||||
has(claudeSessionId: string): boolean {
|
||||
const stmt = this.db.prepare('SELECT 1 FROM streaming_sessions WHERE claude_session_id = ? LIMIT 1');
|
||||
return Boolean(stmt.get(claudeSessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active streaming sessions for a project
|
||||
*/
|
||||
getActiveForProject(project: string): StreamingSessionRow[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM streaming_sessions
|
||||
WHERE project = ? AND status = 'active'
|
||||
ORDER BY started_at_epoch DESC
|
||||
`);
|
||||
return stmt.all(project) as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active streaming sessions
|
||||
*/
|
||||
getAllActive(): StreamingSessionRow[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM streaming_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY started_at_epoch DESC
|
||||
`);
|
||||
return stmt.all() as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent streaming sessions (completed or failed)
|
||||
*/
|
||||
getRecent(limit = 10): StreamingSessionRow[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM streaming_sessions
|
||||
ORDER BY started_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit) as StreamingSessionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as completed
|
||||
*/
|
||||
markCompleted(id: number): StreamingSessionRow {
|
||||
return this.update(id, {
|
||||
status: 'completed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as failed
|
||||
*/
|
||||
markFailed(id: number): StreamingSessionRow {
|
||||
return this.update(id, {
|
||||
status: 'failed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a streaming session by ID
|
||||
*/
|
||||
deleteById(id: number): boolean {
|
||||
const stmt = this.db.prepare('DELETE FROM streaming_sessions WHERE id = ?');
|
||||
const info = stmt.run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a streaming session by Claude session ID
|
||||
*/
|
||||
deleteByClaudeSessionId(claudeSessionId: string): boolean {
|
||||
const stmt = this.db.prepare('DELETE FROM streaming_sessions WHERE claude_session_id = ?');
|
||||
const info = stmt.run(claudeSessionId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old completed/failed sessions (older than N days)
|
||||
*/
|
||||
cleanupOldSessions(daysOld = 30): number {
|
||||
const cutoffEpoch = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM streaming_sessions
|
||||
WHERE status IN ('completed', 'failed')
|
||||
AND completed_at_epoch < ?
|
||||
`);
|
||||
const info = stmt.run(cutoffEpoch);
|
||||
return info.changes;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export { MemoryStore } from './MemoryStore.js';
|
||||
export { OverviewStore } from './OverviewStore.js';
|
||||
export { DiagnosticsStore } from './DiagnosticsStore.js';
|
||||
export { TranscriptEventStore } from './TranscriptEventStore.js';
|
||||
export { StreamingSessionStore } from './StreamingSessionStore.js';
|
||||
|
||||
// Export types
|
||||
export * from './types.js';
|
||||
@@ -26,18 +27,20 @@ export async function createStores() {
|
||||
}
|
||||
|
||||
const db = await manager.initialize();
|
||||
|
||||
|
||||
const { SessionStore } = await import('./SessionStore.js');
|
||||
const { MemoryStore } = await import('./MemoryStore.js');
|
||||
const { OverviewStore } = await import('./OverviewStore.js');
|
||||
const { DiagnosticsStore } = await import('./DiagnosticsStore.js');
|
||||
const { TranscriptEventStore } = await import('./TranscriptEventStore.js');
|
||||
const { StreamingSessionStore } = await import('./StreamingSessionStore.js');
|
||||
|
||||
return {
|
||||
sessions: new SessionStore(db),
|
||||
memories: new MemoryStore(db),
|
||||
overviews: new OverviewStore(db),
|
||||
diagnostics: new DiagnosticsStore(db),
|
||||
transcriptEvents: new TranscriptEventStore(db)
|
||||
transcriptEvents: new TranscriptEventStore(db),
|
||||
streamingSessions: new StreamingSessionStore(db)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,10 +160,53 @@ export const migration002: Migration = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Migration 003 - Add streaming_sessions table for real-time session tracking
|
||||
*/
|
||||
export const migration003: Migration = {
|
||||
version: 3,
|
||||
up: (db: Database.Database) => {
|
||||
// Streaming sessions table - tracks active SDK compression sessions
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS streaming_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT UNIQUE NOT NULL,
|
||||
sdk_session_id TEXT,
|
||||
project TEXT NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
updated_at TEXT,
|
||||
updated_at_epoch INTEGER,
|
||||
completed_at TEXT,
|
||||
completed_at_epoch INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_claude_id ON streaming_sessions(claude_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_sdk_id ON streaming_sessions(sdk_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_project ON streaming_sessions(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_status ON streaming_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_started ON streaming_sessions(started_at_epoch DESC);
|
||||
`);
|
||||
|
||||
console.log('✅ Created streaming_sessions table for real-time session tracking');
|
||||
},
|
||||
|
||||
down: (db: Database.Database) => {
|
||||
db.exec(`
|
||||
DROP TABLE IF EXISTS streaming_sessions;
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* All migrations in order
|
||||
*/
|
||||
export const migrations: Migration[] = [
|
||||
migration001,
|
||||
migration002
|
||||
migration002,
|
||||
migration003
|
||||
];
|
||||
@@ -1,819 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react';
|
||||
import {
|
||||
Bars3Icon,
|
||||
MagnifyingGlassIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import OverviewCard from './src/components/OverviewCard';
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function MemoryStream() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [overviewsOpen, setOverviewsOpen] = useState(false);
|
||||
const [memories, setMemories] = useState([]);
|
||||
const [overviews, setOverviews] = useState([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [status, setStatus] = useState('connecting');
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState('all');
|
||||
const [selectedTag, setSelectedTag] = useState(null);
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isAwaitingOverview, setIsAwaitingOverview] = useState(false);
|
||||
const [debugOverviewCard, setDebugOverviewCard] = useState(false);
|
||||
const eventSourceRef = useRef(null);
|
||||
|
||||
let filteredMemories = selectedProject === 'all'
|
||||
? memories
|
||||
: memories.filter(m => m.project === selectedProject);
|
||||
|
||||
if (selectedTag) {
|
||||
filteredMemories = filteredMemories.filter(m => m.concepts?.includes(selectedTag));
|
||||
}
|
||||
|
||||
const filteredOverviews = selectedProject === 'all'
|
||||
? overviews
|
||||
: overviews.filter(o => o.project === selectedProject);
|
||||
|
||||
const existingCount = filteredMemories.filter(m => !m.isNew).length;
|
||||
const newCount = filteredMemories.filter(m => m.isNew).length;
|
||||
|
||||
const stats = {
|
||||
total: filteredMemories.length,
|
||||
new: newCount,
|
||||
existing: existingCount,
|
||||
sessions: new Set(filteredMemories.map(m => m.session_id)).size,
|
||||
projects: new Set(memories.map(m => m.project)).size
|
||||
};
|
||||
|
||||
const projects = ['all', ...new Set(memories.map(m => m.project).filter(Boolean))];
|
||||
|
||||
useEffect(() => {
|
||||
setStatus('connecting');
|
||||
const eventSource = new EventSource('http://localhost:3001/stream');
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setStatus('connected');
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'initial_load') {
|
||||
const existingMemories = data.memories.map(m => ({ ...m, isNew: false }));
|
||||
setMemories(existingMemories);
|
||||
const existingOverviews = data.overviews.map(o => ({ ...o, isNew: false }));
|
||||
setOverviews(existingOverviews);
|
||||
setInitialLoadComplete(true);
|
||||
setCurrentIndex(0);
|
||||
} else if (data.type === 'new_memories') {
|
||||
const newMemories = data.memories.map(m => ({ ...m, isNew: true }));
|
||||
setMemories(prev => [...newMemories, ...prev]);
|
||||
setCurrentIndex(0);
|
||||
} else if (data.type === 'new_overviews') {
|
||||
const newOverviews = data.overviews.map(o => ({ ...o, isNew: true }));
|
||||
// Remove placeholders for the same projects as the incoming real overviews
|
||||
const incomingProjects = new Set(newOverviews.map(o => o.project));
|
||||
setOverviews(prev => {
|
||||
const withoutPlaceholders = prev.filter(o =>
|
||||
!o.isPlaceholder || !incomingProjects.has(o.project)
|
||||
);
|
||||
return [...newOverviews, ...withoutPlaceholders];
|
||||
});
|
||||
setIsAwaitingOverview(false);
|
||||
} else if (data.type === 'session_start') {
|
||||
// Only process for current project (or 'all')
|
||||
if (selectedProject === 'all' || data.project === selectedProject) {
|
||||
setIsProcessing(true);
|
||||
setIsAwaitingOverview(true);
|
||||
|
||||
// Create placeholder overview card
|
||||
const placeholderOverview = {
|
||||
id: `placeholder-${Date.now()}`,
|
||||
project: data.project,
|
||||
content: '⏳ Session in progress...',
|
||||
created_at: new Date().toISOString(),
|
||||
session_id: null,
|
||||
isNew: true,
|
||||
isPlaceholder: true
|
||||
};
|
||||
setOverviews(prev => [placeholderOverview, ...prev]);
|
||||
}
|
||||
} else if (data.type === 'session_end') {
|
||||
// Only process for current project (or 'all')
|
||||
if (selectedProject === 'all' || data.project === selectedProject) {
|
||||
setIsProcessing(false);
|
||||
setIsAwaitingOverview(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setStatus('reconnecting');
|
||||
setConnected(false);
|
||||
eventSource.close();
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
};
|
||||
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setCurrentIndex(i => (i - 1 + filteredMemories.length) % filteredMemories.length);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setCurrentIndex(i => (i + 1) % filteredMemories.length);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filteredMemories.length]);
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const diff = Date.now() - date;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const memory = filteredMemories[currentIndex] || {};
|
||||
|
||||
// Extract unique tags from all memories
|
||||
const allTags = [...new Set(memories.flatMap(m => m.concepts || []))];
|
||||
const tagCounts = allTags.reduce((acc, tag) => {
|
||||
acc[tag] = memories.filter(m => m.concepts?.includes(tag)).length;
|
||||
return acc;
|
||||
}, {});
|
||||
const sortedTags = allTags.sort((a, b) => tagCounts[b] - tagCounts[a]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-black text-gray-100 relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 opacity-20">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: 'linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)',
|
||||
backgroundSize: '50px 50px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full" style={{
|
||||
background: 'radial-gradient(ellipse at 20% 30%, rgba(59, 130, 246, 0.15) 0%, transparent 50%)'
|
||||
}} />
|
||||
<div className="absolute top-0 right-0 w-full h-full" style={{
|
||||
background: 'radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.15) 0%, transparent 50%)'
|
||||
}} />
|
||||
<div className="absolute bottom-0 left-1/2 w-full h-full" style={{
|
||||
background: 'radial-gradient(ellipse at 50% 80%, rgba(16, 185, 129, 0.1) 0%, transparent 50%)'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
|
||||
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900/90 backdrop-blur-xl px-6 border-r border-gray-800">
|
||||
<div className="relative flex h-16 shrink-0 items-center">
|
||||
<img src="/claude-mem-logo.webp" alt="claude-mem" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="relative flex flex-1 flex-col">
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-blue-600/10 via-purple-600/10 to-emerald-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-blue-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
STATISTICS
|
||||
</h3>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Total</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-blue-500">{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">New</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">{stats.new}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Sessions</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-purple-500">{stats.sessions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Projects</span>
|
||||
<span className="text-lg font-bold text-gray-300">{stats.projects}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-purple-600/10 via-blue-600/10 to-purple-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-purple-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
TAG CLOUD
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sortedTags.slice(0, 20).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
setSelectedTag(selectedTag === tag ? null : tag);
|
||||
setCurrentIndex(0);
|
||||
}}
|
||||
className={classNames(
|
||||
"px-2.5 py-1 rounded-lg border text-xs font-medium transition-all cursor-pointer",
|
||||
selectedTag === tag
|
||||
? "bg-purple-500/30 border-purple-400/60 text-purple-200 shadow-lg shadow-purple-500/20"
|
||||
: "bg-purple-500/10 border-purple-400/30 text-purple-300 hover:bg-purple-500/20"
|
||||
)}
|
||||
>
|
||||
{tag} ({tagCounts[tag]})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-80 xl:flex-col">
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900/90 backdrop-blur-xl px-6 border-r border-gray-800">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<img src="/claude-mem-logo.webp" alt="claude-mem" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-blue-600/10 via-purple-600/10 to-emerald-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-blue-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
STATISTICS
|
||||
</h3>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Total</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-blue-500">{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">New</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">{stats.new}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Sessions</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-purple-500">{stats.sessions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Projects</span>
|
||||
<span className="text-lg font-bold text-gray-300">{stats.projects}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-purple-600/10 via-blue-600/10 to-purple-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-purple-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
TAG CLOUD
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sortedTags.slice(0, 20).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
setSelectedTag(selectedTag === tag ? null : tag);
|
||||
setCurrentIndex(0);
|
||||
}}
|
||||
className={classNames(
|
||||
"px-2.5 py-1 rounded-lg border text-xs font-medium transition-all cursor-pointer",
|
||||
selectedTag === tag
|
||||
? "bg-purple-500/30 border-purple-400/60 text-purple-200 shadow-lg shadow-purple-500/20"
|
||||
: "bg-purple-500/10 border-purple-400/30 text-purple-300 hover:bg-purple-500/20"
|
||||
)}
|
||||
>
|
||||
{tag} ({tagCounts[tag]})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:pl-80">
|
||||
{/* Fixed search header */}
|
||||
<div className="fixed top-0 left-0 right-0 xl:left-80 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-gray-800 bg-gray-900/90 backdrop-blur-xl px-4 sm:px-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="-m-2.5 p-2.5 text-gray-300 xl:hidden hover:text-white transition-colors"
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1 relative">
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search memories..."
|
||||
aria-label="Search"
|
||||
className="col-start-1 row-start-1 block size-full bg-gray-800/50 rounded-lg pl-10 pr-4 text-base text-gray-100 border border-gray-700 focus:border-blue-500/50 outline-none placeholder:text-gray-500 sm:text-sm/6 transition-colors"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center ml-3 text-gray-500"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{connected && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gradient-to-r from-purple-500/20 to-blue-500/20 border border-purple-400/30">
|
||||
<div className="w-2 h-2 bg-purple-400 rounded-full animate-pulse shadow-lg shadow-purple-400/50" />
|
||||
<span className="text-xs font-bold text-purple-300 tracking-wide">LIVE</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setDebugOverviewCard(!debugOverviewCard)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all ${
|
||||
debugOverviewCard
|
||||
? 'bg-gradient-to-r from-blue-500/30 to-purple-500/30 border border-blue-400/60 text-blue-300'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-400 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
DEBUG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOverviewsOpen(true)}
|
||||
className="-m-2.5 p-2.5 text-gray-300 xl:hidden hover:text-white transition-colors"
|
||||
>
|
||||
<span className="sr-only">Open overviews</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="pt-16">
|
||||
{/* Activity Indicator Bar */}
|
||||
<div className="h-1 fixed top-16 left-0 right-0 xl:left-80 z-30" style={{
|
||||
background: 'linear-gradient(90deg, transparent, #3b82f6, #8b5cf6, #10b981, transparent)',
|
||||
animation: isProcessing ? 'scan 3s ease-in-out infinite' : 'none',
|
||||
opacity: isProcessing ? 1 : 0,
|
||||
boxShadow: isProcessing ? '0 0 20px rgba(59, 130, 246, 0.8)' : 'none'
|
||||
}} />
|
||||
|
||||
{/* Debug Overview Card Mode */}
|
||||
{debugOverviewCard && (
|
||||
<OverviewCard debugMode={true} initialState="empty" />
|
||||
)}
|
||||
|
||||
{/* Normal Memory Stream View */}
|
||||
{!debugOverviewCard && (
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-6">
|
||||
{!connected && (
|
||||
<div className="max-w-3xl mx-auto mb-12">
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 via-purple-600 to-emerald-600 rounded-2xl blur opacity-25 animate-pulse" />
|
||||
<div className="relative bg-gray-900/90 backdrop-blur-xl rounded-2xl p-8 border border-gray-800">
|
||||
<div className="text-center">
|
||||
<div className="relative inline-block mb-4">
|
||||
<div className="absolute inset-0 bg-blue-500/20 blur-3xl animate-pulse" />
|
||||
<div className="relative text-6xl">📡</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2 bg-gradient-to-r from-blue-300 to-purple-300 bg-clip-text text-transparent">
|
||||
{status === 'connecting' ? 'Connecting to Memory Stream' : 'Reconnecting...'}
|
||||
</h2>
|
||||
<p className="text-gray-400">~/.claude-mem/claude-mem.db</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connected && filteredMemories.length === 0 && (
|
||||
<div className="max-w-4xl mx-auto text-center py-20">
|
||||
<div className="relative inline-block">
|
||||
<div className="absolute inset-0 bg-purple-500/20 blur-3xl animate-pulse" />
|
||||
<div className="relative text-6xl mb-4">💭</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-300 mb-2">No Memories Found</h3>
|
||||
<p className="text-gray-500">
|
||||
{selectedProject === 'all'
|
||||
? 'No memories with titles in database'
|
||||
: `No memories for project: ${selectedProject}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredMemories.length > 0 && (
|
||||
<div className="mb-8 max-w-6xl mx-auto relative z-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
setSelectedProject(e.target.value);
|
||||
setCurrentIndex(0);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-gray-300 font-mono text-sm cursor-pointer hover:border-gray-600 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
>
|
||||
{projects.map(project => (
|
||||
<option key={project} value={project}>
|
||||
{project === 'all' ? 'All Projects' : project}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentIndex(i => (i - 1 + filteredMemories.length) % filteredMemories.length)}
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-600/20 to-purple-600/20 border border-blue-400/30 hover:border-blue-400/60 flex items-center justify-center transition-all duration-300 hover:scale-110 group"
|
||||
>
|
||||
<span className="text-blue-300 text-lg group-hover:text-blue-200">←</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="flex-1 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 via-purple-500 to-emerald-500 transition-all duration-300"
|
||||
style={{ width: `${((currentIndex + 1) / filteredMemories.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-gray-500 min-w-[80px] text-center">
|
||||
{currentIndex + 1} / {filteredMemories.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentIndex(i => (i + 1) % filteredMemories.length)}
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-600/20 to-blue-600/20 border border-purple-400/30 hover:border-purple-400/60 flex items-center justify-center transition-all duration-300 hover:scale-110 group"
|
||||
>
|
||||
<span className="text-purple-300 text-lg group-hover:text-purple-200">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredMemories.length > 0 && (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div key={memory.id} className="relative" style={{
|
||||
animation: 'slideIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
}}>
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-emerald-600/20 rounded-3xl blur-2xl" />
|
||||
|
||||
<div className="relative bg-gradient-to-br from-gray-900/90 to-gray-950/90 backdrop-blur-xl rounded-3xl p-12 border border-gray-800">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-blue-500/20 to-blue-500/10 border border-blue-400/30 text-blue-300">
|
||||
#{memory.id}
|
||||
</span>
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-purple-500/20 to-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
{memory.project}
|
||||
</span>
|
||||
{memory.origin && (
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-emerald-500/20 to-emerald-500/10 border border-emerald-400/30 text-emerald-300">
|
||||
{memory.origin}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs font-mono text-gray-500">
|
||||
{formatTimestamp(memory.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-300 via-purple-300 to-emerald-300 mb-4 leading-tight">
|
||||
{memory.title}
|
||||
</h1>
|
||||
|
||||
{memory.subtitle && (
|
||||
<p className="text-xl text-gray-400 leading-relaxed">
|
||||
{memory.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{memory.facts?.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-bold text-blue-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
FACTS EXTRACTED
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{memory.facts.map((fact, i) => (
|
||||
<div key={i} className="flex gap-3 text-gray-300 leading-relaxed" style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
animationFillMode: 'both'
|
||||
}}>
|
||||
<span className="text-blue-400 font-mono text-xs mt-1">▸</span>
|
||||
<span>{fact}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memory.concepts?.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-bold text-purple-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
CONCEPTS
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{memory.concepts.map((concept, i) => (
|
||||
<span key={i} className="px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-400/30 text-purple-300 text-sm font-medium" style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
animationFillMode: 'both'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memory.files_touched?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-emerald-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
FILES TOUCHED
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{memory.files_touched.map((file, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm font-mono text-emerald-300/80" style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
animationFillMode: 'both'
|
||||
}}>
|
||||
<span>📄</span>
|
||||
<span>{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-800 flex items-center justify-between">
|
||||
<div className="text-xs font-mono text-gray-600">
|
||||
session: {memory.session_id?.substring(0, 8)}...{memory.session_id?.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-gray-600">
|
||||
<p>← → arrow keys to navigate</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Mobile overviews drawer */}
|
||||
<Dialog open={overviewsOpen} onClose={setOverviewsOpen} className="relative z-50 xl:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex justify-end">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative ml-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:translate-x-full"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute right-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
|
||||
<button type="button" onClick={() => setOverviewsOpen(false)} className="-m-2.5 p-2.5">
|
||||
<span className="sr-only">Close overviews</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div className="relative flex grow flex-col overflow-y-auto bg-gray-900/90 backdrop-blur-xl border-l border-gray-800">
|
||||
<header className="flex items-center justify-between border-b border-gray-800 px-4 py-4 sm:px-6">
|
||||
<h2 className="text-base/7 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300">Session Overviews</h2>
|
||||
<span className="text-sm font-mono text-gray-500">{filteredOverviews.length}</span>
|
||||
</header>
|
||||
<ul role="list" className="divide-y divide-gray-800">
|
||||
{filteredOverviews.length === 0 && (
|
||||
<li className="px-4 py-12 text-center">
|
||||
<div className="relative inline-block">
|
||||
<div className="absolute inset-0 bg-purple-500/10 blur-2xl" />
|
||||
<div className="relative text-4xl mb-3 opacity-50">📋</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No overviews yet</p>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{filteredOverviews.map((overview) => (
|
||||
<li key={overview.id} className="px-4 py-4 sm:px-6 hover:bg-gray-800/30 transition-colors">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
#{overview.id}
|
||||
</span>
|
||||
{overview.isNew && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-blue-500/20 border border-blue-400/40 text-blue-300 animate-pulse">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-gray-500 truncate">
|
||||
{overview.project}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTimestamp(overview.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overview.promptTitle && (
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-blue-300 mb-1 leading-snug">
|
||||
{overview.promptTitle}
|
||||
</h3>
|
||||
{overview.promptSubtitle && (
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
{overview.promptSubtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-300 leading-relaxed line-clamp-6">
|
||||
{overview.content}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-gray-800">
|
||||
<div className="text-xs font-mono text-gray-600 truncate">
|
||||
session: {overview.session_id?.substring(0, 8)}...{overview.session_id?.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Desktop overviews sidebar */}
|
||||
<aside className="hidden xl:block bg-gray-900/90 backdrop-blur-xl xl:fixed xl:bottom-0 xl:right-0 xl:top-16 xl:w-96 xl:overflow-y-auto xl:border-l xl:border-gray-800">
|
||||
<header className="flex items-center justify-between border-b border-gray-800 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-base/7 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300">Session Overviews</h2>
|
||||
<span className="text-sm font-mono text-gray-500">{filteredOverviews.length}</span>
|
||||
</header>
|
||||
<ul role="list" className="divide-y divide-gray-800">
|
||||
{filteredOverviews.length === 0 && (
|
||||
<li className="px-4 py-12 text-center">
|
||||
<div className="relative inline-block">
|
||||
<div className="absolute inset-0 bg-purple-500/10 blur-2xl" />
|
||||
<div className="relative text-4xl mb-3 opacity-50">📋</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No overviews yet</p>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{filteredOverviews.map((overview) => (
|
||||
<li key={overview.id} className="px-4 py-4 sm:px-6 lg:px-8 hover:bg-gray-800/30 transition-colors">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
#{overview.id}
|
||||
</span>
|
||||
{overview.isNew && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-blue-500/20 border border-blue-400/40 text-blue-300 animate-pulse">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-gray-500 truncate">
|
||||
{overview.project}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTimestamp(overview.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overview.promptTitle && (
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-blue-300 mb-1 leading-snug">
|
||||
{overview.promptTitle}
|
||||
</h3>
|
||||
{overview.promptSubtitle && (
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
{overview.promptSubtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-300 leading-relaxed line-clamp-6">
|
||||
{overview.content}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-gray-800">
|
||||
<div className="text-xs font-mono text-gray-600 truncate">
|
||||
session: {overview.session_id?.substring(0, 8)}...{overview.session_id?.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes scan {
|
||||
0%, 100% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
# Memory Stream - Live Memory Viewer
|
||||
|
||||
A real-time slideshow viewer for claude-mem memories with SSE (Server-Sent Events) support.
|
||||
|
||||
## Features
|
||||
|
||||
- 📡 **Live streaming** - Automatically displays new memories as they're created
|
||||
- 🎬 **Auto-slideshow** - Cycles through memories every 5 seconds
|
||||
- ⏸️ **Pause/Resume** - Space bar or button controls
|
||||
- ⌨️ **Keyboard navigation** - Arrow keys to navigate
|
||||
- 🎨 **Beautiful UI** - Cyberpunk-themed neural network aesthetic
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Start the SSE server
|
||||
|
||||
```bash
|
||||
node src/ui/memory-stream/server.js
|
||||
# or use the package script:
|
||||
npm run memory-stream:server
|
||||
```
|
||||
|
||||
This will:
|
||||
- Watch `~/.claude-mem/claude-mem.db-wal` for changes
|
||||
- Serve SSE events on `http://localhost:3001/stream`
|
||||
- Automatically detect and broadcast new memories
|
||||
|
||||
### 2. Start your React dev server
|
||||
|
||||
```bash
|
||||
# In your React app directory
|
||||
npm run dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
### 3. Open the viewer
|
||||
|
||||
Navigate to your React app (usually `http://localhost:5173`)
|
||||
|
||||
## Usage
|
||||
|
||||
### Live Mode (Recommended)
|
||||
|
||||
1. Click **"CONNECT LIVE STREAM"**
|
||||
2. Server must be running (`node memory-stream-server.js`)
|
||||
3. New memories appear automatically as they're created
|
||||
4. Perfect for real-time monitoring during Claude Code sessions
|
||||
|
||||
### Presentation Mode (Alternative)
|
||||
|
||||
1. Click **"START PRESENTATION"**
|
||||
2. Select your `~/.claude-mem/claude-mem.db` file
|
||||
3. Static slideshow of existing memories
|
||||
4. No server required
|
||||
|
||||
## Controls
|
||||
|
||||
- **Space** - Pause/Resume slideshow
|
||||
- **←** - Previous memory
|
||||
- **→** - Next memory
|
||||
- **Click buttons** - Same as keyboard controls
|
||||
|
||||
## How It Works
|
||||
|
||||
### SSE Server
|
||||
- Uses `better-sqlite3` with WAL mode (already enabled in claude-mem)
|
||||
- Watches the `-wal` file for changes using `fs.watch()`
|
||||
- Queries for new memories when WAL changes detected
|
||||
- Broadcasts to all connected clients via Server-Sent Events
|
||||
|
||||
### React Client
|
||||
- Connects to SSE endpoint via `EventSource`
|
||||
- Auto-reconnects on connection loss
|
||||
- Appends new memories to the slideshow in real-time
|
||||
- No polling, pure event-driven updates
|
||||
|
||||
## Technical Details
|
||||
|
||||
**Database**: SQLite with WAL (Write-Ahead Logging) mode
|
||||
**Change Detection**: `fs.watch()` on `claude-mem.db-wal`
|
||||
**Transport**: Server-Sent Events (SSE)
|
||||
**Auto-reconnect**: 2-second retry on connection loss
|
||||
**CORS**: Enabled for local development
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Connection lost"**
|
||||
- Ensure server is running: `node src/ui/memory-stream/server.js`
|
||||
- Check port 3001 is available
|
||||
- Look for server console output
|
||||
|
||||
**No memories showing**
|
||||
- Verify memories exist with `title` field
|
||||
- Check database path: `~/.claude-mem/claude-mem.db`
|
||||
- Try "START PRESENTATION" mode to verify database access
|
||||
|
||||
**WAL file not found**
|
||||
- WAL mode auto-enabled by claude-mem
|
||||
- File created automatically on first write
|
||||
- Check database exists at expected path
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Memory Stream - Claude Mem</title>
|
||||
<script type="module" crossorigin src="/assets/index-BjZoir4u.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-5_3SV7cT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,120 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Memory Stream - Claude Mem</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './MemoryStream.jsx';
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react'
|
||||
import {
|
||||
ChartBarSquareIcon,
|
||||
Cog6ToothIcon,
|
||||
FolderIcon,
|
||||
GlobeAltIcon,
|
||||
ServerIcon,
|
||||
SignalIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { Bars3Icon, ChevronRightIcon, ChevronUpDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Projects', href: '#', icon: FolderIcon, current: false },
|
||||
{ name: 'Deployments', href: '#', icon: ServerIcon, current: true },
|
||||
{ name: 'Activity', href: '#', icon: SignalIcon, current: false },
|
||||
{ name: 'Domains', href: '#', icon: GlobeAltIcon, current: false },
|
||||
{ name: 'Usage', href: '#', icon: ChartBarSquareIcon, current: false },
|
||||
{ name: 'Settings', href: '#', icon: Cog6ToothIcon, current: false },
|
||||
]
|
||||
const teams = [
|
||||
{ id: 1, name: 'Planetaria', href: '#', initial: 'P', current: false },
|
||||
{ id: 2, name: 'Protocol', href: '#', initial: 'P', current: false },
|
||||
{ id: 3, name: 'Tailwind Labs', href: '#', initial: 'T', current: false },
|
||||
]
|
||||
const statuses = {
|
||||
offline: 'text-gray-400 bg-gray-100 dark:text-gray-500 dark:bg-gray-100/10',
|
||||
online: 'text-green-500 bg-green-500/10 dark:text-green-400 dark:bg-green-400/10',
|
||||
error: 'text-rose-500 bg-rose-500/10 dark:text-rose-400 dark:bg-rose-400/10',
|
||||
}
|
||||
const environments = {
|
||||
Preview: 'text-gray-500 bg-gray-50 ring-gray-200 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20',
|
||||
Production:
|
||||
'text-indigo-500 bg-indigo-50 ring-indigo-200 dark:text-indigo-400 dark:bg-indigo-400/10 dark:ring-indigo-400/30',
|
||||
}
|
||||
const deployments = [
|
||||
{
|
||||
id: 1,
|
||||
href: '#',
|
||||
projectName: 'ios-app',
|
||||
teamName: 'Planetaria',
|
||||
status: 'offline',
|
||||
statusText: 'Initiated 1m 32s ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
href: '#',
|
||||
projectName: 'mobile-api',
|
||||
teamName: 'Planetaria',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 3m ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Production',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
href: '#',
|
||||
projectName: 'tailwindcss.com',
|
||||
teamName: 'Tailwind Labs',
|
||||
status: 'offline',
|
||||
statusText: 'Deployed 3h ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
href: '#',
|
||||
projectName: 'company-website',
|
||||
teamName: 'Tailwind Labs',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 1d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
href: '#',
|
||||
projectName: 'relay-service',
|
||||
teamName: 'Protocol',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 1d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Production',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
href: '#',
|
||||
projectName: 'android-app',
|
||||
teamName: 'Planetaria',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 5d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
href: '#',
|
||||
projectName: 'api.protocol.chat',
|
||||
teamName: 'Protocol',
|
||||
status: 'error',
|
||||
statusText: 'Failed to deploy 6d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
href: '#',
|
||||
projectName: 'planetaria.tech',
|
||||
teamName: 'Planetaria',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 6d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
]
|
||||
const activityItems = [
|
||||
{
|
||||
user: {
|
||||
name: 'Michael Foster',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'ios-app',
|
||||
commit: '2d89f0c8',
|
||||
branch: 'main',
|
||||
date: '1h',
|
||||
dateTime: '2023-01-23T11:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Lindsay Walton',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'mobile-api',
|
||||
commit: '249df660',
|
||||
branch: 'main',
|
||||
date: '3h',
|
||||
dateTime: '2023-01-23T09:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Courtney Henry',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'ios-app',
|
||||
commit: '11464223',
|
||||
branch: 'main',
|
||||
date: '12h',
|
||||
dateTime: '2023-01-23T00:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Courtney Henry',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'company-website',
|
||||
commit: 'dad28e95',
|
||||
branch: 'main',
|
||||
date: '2d',
|
||||
dateTime: '2023-01-21T13:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Michael Foster',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'relay-service',
|
||||
commit: '624bc94c',
|
||||
branch: 'main',
|
||||
date: '5d',
|
||||
dateTime: '2023-01-18T12:34',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Courtney Henry',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'api.protocol.chat',
|
||||
commit: 'e111f80e',
|
||||
branch: 'main',
|
||||
date: '1w',
|
||||
dateTime: '2023-01-16T15:54',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Michael Foster',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'api.protocol.chat',
|
||||
commit: '5e136005',
|
||||
branch: 'main',
|
||||
date: '1w',
|
||||
dateTime: '2023-01-16T11:31',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Whitney Francis',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'ios-app',
|
||||
commit: '5c1fd07f',
|
||||
branch: 'main',
|
||||
date: '2w',
|
||||
dateTime: '2023-01-09T08:45',
|
||||
},
|
||||
]
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function Example() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
This example requires updating your template:
|
||||
|
||||
```
|
||||
<html class="h-full bg-white dark:bg-gray-900">
|
||||
<body class="h-full">
|
||||
```
|
||||
*/}
|
||||
<div>
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
|
||||
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-50 px-6 dark:bg-gray-900 dark:ring dark:ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
||||
<div className="relative flex h-16 shrink-0 items-center">
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
|
||||
className="h-8 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||
className="hidden h-8 w-auto dark:block"
|
||||
/>
|
||||
</div>
|
||||
<nav className="relative flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'text-indigo-600 dark:text-white'
|
||||
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
||||
'size-6 shrink-0',
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{teams.map((team) => (
|
||||
<li key={team.name}>
|
||||
<a
|
||||
href={team.href}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
|
||||
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{team.initial}
|
||||
</span>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li className="-mx-6 mt-auto">
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/5"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
className="size-8 rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
<span className="sr-only">Your profile</span>
|
||||
<span aria-hidden="true">Tom Cook</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col dark:bg-gray-900">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-50 px-6 ring-1 ring-gray-200 dark:bg-black/10 dark:ring-white/5">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
|
||||
className="h-8 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||
className="hidden h-8 w-auto dark:block"
|
||||
/>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'text-indigo-600 dark:text-white'
|
||||
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
||||
'size-6 shrink-0',
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<div className="text-xs/6 font-semibold text-gray-500 dark:text-gray-400">Your teams</div>
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{teams.map((team) => (
|
||||
<li key={team.name}>
|
||||
<a
|
||||
href={team.href}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
|
||||
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{team.initial}
|
||||
</span>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li className="-mx-6 mt-auto">
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/5"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
className="size-8 rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
<span className="sr-only">Your profile</span>
|
||||
<span aria-hidden="true">Tom Cook</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:pl-72">
|
||||
{/* Sticky search header */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-gray-200 bg-white px-4 shadow-sm sm:px-6 lg:px-8 dark:border-white/5 dark:bg-gray-900 dark:shadow-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="-m-2.5 p-2.5 text-gray-900 xl:hidden dark:text-white"
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
className="col-start-1 row-start-1 block size-full bg-transparent pl-8 text-base text-gray-900 outline-none placeholder:text-gray-400 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="lg:pr-96">
|
||||
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 dark:border-white/5">
|
||||
<h1 className="text-base/7 font-semibold text-gray-900 dark:text-white">Deployments</h1>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="flex items-center gap-x-1 text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Sort by
|
||||
<ChevronUpDownIcon aria-hidden="true" className="size-5 text-gray-500" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline outline-1 outline-gray-900/5 transition data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
|
||||
>
|
||||
Name
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
|
||||
>
|
||||
Date updated
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
|
||||
>
|
||||
Environment
|
||||
</a>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</header>
|
||||
|
||||
{/* Deployment list */}
|
||||
<ul role="list" className="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{deployments.map((deployment) => (
|
||||
<li key={deployment.id} className="relative flex items-center space-x-4 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="min-w-0 flex-auto">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className={classNames(statuses[deployment.status], 'flex-none rounded-full p-1')}>
|
||||
<div className="size-2 rounded-full bg-current" />
|
||||
</div>
|
||||
<h2 className="min-w-0 text-sm/6 font-semibold text-gray-900 dark:text-white">
|
||||
<a href={deployment.href} className="flex gap-x-2">
|
||||
<span className="truncate">{deployment.teamName}</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="whitespace-nowrap">{deployment.projectName}</span>
|
||||
<span className="absolute inset-0" />
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-x-2.5 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
<p className="truncate">{deployment.description}</p>
|
||||
<svg viewBox="0 0 2 2" className="size-0.5 flex-none fill-gray-300 dark:fill-gray-500">
|
||||
<circle r={1} cx={1} cy={1} />
|
||||
</svg>
|
||||
<p className="whitespace-nowrap">{deployment.statusText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
environments[deployment.environment],
|
||||
'flex-none rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
)}
|
||||
>
|
||||
{deployment.environment}
|
||||
</div>
|
||||
<ChevronRightIcon aria-hidden="true" className="size-5 flex-none text-gray-400" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
{/* Activity feed */}
|
||||
<aside className="bg-gray-50 lg:fixed lg:bottom-0 lg:right-0 lg:top-16 lg:w-96 lg:overflow-y-auto lg:border-l lg:border-gray-200 dark:bg-black/10 dark:lg:border-white/5">
|
||||
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 dark:border-white/5">
|
||||
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">Activity feed</h2>
|
||||
<a href="#" className="text-sm/6 font-semibold text-indigo-600 dark:text-indigo-400">
|
||||
View all
|
||||
</a>
|
||||
</header>
|
||||
<ul role="list" className="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{activityItems.map((item) => (
|
||||
<li key={item.commit} className="px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<img
|
||||
alt=""
|
||||
src={item.user.imageUrl}
|
||||
className="size-6 flex-none rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
<h3 className="flex-auto truncate text-sm/6 font-semibold text-gray-900 dark:text-white">
|
||||
{item.user.name}
|
||||
</h3>
|
||||
<time dateTime={item.dateTime} className="flex-none text-xs text-gray-500 dark:text-gray-600">
|
||||
{item.date}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-3 truncate text-sm text-gray-500">
|
||||
Pushed to <span className="text-gray-700 dark:text-gray-400">{item.projectName}</span> (
|
||||
<span className="font-mono text-gray-700 dark:text-gray-400">{item.commit}</span> on{' '}
|
||||
<span className="text-gray-700 dark:text-gray-400">{item.branch}</span>)
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import MemoryStream from './MemoryStream.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<MemoryStream />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Generated
-2707
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "memory-stream-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { watch, existsSync, readFileSync } from 'fs';
|
||||
import { createServer } from 'http';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const DB_PATH = join(homedir(), '.claude-mem/claude-mem.db');
|
||||
const SESSIONS_DIR = join(homedir(), '.claude-mem/sessions');
|
||||
const PORT = 3001;
|
||||
|
||||
let clients = [];
|
||||
let lastMaxId = 0;
|
||||
let lastOverviewId = 0;
|
||||
|
||||
function safeJsonParse(jsonString) {
|
||||
if (!jsonString) return [];
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getMemories(minId = 0) {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
const memories = db.prepare(`
|
||||
SELECT id, session_id, created_at, project, origin, title, subtitle, facts, concepts, files_touched
|
||||
FROM memories
|
||||
WHERE id > ? AND title IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
`).all(minId);
|
||||
db.close();
|
||||
|
||||
return memories.map(m => ({
|
||||
...m,
|
||||
facts: safeJsonParse(m.facts),
|
||||
concepts: safeJsonParse(m.concepts),
|
||||
files_touched: safeJsonParse(m.files_touched)
|
||||
}));
|
||||
}
|
||||
|
||||
function getOverviews(minId = 0) {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
const overviews = db.prepare(`
|
||||
SELECT id, session_id, content, created_at, project, origin
|
||||
FROM overviews
|
||||
WHERE id > ?
|
||||
ORDER BY id DESC
|
||||
`).all(minId);
|
||||
db.close();
|
||||
|
||||
// Enrich overviews with session titles/subtitles from session JSON files
|
||||
return overviews.map(overview => {
|
||||
const sessionFile = join(SESSIONS_DIR, `${overview.project}_streaming.json`);
|
||||
let promptTitle = null;
|
||||
let promptSubtitle = null;
|
||||
|
||||
try {
|
||||
if (existsSync(sessionFile)) {
|
||||
const sessionData = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
||||
// Only attach title/subtitle if it's from the same Claude session
|
||||
if (sessionData.claudeSessionId === overview.session_id) {
|
||||
promptTitle = sessionData.promptTitle || null;
|
||||
promptSubtitle = sessionData.promptSubtitle || null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors reading session file
|
||||
}
|
||||
|
||||
return {
|
||||
...overview,
|
||||
promptTitle,
|
||||
promptSubtitle
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getSessions() {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
|
||||
// Get unique sessions from overviews
|
||||
const sessions = db.prepare(`
|
||||
SELECT DISTINCT
|
||||
o.session_id,
|
||||
o.project,
|
||||
o.created_at,
|
||||
o.content as overview_content
|
||||
FROM overviews o
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT 50
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function getSessionData(sessionId) {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
|
||||
const overview = db.prepare(`
|
||||
SELECT id, session_id, content, created_at, project, origin
|
||||
FROM overviews
|
||||
WHERE session_id = ?
|
||||
LIMIT 1
|
||||
`).get(sessionId);
|
||||
|
||||
const memories = db.prepare(`
|
||||
SELECT id, session_id, created_at, project, origin, title, subtitle, facts, concepts, files_touched
|
||||
FROM memories
|
||||
WHERE session_id = ? AND title IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
`).all(sessionId);
|
||||
|
||||
db.close();
|
||||
|
||||
return {
|
||||
overview,
|
||||
memories: memories.map(m => ({
|
||||
...m,
|
||||
facts: safeJsonParse(m.facts),
|
||||
concepts: safeJsonParse(m.concepts),
|
||||
files_touched: safeJsonParse(m.files_touched)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast(type, data) {
|
||||
const message = `data: ${JSON.stringify({ type, ...data })}\n\n`;
|
||||
clients.forEach(client => client.write(message));
|
||||
}
|
||||
|
||||
function broadcastSessionState(eventType, project) {
|
||||
const message = `data: ${JSON.stringify({ type: eventType, project })}\n\n`;
|
||||
clients.forEach(client => client.write(message));
|
||||
console.log(`📡 Broadcasting ${eventType} for project: ${project}`);
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/stream') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
|
||||
clients.push(res);
|
||||
console.log(`🔌 Client connected (${clients.length} total)`);
|
||||
|
||||
const allMemories = getMemories(-1);
|
||||
lastMaxId = allMemories.length > 0 ? Math.max(...allMemories.map(m => m.id)) : 0;
|
||||
|
||||
const allOverviews = getOverviews(-1);
|
||||
lastOverviewId = allOverviews.length > 0 ? Math.max(...allOverviews.map(o => o.id)) : 0;
|
||||
|
||||
console.log(`📦 Sending ${allMemories.length} memories and ${allOverviews.length} overviews to new client`);
|
||||
broadcast('initial_load', { memories: allMemories, overviews: allOverviews });
|
||||
|
||||
req.on('close', () => {
|
||||
clients = clients.filter(client => client !== res);
|
||||
console.log(`🔌 Client disconnected (${clients.length} remaining)`);
|
||||
});
|
||||
} else if (req.url === '/api/sessions') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
const sessions = getSessions();
|
||||
res.end(JSON.stringify(sessions));
|
||||
} else if (req.url.startsWith('/api/session/')) {
|
||||
const sessionId = req.url.replace('/api/session/', '');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
const sessionData = getSessionData(sessionId);
|
||||
res.end(JSON.stringify(sessionData));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
watch(DB_PATH, (eventType) => {
|
||||
const newMemories = getMemories(lastMaxId);
|
||||
if (newMemories.length > 0) {
|
||||
lastMaxId = Math.max(...newMemories.map(m => m.id));
|
||||
console.log(`✨ Broadcasting ${newMemories.length} new memories`);
|
||||
broadcast('new_memories', { memories: newMemories });
|
||||
}
|
||||
|
||||
const newOverviews = getOverviews(lastOverviewId);
|
||||
if (newOverviews.length > 0) {
|
||||
lastOverviewId = Math.max(...newOverviews.map(o => o.id));
|
||||
console.log(`✨ Broadcasting ${newOverviews.length} new overviews`);
|
||||
broadcast('new_overviews', { overviews: newOverviews });
|
||||
}
|
||||
});
|
||||
|
||||
watch(SESSIONS_DIR, (eventType, filename) => {
|
||||
if (!filename || !filename.endsWith('_streaming.json')) return;
|
||||
|
||||
const project = filename.replace('_streaming.json', '');
|
||||
const sessionPath = join(SESSIONS_DIR, filename);
|
||||
|
||||
if (eventType === 'rename') {
|
||||
// Check if file exists to determine if it was created or deleted
|
||||
if (existsSync(sessionPath)) {
|
||||
broadcastSessionState('session_start', project);
|
||||
} else {
|
||||
broadcastSessionState('session_end', project);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Memory Stream Server running on http://localhost:${PORT}`);
|
||||
console.log(`📡 SSE endpoint: http://localhost:${PORT}/stream`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
clients.forEach(client => client.end());
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -1,570 +0,0 @@
|
||||
// Component ported and enhanced from https://codepen.io/JuanFuentes/pen/eYEeoyE
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform float mouse;
|
||||
uniform float uEnableWaves;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
float time = uTime * 5.;
|
||||
|
||||
float waveFactor = uEnableWaves;
|
||||
|
||||
vec3 transformed = position;
|
||||
|
||||
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
|
||||
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
|
||||
transformed.z += sin(time + position.x) * waveFactor;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
varying vec2 vUv;
|
||||
uniform float mouse;
|
||||
uniform float uTime;
|
||||
uniform sampler2D uTexture;
|
||||
|
||||
void main() {
|
||||
float time = uTime;
|
||||
vec2 pos = vUv;
|
||||
|
||||
float move = sin(time + mouse) * 0.01;
|
||||
float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r;
|
||||
float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g;
|
||||
float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b;
|
||||
float a = texture2D(uTexture, pos).a;
|
||||
gl_FragColor = vec4(r, g, b, a);
|
||||
}
|
||||
`;
|
||||
|
||||
function map(n, start, stop, start2, stop2) {
|
||||
return ((n - start) / (stop - start)) * (stop2 - start2) + start2;
|
||||
}
|
||||
|
||||
const PX_RATIO = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
|
||||
|
||||
class AsciiFilter {
|
||||
width = 0;
|
||||
height = 0;
|
||||
center = { x: 0, y: 0 };
|
||||
mouse = { x: 0, y: 0 };
|
||||
cols = 0;
|
||||
rows = 0;
|
||||
|
||||
constructor(renderer, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
charset,
|
||||
invert
|
||||
} = {}) {
|
||||
this.renderer = renderer;
|
||||
this.domElement = document.createElement('div');
|
||||
this.domElement.style.position = 'absolute';
|
||||
this.domElement.style.top = '0';
|
||||
this.domElement.style.left = '0';
|
||||
this.domElement.style.width = '100%';
|
||||
this.domElement.style.height = '100%';
|
||||
|
||||
this.pre = document.createElement('pre');
|
||||
this.domElement.appendChild(this.pre);
|
||||
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.context = this.canvas.getContext('2d');
|
||||
this.domElement.appendChild(this.canvas);
|
||||
|
||||
this.deg = 0;
|
||||
this.invert = invert ?? true;
|
||||
this.fontSize = fontSize ?? 12;
|
||||
this.fontFamily = fontFamily ?? "'Courier New', monospace";
|
||||
this.charset = charset ?? ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
|
||||
|
||||
if (this.context) {
|
||||
this.context.imageSmoothingEnabled = false;
|
||||
this.context.imageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
}
|
||||
|
||||
setSize(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.renderer.setSize(width, height);
|
||||
this.reset();
|
||||
|
||||
this.center = { x: width / 2, y: height / 2 };
|
||||
this.mouse = { x: this.center.x, y: this.center.y };
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.context) {
|
||||
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
|
||||
const charWidth = this.context.measureText('A').width;
|
||||
|
||||
this.cols = Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize)));
|
||||
this.rows = Math.floor(this.height / this.fontSize);
|
||||
|
||||
this.canvas.width = this.cols;
|
||||
this.canvas.height = this.rows;
|
||||
this.pre.style.fontFamily = this.fontFamily;
|
||||
this.pre.style.fontSize = `${this.fontSize}px`;
|
||||
this.pre.style.margin = '0';
|
||||
this.pre.style.padding = '0';
|
||||
this.pre.style.lineHeight = '1em';
|
||||
this.pre.style.position = 'absolute';
|
||||
this.pre.style.left = '50%';
|
||||
this.pre.style.top = '50%';
|
||||
this.pre.style.transform = 'translate(-50%, -50%)';
|
||||
this.pre.style.zIndex = '9';
|
||||
this.pre.style.backgroundAttachment = 'fixed';
|
||||
this.pre.style.mixBlendMode = 'difference';
|
||||
}
|
||||
}
|
||||
|
||||
render(scene, camera) {
|
||||
this.renderer.render(scene, camera);
|
||||
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
if (this.context) {
|
||||
this.context.clearRect(0, 0, w, h);
|
||||
if (this.context && w && h) {
|
||||
this.context.drawImage(this.renderer.domElement, 0, 0, w, h);
|
||||
}
|
||||
|
||||
this.asciify(this.context, w, h);
|
||||
this.hue();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
this.mouse = { x: e.clientX * PX_RATIO, y: e.clientY * PX_RATIO };
|
||||
}
|
||||
|
||||
get dx() {
|
||||
return this.mouse.x - this.center.x;
|
||||
}
|
||||
|
||||
get dy() {
|
||||
return this.mouse.y - this.center.y;
|
||||
}
|
||||
|
||||
hue() {
|
||||
const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI;
|
||||
this.deg += (deg - this.deg) * 0.075;
|
||||
this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)`;
|
||||
}
|
||||
|
||||
asciify(ctx, w, h) {
|
||||
if (w && h) {
|
||||
const imgData = ctx.getImageData(0, 0, w, h).data;
|
||||
let str = '';
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = x * 4 + y * 4 * w;
|
||||
const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];
|
||||
|
||||
if (a === 0) {
|
||||
str += ' ';
|
||||
continue;
|
||||
}
|
||||
|
||||
let gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
|
||||
let idx = Math.floor((1 - gray) * (this.charset.length - 1));
|
||||
if (this.invert) idx = this.charset.length - idx - 1;
|
||||
str += this.charset[idx];
|
||||
}
|
||||
str += '\n';
|
||||
}
|
||||
this.pre.innerHTML = str;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
}
|
||||
}
|
||||
|
||||
class CanvasTxt {
|
||||
constructor(txt, {
|
||||
fontSize = 200,
|
||||
fontFamily = 'Arial',
|
||||
color = '#fdf9f3'
|
||||
} = {}) {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.context = this.canvas.getContext('2d');
|
||||
this.txt = txt;
|
||||
this.fontSize = fontSize;
|
||||
this.fontFamily = fontFamily;
|
||||
this.color = color;
|
||||
|
||||
this.font = `600 ${this.fontSize}px ${this.fontFamily}`;
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.context) {
|
||||
this.context.font = this.font;
|
||||
|
||||
// Split text into lines
|
||||
const lines = this.txt.split('\n');
|
||||
|
||||
// Measure all lines to find max width
|
||||
let maxWidth = 0;
|
||||
for (const line of lines) {
|
||||
const metrics = this.context.measureText(line);
|
||||
maxWidth = Math.max(maxWidth, metrics.width);
|
||||
}
|
||||
|
||||
// Calculate total height (first line metrics for line height)
|
||||
const firstMetrics = this.context.measureText(lines[0] || 'A');
|
||||
const lineHeight = Math.ceil(firstMetrics.actualBoundingBoxAscent + firstMetrics.actualBoundingBoxDescent);
|
||||
|
||||
const textWidth = Math.ceil(maxWidth) + 20;
|
||||
const textHeight = lineHeight * lines.length + 20;
|
||||
|
||||
this.canvas.width = textWidth;
|
||||
this.canvas.height = textHeight;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.context) {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.fillStyle = this.color;
|
||||
this.context.font = this.font;
|
||||
|
||||
// Split text into lines and render each
|
||||
const lines = this.txt.split('\n');
|
||||
const metrics = this.context.measureText(lines[0] || 'A');
|
||||
const lineHeight = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const yPos = 10 + metrics.actualBoundingBoxAscent + (index * lineHeight);
|
||||
this.context.fillText(line, 10, yPos);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
|
||||
get texture() {
|
||||
return this.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
class CanvAscii {
|
||||
animationFrameId = 0;
|
||||
|
||||
constructor(
|
||||
{
|
||||
text,
|
||||
asciiFontSize,
|
||||
textFontSize,
|
||||
textColor,
|
||||
planeBaseHeight,
|
||||
enableWaves,
|
||||
enableMouseRotation
|
||||
},
|
||||
containerElem,
|
||||
width,
|
||||
height
|
||||
) {
|
||||
this.textString = text;
|
||||
this.asciiFontSize = asciiFontSize;
|
||||
this.textFontSize = textFontSize;
|
||||
this.textColor = textColor;
|
||||
this.planeBaseHeight = planeBaseHeight;
|
||||
this.container = containerElem;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.enableWaves = enableWaves;
|
||||
this.enableMouseRotation = enableMouseRotation;
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);
|
||||
this.camera.position.z = 30;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.mouse = { x: 0, y: 0 };
|
||||
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
|
||||
this.setMesh();
|
||||
this.setRenderer();
|
||||
}
|
||||
|
||||
setMesh() {
|
||||
this.textCanvas = new CanvasTxt(this.textString, {
|
||||
fontSize: this.textFontSize,
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
color: this.textColor
|
||||
});
|
||||
this.textCanvas.resize();
|
||||
this.textCanvas.render();
|
||||
|
||||
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
|
||||
this.texture.minFilter = THREE.NearestFilter;
|
||||
|
||||
const textAspect = this.textCanvas.width / this.textCanvas.height;
|
||||
const baseH = this.planeBaseHeight;
|
||||
const planeW = baseH * textAspect;
|
||||
const planeH = baseH;
|
||||
|
||||
this.geometry = new THREE.PlaneGeometry(planeW, planeH, 36, 36);
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
mouse: { value: 1.0 },
|
||||
uTexture: { value: this.texture },
|
||||
uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 }
|
||||
}
|
||||
});
|
||||
|
||||
this.mesh = new THREE.Mesh(this.geometry, this.material);
|
||||
this.scene.add(this.mesh);
|
||||
}
|
||||
|
||||
setRenderer() {
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
this.renderer.setPixelRatio(1);
|
||||
this.renderer.setClearColor(0x000000, 0);
|
||||
|
||||
this.filter = new AsciiFilter(this.renderer, {
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
fontSize: this.asciiFontSize,
|
||||
invert: true
|
||||
});
|
||||
|
||||
this.container.appendChild(this.filter.domElement);
|
||||
this.setSize(this.width, this.height);
|
||||
|
||||
this.container.addEventListener('mousemove', this.onMouseMove);
|
||||
this.container.addEventListener('touchmove', this.onMouseMove);
|
||||
}
|
||||
|
||||
setSize(w, h) {
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
this.filter.setSize(w, h);
|
||||
|
||||
this.center = { x: w / 2, y: h / 2 };
|
||||
}
|
||||
|
||||
load() {
|
||||
this.animate();
|
||||
}
|
||||
|
||||
onMouseMove(evt) {
|
||||
const e = (evt).touches ? (evt).touches[0] : (evt);
|
||||
const bounds = this.container.getBoundingClientRect();
|
||||
const x = e.clientX - bounds.left;
|
||||
const y = e.clientY - bounds.top;
|
||||
this.mouse = { x, y };
|
||||
}
|
||||
|
||||
animate() {
|
||||
const animateFrame = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animateFrame);
|
||||
this.render();
|
||||
};
|
||||
animateFrame();
|
||||
}
|
||||
|
||||
render() {
|
||||
const time = new Date().getTime() * 0.001;
|
||||
|
||||
this.textCanvas.render();
|
||||
this.texture.needsUpdate = true;
|
||||
|
||||
(this.mesh.material).uniforms.uTime.value = Math.sin(time);
|
||||
|
||||
this.updateRotation();
|
||||
this.filter.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
updateRotation() {
|
||||
if (!this.enableMouseRotation) return;
|
||||
|
||||
const x = map(this.mouse.y, 0, this.height, 0.5, -0.5);
|
||||
const y = map(this.mouse.x, 0, this.width, -0.5, 0.5);
|
||||
|
||||
this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05;
|
||||
this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.scene.traverse(object => {
|
||||
const obj = object;
|
||||
if (!obj.isMesh) return;
|
||||
[obj.material].flat().forEach(material => {
|
||||
material.dispose();
|
||||
Object.keys(material).forEach(key => {
|
||||
const matProp = material[key];
|
||||
if (matProp && typeof matProp === 'object' && 'dispose' in matProp && typeof matProp.dispose === 'function') {
|
||||
matProp.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
obj.geometry.dispose();
|
||||
});
|
||||
this.scene.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.filter.dispose();
|
||||
this.container.removeChild(this.filter.domElement);
|
||||
this.container.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.container.removeEventListener('touchmove', this.onMouseMove);
|
||||
this.clear();
|
||||
this.renderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export default function ASCIIText({
|
||||
text = 'David!',
|
||||
asciiFontSize = 8,
|
||||
textFontSize = 200,
|
||||
textColor = '#fdf9f3',
|
||||
planeBaseHeight = 8,
|
||||
enableWaves = true,
|
||||
enableMouseRotation = true
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const asciiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting && entry.boundingClientRect.width > 0 && entry.boundingClientRect.height > 0) {
|
||||
const { width: w, height: h } = entry.boundingClientRect;
|
||||
|
||||
asciiRef.current = new CanvAscii({
|
||||
text,
|
||||
asciiFontSize,
|
||||
textFontSize,
|
||||
textColor,
|
||||
planeBaseHeight,
|
||||
enableWaves,
|
||||
enableMouseRotation
|
||||
}, containerRef.current, w, h);
|
||||
asciiRef.current.load();
|
||||
|
||||
observer.disconnect();
|
||||
}
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (asciiRef.current) {
|
||||
asciiRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
asciiRef.current = new CanvAscii({
|
||||
text,
|
||||
asciiFontSize,
|
||||
textFontSize,
|
||||
textColor,
|
||||
planeBaseHeight,
|
||||
enableWaves,
|
||||
enableMouseRotation
|
||||
}, containerRef.current, width, height);
|
||||
asciiRef.current.load();
|
||||
|
||||
const ro = new ResizeObserver(entries => {
|
||||
if (!entries[0] || !asciiRef.current) return;
|
||||
const { width: w, height: h } = entries[0].contentRect;
|
||||
if (w > 0 && h > 0) {
|
||||
asciiRef.current.setSize(w, h);
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (asciiRef.current) {
|
||||
asciiRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, [text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, enableMouseRotation]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="ascii-text-container"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}>
|
||||
<style>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&display=swap');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ascii-text-container canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: optimizeSpeed;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -o-crisp-edges;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.ascii-text-container pre {
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
line-height: 1em;
|
||||
text-align: left;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background-image: radial-gradient(circle, #ff6188 0%, #fc9867 50%, #ffd866 100%);
|
||||
background-attachment: fixed;
|
||||
-webkit-text-fill-color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
z-index: 9;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Renderer, Program, Mesh, Triangle, Vec3 } from 'ogl';
|
||||
|
||||
export default function Orb({
|
||||
hue = 0,
|
||||
hoverIntensity = 0.2,
|
||||
rotateOnHover = true,
|
||||
forceHoverState = false
|
||||
}) {
|
||||
const ctnDom = useRef(null);
|
||||
|
||||
const vert = /* glsl */ `
|
||||
precision highp float;
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const frag = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float hue;
|
||||
uniform float hover;
|
||||
uniform float rot;
|
||||
uniform float hoverIntensity;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 rgb2yiq(vec3 c) {
|
||||
float y = dot(c, vec3(0.299, 0.587, 0.114));
|
||||
float i = dot(c, vec3(0.596, -0.274, -0.322));
|
||||
float q = dot(c, vec3(0.211, -0.523, 0.312));
|
||||
return vec3(y, i, q);
|
||||
}
|
||||
|
||||
vec3 yiq2rgb(vec3 c) {
|
||||
float r = c.x + 0.956 * c.y + 0.621 * c.z;
|
||||
float g = c.x - 0.272 * c.y - 0.647 * c.z;
|
||||
float b = c.x - 1.106 * c.y + 1.703 * c.z;
|
||||
return vec3(r, g, b);
|
||||
}
|
||||
|
||||
vec3 adjustHue(vec3 color, float hueDeg) {
|
||||
float hueRad = hueDeg * 3.14159265 / 180.0;
|
||||
vec3 yiq = rgb2yiq(color);
|
||||
float cosA = cos(hueRad);
|
||||
float sinA = sin(hueRad);
|
||||
float i = yiq.y * cosA - yiq.z * sinA;
|
||||
float q = yiq.y * sinA + yiq.z * cosA;
|
||||
yiq.y = i;
|
||||
yiq.z = q;
|
||||
return yiq2rgb(yiq);
|
||||
}
|
||||
|
||||
vec3 hash33(vec3 p3) {
|
||||
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
|
||||
p3 += dot(p3, p3.yxz + 19.19);
|
||||
return -1.0 + 2.0 * fract(vec3(
|
||||
p3.x + p3.y,
|
||||
p3.x + p3.z,
|
||||
p3.y + p3.z
|
||||
) * p3.zyx);
|
||||
}
|
||||
|
||||
float snoise3(vec3 p) {
|
||||
const float K1 = 0.333333333;
|
||||
const float K2 = 0.166666667;
|
||||
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
|
||||
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
|
||||
vec3 e = step(vec3(0.0), d0 - d0.yzx);
|
||||
vec3 i1 = e * (1.0 - e.zxy);
|
||||
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
|
||||
vec3 d1 = d0 - (i1 - K2);
|
||||
vec3 d2 = d0 - (i2 - K1);
|
||||
vec3 d3 = d0 - 0.5;
|
||||
vec4 h = max(0.6 - vec4(
|
||||
dot(d0, d0),
|
||||
dot(d1, d1),
|
||||
dot(d2, d2),
|
||||
dot(d3, d3)
|
||||
), 0.0);
|
||||
vec4 n = h * h * h * h * vec4(
|
||||
dot(d0, hash33(i)),
|
||||
dot(d1, hash33(i + i1)),
|
||||
dot(d2, hash33(i + i2)),
|
||||
dot(d3, hash33(i + 1.0))
|
||||
);
|
||||
return dot(vec4(31.316), n);
|
||||
}
|
||||
|
||||
vec4 extractAlpha(vec3 colorIn) {
|
||||
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
|
||||
return vec4(colorIn.rgb / (a + 1e-5), a);
|
||||
}
|
||||
|
||||
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
|
||||
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
|
||||
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
|
||||
const float innerRadius = 0.6;
|
||||
const float noiseScale = 0.65;
|
||||
|
||||
float light1(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * attenuation);
|
||||
}
|
||||
|
||||
float light2(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * dist * attenuation);
|
||||
}
|
||||
|
||||
vec4 draw(vec2 uv) {
|
||||
vec3 color1 = adjustHue(baseColor1, hue);
|
||||
vec3 color2 = adjustHue(baseColor2, hue);
|
||||
vec3 color3 = adjustHue(baseColor3, hue);
|
||||
|
||||
float ang = atan(uv.y, uv.x);
|
||||
float len = length(uv);
|
||||
float invLen = len > 0.0 ? 1.0 / len : 0.0;
|
||||
|
||||
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
|
||||
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
|
||||
float d0 = distance(uv, (r0 * invLen) * uv);
|
||||
float v0 = light1(1.0, 10.0, d0);
|
||||
v0 *= smoothstep(r0 * 1.05, r0, len);
|
||||
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
|
||||
|
||||
float a = iTime * -1.0;
|
||||
vec2 pos = vec2(cos(a), sin(a)) * r0;
|
||||
float d = distance(uv, pos);
|
||||
float v1 = light2(1.5, 5.0, d);
|
||||
v1 *= light1(1.0, 50.0, d0);
|
||||
|
||||
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
|
||||
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
|
||||
|
||||
vec3 col = mix(color1, color2, cl);
|
||||
col = mix(color3, col, v0);
|
||||
col = (col + v1) * v2 * v3;
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
|
||||
return extractAlpha(col);
|
||||
}
|
||||
|
||||
vec4 mainImage(vec2 fragCoord) {
|
||||
vec2 center = iResolution.xy * 0.5;
|
||||
float size = min(iResolution.x, iResolution.y);
|
||||
vec2 uv = (fragCoord - center) / size * 2.0;
|
||||
|
||||
float angle = rot;
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
||||
|
||||
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
|
||||
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
|
||||
|
||||
return draw(uv);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord = vUv * iResolution.xy;
|
||||
vec4 col = mainImage(fragCoord);
|
||||
gl_FragColor = vec4(col.rgb * col.a, col.a);
|
||||
}
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const container = ctnDom.current;
|
||||
if (!container) return;
|
||||
|
||||
const renderer = new Renderer({ alpha: true, premultipliedAlpha: false });
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
container.appendChild(gl.canvas);
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
const program = new Program(gl, {
|
||||
vertex: vert,
|
||||
fragment: frag,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: {
|
||||
value: new Vec3(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
|
||||
},
|
||||
hue: { value: hue },
|
||||
hover: { value: 0 },
|
||||
rot: { value: 0 },
|
||||
hoverIntensity: { value: hoverIntensity }
|
||||
}
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
function resize() {
|
||||
if (!container) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
renderer.setSize(width * dpr, height * dpr);
|
||||
gl.canvas.style.width = width + 'px';
|
||||
gl.canvas.style.height = height + 'px';
|
||||
program.uniforms.iResolution.value.set(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
let targetHover = 0;
|
||||
let lastTime = 0;
|
||||
let currentRot = 0;
|
||||
const rotationSpeed = 0.3;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const size = Math.min(width, height);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const uvX = ((x - centerX) / size) * 2.0;
|
||||
const uvY = ((y - centerY) / size) * 2.0;
|
||||
|
||||
if (Math.sqrt(uvX * uvX + uvY * uvY) < 0.8) {
|
||||
targetHover = 1;
|
||||
} else {
|
||||
targetHover = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
targetHover = 0;
|
||||
};
|
||||
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
container.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
let rafId;
|
||||
const update = (t) => {
|
||||
rafId = requestAnimationFrame(update);
|
||||
const dt = (t - lastTime) * 0.001;
|
||||
lastTime = t;
|
||||
program.uniforms.iTime.value = t * 0.001;
|
||||
program.uniforms.hue.value = hue;
|
||||
program.uniforms.hoverIntensity.value = hoverIntensity;
|
||||
|
||||
const effectiveHover = forceHoverState ? 1 : targetHover;
|
||||
program.uniforms.hover.value += (effectiveHover - program.uniforms.hover.value) * 0.1;
|
||||
|
||||
if (rotateOnHover && effectiveHover > 0.5) {
|
||||
currentRot += dt * rotationSpeed;
|
||||
}
|
||||
program.uniforms.rot.value = currentRot;
|
||||
|
||||
renderer.render({ scene: mesh });
|
||||
};
|
||||
rafId = requestAnimationFrame(update);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', resize);
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
container.removeChild(gl.canvas);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
}, [hue, hoverIntensity, rotateOnHover, forceHoverState]);
|
||||
|
||||
return <div ref={ctnDom} className="w-full h-full" />;
|
||||
}
|
||||
@@ -1,987 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Orb from './Orb';
|
||||
import ASCIIText from './ASCIIText';
|
||||
|
||||
const DUMMY_DATA = {
|
||||
title: 'Session Memory Processing',
|
||||
subtitle: 'Compressing conversation context into semantic memories',
|
||||
memories: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'First Memory',
|
||||
subtitle: 'Initial context capture',
|
||||
facts: ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||
concepts: ['concept1', 'concept2']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Second Memory',
|
||||
subtitle: 'Additional context',
|
||||
facts: ['Fact A', 'Fact B'],
|
||||
concepts: ['concept3']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Third Memory',
|
||||
subtitle: 'More context',
|
||||
facts: ['Fact X', 'Fact Y', 'Fact Z'],
|
||||
concepts: ['concept4', 'concept5', 'concept6']
|
||||
}
|
||||
],
|
||||
overview: 'This session involved implementing a progressive UI visualization system for memory processing. The user requested a session card component with four distinct states showing the evolution from empty state through memory accumulation to final overview completion.'
|
||||
};
|
||||
|
||||
export default function OverviewCard({
|
||||
debugMode = true,
|
||||
initialState = 'empty',
|
||||
sessionData = null // { overview, memories }
|
||||
}) {
|
||||
const [uiState, setUiState] = useState(initialState);
|
||||
const [orbOpacity, setOrbOpacity] = useState(0);
|
||||
const [titleOpacity, setTitleOpacity] = useState(0);
|
||||
const [asciiFontSize, setAsciiFontSize] = useState(64);
|
||||
const [cardOpacity, setCardOpacity] = useState(0);
|
||||
const [titlePosition, setTitlePosition] = useState('center'); // 'center' or 'top'
|
||||
const [visibleMemories, setVisibleMemories] = useState(0);
|
||||
const [overviewOpacity, setOverviewOpacity] = useState(0);
|
||||
const [expandedMemoryId, setExpandedMemoryId] = useState(null); // null = show overview, number = show expanded memory
|
||||
const [selectedSessionId, setSelectedSessionId] = useState(null);
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [loadedSessionData, setLoadedSessionData] = useState(null);
|
||||
|
||||
// Use provided sessionData or loaded session data or fallback to dummy data
|
||||
const data = sessionData || loadedSessionData || DUMMY_DATA;
|
||||
|
||||
// Orb parameters
|
||||
const [orbHue, setOrbHue] = useState(0);
|
||||
const [orbHoverIntensity, setOrbHoverIntensity] = useState(0.05);
|
||||
const [orbRotateOnHover, setOrbRotateOnHover] = useState(false);
|
||||
const [orbForceHoverState, setOrbForceHoverState] = useState(false);
|
||||
|
||||
// Load settings from localStorage or use defaults
|
||||
const loadSetting = (key, defaultValue) => {
|
||||
const saved = localStorage.getItem(`overviewCard_${key}`);
|
||||
return saved !== null ? JSON.parse(saved) : defaultValue;
|
||||
};
|
||||
|
||||
// ASCIIText parameters - Title
|
||||
const [asciiText, setAsciiText] = useState(() => loadSetting('asciiText', DUMMY_DATA.title));
|
||||
const [asciiTitleFontSize, setAsciiTitleFontSize] = useState(() => loadSetting('asciiTitleFontSize', 12));
|
||||
const [asciiTitleTextFontSize, setAsciiTitleTextFontSize] = useState(() => loadSetting('asciiTitleTextFontSize', 200));
|
||||
const [asciiTitleColor, setAsciiTitleColor] = useState(() => loadSetting('asciiTitleColor', '#60a5fa'));
|
||||
const [asciiTitlePlaneHeight, setAsciiTitlePlaneHeight] = useState(() => loadSetting('asciiTitlePlaneHeight', 8));
|
||||
const [asciiTitleEnableWaves, setAsciiTitleEnableWaves] = useState(() => loadSetting('asciiTitleEnableWaves', false));
|
||||
const [asciiTitleEnableMouseRotation, setAsciiTitleEnableMouseRotation] = useState(() => loadSetting('asciiTitleEnableMouseRotation', false));
|
||||
const [asciiTitleOffsetY, setAsciiTitleOffsetY] = useState(() => loadSetting('asciiTitleOffsetY', 0));
|
||||
|
||||
// ASCIIText parameters - Subtitle
|
||||
const [asciiSubtitle, setAsciiSubtitle] = useState(() => loadSetting('asciiSubtitle', DUMMY_DATA.subtitle));
|
||||
const [asciiSubtitleFontSize, setAsciiSubtitleFontSize] = useState(() => loadSetting('asciiSubtitleFontSize', 6));
|
||||
const [asciiSubtitleTextFontSize, setAsciiSubtitleTextFontSize] = useState(() => loadSetting('asciiSubtitleTextFontSize', 120));
|
||||
const [asciiSubtitleColor, setAsciiSubtitleColor] = useState(() => loadSetting('asciiSubtitleColor', '#60a5fa'));
|
||||
const [asciiSubtitlePlaneHeight, setAsciiSubtitlePlaneHeight] = useState(() => loadSetting('asciiSubtitlePlaneHeight', 4.8));
|
||||
const [asciiSubtitleEnableWaves, setAsciiSubtitleEnableWaves] = useState(() => loadSetting('asciiSubtitleEnableWaves', false));
|
||||
const [asciiSubtitleEnableMouseRotation, setAsciiSubtitleEnableMouseRotation] = useState(() => loadSetting('asciiSubtitleEnableMouseRotation', false));
|
||||
const [asciiSubtitleOffsetY, setAsciiSubtitleOffsetY] = useState(() => loadSetting('asciiSubtitleOffsetY', 0));
|
||||
|
||||
// Debug panel section expansion state
|
||||
const [sectionsExpanded, setSectionsExpanded] = useState({
|
||||
animation: true,
|
||||
orb: false,
|
||||
asciiTitle: false,
|
||||
asciiSubtitle: false
|
||||
});
|
||||
|
||||
// Save to localStorage whenever settings change
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiText', JSON.stringify(asciiText));
|
||||
}, [asciiText]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleFontSize', JSON.stringify(asciiTitleFontSize));
|
||||
}, [asciiTitleFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleTextFontSize', JSON.stringify(asciiTitleTextFontSize));
|
||||
}, [asciiTitleTextFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleColor', JSON.stringify(asciiTitleColor));
|
||||
}, [asciiTitleColor]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitlePlaneHeight', JSON.stringify(asciiTitlePlaneHeight));
|
||||
}, [asciiTitlePlaneHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleEnableWaves', JSON.stringify(asciiTitleEnableWaves));
|
||||
}, [asciiTitleEnableWaves]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleEnableMouseRotation', JSON.stringify(asciiTitleEnableMouseRotation));
|
||||
}, [asciiTitleEnableMouseRotation]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleOffsetY', JSON.stringify(asciiTitleOffsetY));
|
||||
}, [asciiTitleOffsetY]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitle', JSON.stringify(asciiSubtitle));
|
||||
}, [asciiSubtitle]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleFontSize', JSON.stringify(asciiSubtitleFontSize));
|
||||
}, [asciiSubtitleFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleTextFontSize', JSON.stringify(asciiSubtitleTextFontSize));
|
||||
}, [asciiSubtitleTextFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleColor', JSON.stringify(asciiSubtitleColor));
|
||||
}, [asciiSubtitleColor]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitlePlaneHeight', JSON.stringify(asciiSubtitlePlaneHeight));
|
||||
}, [asciiSubtitlePlaneHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleEnableWaves', JSON.stringify(asciiSubtitleEnableWaves));
|
||||
}, [asciiSubtitleEnableWaves]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleEnableMouseRotation', JSON.stringify(asciiSubtitleEnableMouseRotation));
|
||||
}, [asciiSubtitleEnableMouseRotation]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleOffsetY', JSON.stringify(asciiSubtitleOffsetY));
|
||||
}, [asciiSubtitleOffsetY]);
|
||||
|
||||
// Fetch available sessions
|
||||
useEffect(() => {
|
||||
if (debugMode) {
|
||||
fetch('http://localhost:3001/api/sessions')
|
||||
.then(res => res.json())
|
||||
.then(data => setSessions(data))
|
||||
.catch(err => console.error('Failed to fetch sessions:', err));
|
||||
}
|
||||
}, [debugMode]);
|
||||
|
||||
// Load session data when selected
|
||||
useEffect(() => {
|
||||
if (selectedSessionId && debugMode) {
|
||||
fetch(`http://localhost:3001/api/session/${selectedSessionId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Transform data to match expected format
|
||||
const formattedData = {
|
||||
title: data.overview?.content?.split('.')[0] || 'Session Overview',
|
||||
subtitle: data.overview?.content?.substring(0, 100) || '',
|
||||
overview: data.overview?.content || '',
|
||||
memories: data.memories || []
|
||||
};
|
||||
setLoadedSessionData(formattedData);
|
||||
// Auto-transition to complete state to show the data
|
||||
if (data.memories?.length > 0) {
|
||||
setUiState('complete');
|
||||
setVisibleMemories(data.memories.length);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch session data:', err));
|
||||
}
|
||||
}, [selectedSessionId, debugMode]);
|
||||
|
||||
// State transition effects
|
||||
useEffect(() => {
|
||||
switch (uiState) {
|
||||
case 'empty':
|
||||
// Reset everything
|
||||
setOrbOpacity(0);
|
||||
setTitleOpacity(0);
|
||||
setAsciiFontSize(64);
|
||||
setCardOpacity(0);
|
||||
setTitlePosition('center');
|
||||
setVisibleMemories(0);
|
||||
setOverviewOpacity(0);
|
||||
setAsciiText(DUMMY_DATA.title);
|
||||
setAsciiSubtitle(DUMMY_DATA.subtitle);
|
||||
|
||||
// Fade in orb and title
|
||||
setTimeout(() => setOrbOpacity(1), 100);
|
||||
setTimeout(() => {
|
||||
setTitleOpacity(1);
|
||||
// Start animating font size down
|
||||
let size = 64;
|
||||
const interval = setInterval(() => {
|
||||
size -= 2;
|
||||
if (size <= 12) {
|
||||
size = 12;
|
||||
clearInterval(interval);
|
||||
}
|
||||
setAsciiFontSize(size);
|
||||
}, 30);
|
||||
}, 200);
|
||||
break;
|
||||
|
||||
case 'first-memory':
|
||||
// Card fades in, title moves to top
|
||||
setCardOpacity(1);
|
||||
setTitlePosition('top');
|
||||
setVisibleMemories(1);
|
||||
break;
|
||||
|
||||
case 'accumulating':
|
||||
// Show all memories
|
||||
setVisibleMemories(data.memories?.length || DUMMY_DATA.memories.length);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
// Overview fades in, orb fades out, card becomes solid
|
||||
setOverviewOpacity(1);
|
||||
setOrbOpacity(0);
|
||||
// Make card fully opaque by increasing opacity even more
|
||||
setCardOpacity(1);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [uiState]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full min-h-screen">
|
||||
{/* Debug Controls */}
|
||||
{debugMode && (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-gray-900/95 backdrop-blur-xl border border-gray-700 rounded-xl w-96 max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h3 className="text-sm font-bold text-blue-400 mb-3">Debug Controls</h3>
|
||||
|
||||
{/* Session Selector */}
|
||||
<div className="mb-3">
|
||||
<label className="text-xs text-gray-400 mb-1 block">Load Real Session</label>
|
||||
<select
|
||||
value={selectedSessionId || ''}
|
||||
onChange={(e) => setSelectedSessionId(e.target.value || null)}
|
||||
className="w-full px-2 py-1.5 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
>
|
||||
<option value="">-- Dummy Data --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.session_id} value={session.session_id}>
|
||||
{session.project} - {new Date(session.created_at).toLocaleDateString()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* State Buttons - 2x2 Grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setUiState('empty')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'empty'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
1. Empty
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUiState('first-memory')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'first-memory'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
2. First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUiState('accumulating')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'accumulating'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
3. Accum
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUiState('complete')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'complete'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
4. Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-2">
|
||||
|
||||
{/* Animation State Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, animation: !s.animation }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-purple-400">Animation State</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.animation ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.animation && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Orb Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={orbOpacity}
|
||||
onChange={(e) => setOrbOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{orbOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Title Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={titleOpacity}
|
||||
onChange={(e) => setTitleOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{titleOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Card Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={cardOpacity}
|
||||
onChange={(e) => setCardOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{cardOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Overview Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={overviewOpacity}
|
||||
onChange={(e) => setOverviewOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{overviewOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Title Position</label>
|
||||
<select
|
||||
value={titlePosition}
|
||||
onChange={(e) => setTitlePosition(e.target.value)}
|
||||
className="px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
>
|
||||
<option value="center">Center</option>
|
||||
<option value="top">Top</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Visible Memories</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={data.memories?.length || 0}
|
||||
step="1"
|
||||
value={visibleMemories}
|
||||
onChange={(e) => setVisibleMemories(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{visibleMemories}/{data.memories?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Orb Parameters Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, orb: !s.orb }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-blue-400">Orb Parameters</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.orb ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.orb && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Hue</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
step="1"
|
||||
value={orbHue}
|
||||
onChange={(e) => setOrbHue(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{orbHue}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Hover Intensity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={orbHoverIntensity}
|
||||
onChange={(e) => setOrbHoverIntensity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{orbHoverIntensity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={orbRotateOnHover}
|
||||
onChange={(e) => setOrbRotateOnHover(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Rotate On Hover
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={orbForceHoverState}
|
||||
onChange={(e) => setOrbForceHoverState(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Force Hover State
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASCII Title Parameters Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, asciiTitle: !s.asciiTitle }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-emerald-400">ASCII Title</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.asciiTitle ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.asciiTitle && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Text</label>
|
||||
<textarea
|
||||
value={asciiText}
|
||||
onChange={(e) => setAsciiText(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">ASCII Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="64"
|
||||
step="1"
|
||||
value={asciiTitleFontSize}
|
||||
onChange={(e) => setAsciiTitleFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Text Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="400"
|
||||
step="10"
|
||||
value={asciiTitleTextFontSize}
|
||||
onChange={(e) => setAsciiTitleTextFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleTextFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={asciiTitleColor}
|
||||
onChange={(e) => setAsciiTitleColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={asciiTitleColor}
|
||||
onChange={(e) => setAsciiTitleColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Plane Height</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
step="0.5"
|
||||
value={asciiTitlePlaneHeight}
|
||||
onChange={(e) => setAsciiTitlePlaneHeight(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitlePlaneHeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Y Offset</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="10"
|
||||
value={asciiTitleOffsetY}
|
||||
onChange={(e) => setAsciiTitleOffsetY(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleOffsetY}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiTitleEnableWaves}
|
||||
onChange={(e) => setAsciiTitleEnableWaves(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Enable Waves
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiTitleEnableMouseRotation}
|
||||
onChange={(e) => setAsciiTitleEnableMouseRotation(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Mouse Rotation
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASCII Subtitle Parameters Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, asciiSubtitle: !s.asciiSubtitle }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-amber-400">ASCII Subtitle</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.asciiSubtitle ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.asciiSubtitle && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Text</label>
|
||||
<textarea
|
||||
value={asciiSubtitle}
|
||||
onChange={(e) => setAsciiSubtitle(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">ASCII Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="64"
|
||||
step="1"
|
||||
value={asciiSubtitleFontSize}
|
||||
onChange={(e) => setAsciiSubtitleFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Text Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="400"
|
||||
step="10"
|
||||
value={asciiSubtitleTextFontSize}
|
||||
onChange={(e) => setAsciiSubtitleTextFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleTextFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={asciiSubtitleColor}
|
||||
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={asciiSubtitleColor}
|
||||
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Plane Height</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
step="0.5"
|
||||
value={asciiSubtitlePlaneHeight}
|
||||
onChange={(e) => setAsciiSubtitlePlaneHeight(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitlePlaneHeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Y Offset</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="10"
|
||||
value={asciiSubtitleOffsetY}
|
||||
onChange={(e) => setAsciiSubtitleOffsetY(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleOffsetY}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiSubtitleEnableWaves}
|
||||
onChange={(e) => setAsciiSubtitleEnableWaves(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Enable Waves
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiSubtitleEnableMouseRotation}
|
||||
onChange={(e) => setAsciiSubtitleEnableMouseRotation(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Mouse Rotation
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orb Background Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none transition-opacity duration-500"
|
||||
style={{ opacity: orbOpacity }}
|
||||
>
|
||||
<Orb
|
||||
hue={orbHue}
|
||||
hoverIntensity={orbHoverIntensity}
|
||||
rotateOnHover={orbRotateOnHover}
|
||||
forceHoverState={orbForceHoverState}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating Title (State 1: Empty) */}
|
||||
{titlePosition === 'center' && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-500"
|
||||
style={{ opacity: titleOpacity }}
|
||||
>
|
||||
<div className="relative w-full flex flex-col items-center">
|
||||
<div
|
||||
className="relative w-full h-64"
|
||||
style={{ transform: `translateY(${asciiTitleOffsetY}px)` }}
|
||||
>
|
||||
<ASCIIText
|
||||
text={asciiText}
|
||||
asciiFontSize={asciiTitleFontSize}
|
||||
textFontSize={asciiTitleTextFontSize}
|
||||
textColor={asciiTitleColor}
|
||||
planeBaseHeight={asciiTitlePlaneHeight}
|
||||
enableWaves={asciiTitleEnableWaves}
|
||||
enableMouseRotation={asciiTitleEnableMouseRotation}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="relative w-full h-32"
|
||||
style={{ transform: `translateY(${asciiSubtitleOffsetY}px)` }}
|
||||
>
|
||||
<ASCIIText
|
||||
text={asciiSubtitle}
|
||||
asciiFontSize={asciiSubtitleFontSize}
|
||||
textFontSize={asciiSubtitleTextFontSize}
|
||||
textColor={asciiSubtitleColor}
|
||||
planeBaseHeight={asciiSubtitlePlaneHeight}
|
||||
enableWaves={asciiSubtitleEnableWaves}
|
||||
enableMouseRotation={asciiSubtitleEnableMouseRotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Card (States 2-4) */}
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-4 py-20 transition-opacity duration-500"
|
||||
style={{ opacity: cardOpacity }}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Blur background effect */}
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-emerald-600/20 rounded-3xl blur-2xl" />
|
||||
|
||||
{/* Card with backdrop blur */}
|
||||
<div
|
||||
className="relative rounded-3xl p-12 border border-gray-800 transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: uiState === 'complete'
|
||||
? 'rgba(10, 10, 15, 0.95)'
|
||||
: 'rgba(10, 10, 15, 0.7)',
|
||||
backdropFilter: 'blur(20px)'
|
||||
}}
|
||||
>
|
||||
{/* Title at top of card (States 2-4) */}
|
||||
{titlePosition === 'top' && (
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-300 via-purple-300 to-emerald-300 mb-4 leading-tight">
|
||||
{data.title || 'Session Overview'}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400 leading-relaxed">
|
||||
{data.subtitle || ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Section (State 4: Complete) */}
|
||||
{uiState === 'complete' && data.overview && (
|
||||
<div
|
||||
className="mb-8 pb-8 border-b border-gray-800 transition-opacity duration-500"
|
||||
style={{ opacity: overviewOpacity }}
|
||||
>
|
||||
<h3 className="text-sm font-bold text-emerald-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
SESSION OVERVIEW
|
||||
</h3>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
{data.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Memory View */}
|
||||
{expandedMemoryId !== null && (
|
||||
<div>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => setExpandedMemoryId(null)}
|
||||
className="flex items-center gap-2 mb-6 px-4 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50 hover:border-gray-600 transition-all"
|
||||
>
|
||||
<span className="text-lg">←</span>
|
||||
<span className="text-sm font-medium">Back to Overview</span>
|
||||
</button>
|
||||
|
||||
{/* Full Memory Card */}
|
||||
{(() => {
|
||||
const memory = data.memories?.find(m => m.id === expandedMemoryId);
|
||||
if (!memory) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-4">
|
||||
{memory.title}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-400">
|
||||
{memory.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{memory.facts && memory.facts.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-bold text-blue-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
FACTS EXTRACTED
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{memory.facts.map((fact, i) => (
|
||||
<div key={i} className="flex gap-3 text-gray-300 leading-relaxed">
|
||||
<span className="text-blue-400 font-mono text-xs mt-1">▸</span>
|
||||
<span>{fact}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memory.concepts && memory.concepts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-purple-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
CONCEPTS
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{memory.concepts.map((concept, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-400/30 text-purple-300 text-sm font-medium"
|
||||
>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory Mini-cards (Overview) */}
|
||||
{expandedMemoryId === null && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(data.memories || []).slice(0, visibleMemories).map((memory, index) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
onClick={() => setExpandedMemoryId(memory.id)}
|
||||
className="border border-gray-700/50 rounded-xl p-4 bg-gray-900/30 cursor-pointer hover:bg-gray-800/40 hover:border-gray-600/50 transition-all"
|
||||
style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
animationFillMode: 'both'
|
||||
}}
|
||||
>
|
||||
<h3 className="text-base font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-2">
|
||||
{memory.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 line-clamp-2 mb-3">
|
||||
{memory.subtitle}
|
||||
</p>
|
||||
|
||||
{/* Preview badges */}
|
||||
<div className="flex gap-2">
|
||||
{memory.facts && memory.facts.length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-blue-500/10 border border-blue-400/30 text-blue-300">
|
||||
{memory.facts.length} facts
|
||||
</span>
|
||||
)}
|
||||
{memory.concepts && memory.concepts.length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
{memory.concepts.length} concepts
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { platform, homedir } from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { chmodSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const isWindows = platform() === 'win32';
|
||||
|
||||
/**
|
||||
* Platform-specific utilities for cross-platform compatibility
|
||||
* Handles differences between Windows and Unix-like systems
|
||||
*/
|
||||
export const Platform = {
|
||||
/**
|
||||
* Returns the appropriate shell for the current platform
|
||||
*/
|
||||
getShell: (): string => {
|
||||
return isWindows ? 'powershell' : '/bin/sh';
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the file extension for hook scripts
|
||||
*/
|
||||
getHookExtension: (): string => {
|
||||
return '.js'; // Both platforms can execute Node.js scripts
|
||||
},
|
||||
|
||||
/**
|
||||
* Finds the path to an executable command
|
||||
* @param name - Name of the executable to find
|
||||
* @returns Full path to the executable
|
||||
*/
|
||||
findExecutable: (name: string): string => {
|
||||
const cmd = isWindows ? `where ${name}` : `which ${name}`;
|
||||
return execSync(cmd, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes a file executable (Unix only - no-op on Windows)
|
||||
* @param path - Path to the file to make executable
|
||||
*/
|
||||
makeExecutable: (path: string): void => {
|
||||
if (!isWindows) {
|
||||
chmodSync(path, 0o755);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Installs uv package manager using platform-specific method
|
||||
*/
|
||||
installUv: (): void => {
|
||||
if (isWindows) {
|
||||
execSync('powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"', {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
} else {
|
||||
execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', {
|
||||
stdio: 'pipe',
|
||||
shell: '/bin/sh'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns shell configuration file paths for the current platform
|
||||
* @returns Array of shell config file paths
|
||||
*/
|
||||
getShellConfigPaths: (): string[] => {
|
||||
const home = homedir();
|
||||
|
||||
if (isWindows) {
|
||||
return [
|
||||
join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
||||
join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1')
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
join(home, '.bashrc'),
|
||||
join(home, '.zshrc'),
|
||||
join(home, '.bash_profile')
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the appropriate alias syntax for the current platform's shell
|
||||
* @param aliasName - Name of the alias
|
||||
* @param command - Command to alias
|
||||
* @returns Alias definition string
|
||||
*/
|
||||
getAliasDefinition: (aliasName: string, command: string): string => {
|
||||
if (isWindows) {
|
||||
// PowerShell function syntax
|
||||
return `function ${aliasName} { ${command} $args }`;
|
||||
}
|
||||
|
||||
// Bash/Zsh alias syntax
|
||||
return `alias ${aliasName}='${command}'`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether the current platform is Windows
|
||||
*/
|
||||
isWindows: (): boolean => isWindows,
|
||||
|
||||
/**
|
||||
* Returns whether the current platform is Unix-like (macOS/Linux)
|
||||
*/
|
||||
isUnix: (): boolean => !isWindows
|
||||
};
|
||||
Reference in New Issue
Block a user