Compare commits

..

6 Commits

Author SHA1 Message Date
Alex Newman 5f15695c3f Release v3.9.16
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-06 22:55:16 -04:00
Alex Newman c49533c250 Release v3.9.14
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 21:47:35 -04:00
Alex Newman 4f49cb1bc9 Release v3.9.13
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 21:45:36 -04:00
Alex Newman 874726b193 Release v3.9.12
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 21:09:11 -04:00
Alex Newman 5244a12422 Release v3.9.11
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 20:55:36 -04:00
Alex Newman 5b30764fa8 Release v3.9.10
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 18:27:36 -04:00
36 changed files with 1811 additions and 7842 deletions
+420 -198
View File
@@ -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**
+265
View File
@@ -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.
+201 -171
View File
File diff suppressed because one or more lines are too long
+12 -6
View File
@@ -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 });
}
+194 -1
View File
@@ -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
View File
@@ -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 });
}
+36 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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> =======================================
+46 -44
View File
@@ -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
View File
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;
}
}
+5 -2
View File
@@ -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)
};
}
+44 -1
View File
@@ -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
];
-819
View File
@@ -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>
</>
);
}
-101
View File
@@ -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

-22
View File
@@ -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
-13
View File
@@ -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>
-120
View File
@@ -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;
}
}
-12
View File
@@ -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
View File
@@ -1 +0,0 @@
export { default } from './MemoryStream.jsx';
-8
View File
@@ -1,8 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
-604
View File
@@ -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>
</>
)
}
-10
View File
@@ -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>,
)
File diff suppressed because it is too large Load Diff
-34
View File
@@ -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"
}
}
-232
View File
@@ -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>
);
}
-274
View File
@@ -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>
);
}
-6
View File
@@ -1,6 +0,0 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
-19
View File
@@ -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
}
})
+112
View File
@@ -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
};