Release v3.9.9
Published from npm package build Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,248 @@
|
|||||||
# 🧠 Claude Memory System (claude-mem)
|
# 🧠 Claude Memory System (claude-mem)
|
||||||
|
|
||||||
## Remember that one thing? Neither do we… but `claude-mem` does! 😵💫
|
A real-time memory system for Claude Code that captures, compresses, and retrieves conversation context across sessions using semantic search and vector embeddings.
|
||||||
|
|
||||||
Stop repeating yourself. `claude-mem` remembers what you and Claude Code figure out, so every new chat starts smarter than the last.
|
## ⚡️ Quick Start
|
||||||
|
|
||||||
## ⚡️ 10‑Second Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g claude-mem && claude-mem install
|
|
||||||
```
|
|
||||||
|
|
||||||
That’s it. Restart Claude Code and you’re good. No config. No tedious setup or dependencies.
|
|
||||||
|
|
||||||
## ✨ What You Get
|
|
||||||
|
|
||||||
- Remembers key insights from your chats with Claude Code
|
|
||||||
- Starts new sessions with the right context
|
|
||||||
- Works quietly in the background
|
|
||||||
- One-command install and status check
|
|
||||||
|
|
||||||
## 🗑️ Smart Trash™ (Your Panic Button)
|
|
||||||
|
|
||||||
Delete something by accident? It’s not gone.
|
|
||||||
- Everything goes to `~/.claude-mem/trash/`
|
|
||||||
- Restore with a single command: `claude-mem restore`
|
|
||||||
- Timestamped so you can see when things moved
|
|
||||||
|
|
||||||
## 🎯 Why It’s Useful
|
|
||||||
|
|
||||||
- No more re-explaining your project over and over
|
|
||||||
- Pick up exactly where you left off
|
|
||||||
- Find past solutions fast when you face a familiar bug
|
|
||||||
- Your knowledge compounds the more you use it
|
|
||||||
|
|
||||||
## 🧭 Minimal Commands You’ll Ever Need
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude-mem install # Set up/repair integration
|
|
||||||
claude-mem status # Check everything’s working
|
|
||||||
claude-mem load-context # Peek at what it remembers
|
|
||||||
claude-mem logs # If you’re curious
|
|
||||||
claude-mem uninstall # Remove hooks
|
|
||||||
|
|
||||||
# Extras
|
|
||||||
claude-mem trash-view # See what’s in Smart Trash™
|
|
||||||
claude-mem restore # Restore deleted items
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📁 Where Stuff Lives (super simple)
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.claude-mem/
|
|
||||||
├── index/ # memory index
|
|
||||||
├── archives/ # transcripts
|
|
||||||
├── hooks/ # integration bits
|
|
||||||
├── trash/ # Smart Trash™
|
|
||||||
└── logs/ # diagnostics
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Requirements
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- Claude Code
|
|
||||||
|
|
||||||
## 🆘 If Something’s Weird
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude-mem status # quick health check
|
|
||||||
claude-mem install --force # fixes most issues
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
Licensed under AGPL-3.0. See `LICENSE`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ready to remember more and repeat less?
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g claude-mem
|
npm install -g claude-mem
|
||||||
claude-mem install
|
claude-mem install
|
||||||
```
|
```
|
||||||
|
|
||||||
Your future self will thank you. 🧠✨
|
Restart Claude Code. Memory capture starts automatically.
|
||||||
|
|
||||||
|
## ✨ What It Does
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
# Memory Operations
|
||||||
|
claude-mem load-context # View current session context
|
||||||
|
claude-mem logs # View operation logs
|
||||||
|
claude-mem changelog # Generate CHANGELOG.md from memories
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Storage Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude-mem/
|
||||||
|
├── chroma/ # ChromaDB vector database
|
||||||
|
├── archives/ # Compressed transcript backups
|
||||||
|
├── index/ # Legacy JSONL memory indices
|
||||||
|
├── hooks/ # Hook configuration files
|
||||||
|
├── trash/ # Smart Trash™ with recovery
|
||||||
|
├── logs/ # Operation logs
|
||||||
|
└── claude-mem.db # SQLite metadata database
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
**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/`
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Services** (`src/services/`)
|
||||||
|
- SQLite stores: Session, Memory, Overview, Diagnostics, TranscriptEvent
|
||||||
|
- Path discovery for project detection
|
||||||
|
- Rolling settings and logs
|
||||||
|
|
||||||
|
## 🔍 How Memory Search Works
|
||||||
|
|
||||||
|
**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
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**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"
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
## ✅ Requirements
|
||||||
|
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
- Bun >= 1.0.0 (for development)
|
||||||
|
- Claude Code with MCP support
|
||||||
|
- macOS/Linux (POSIX-compliant)
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Live Memory Viewer
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
AGPL-3.0 - See LICENSE file for details
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember more. Repeat less.** 🧠✨
|
||||||
Vendored
+417
-476
File diff suppressed because one or more lines are too long
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post Tool Use Hook - Streaming SDK Version
|
||||||
|
*
|
||||||
|
* Feeds tool responses to the streaming SDK session for real-time processing.
|
||||||
|
* SDK decides what to store and calls bash commands directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 = {}) {
|
||||||
|
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(HOOKS_LOG, logLine);
|
||||||
|
process.stderr.write(logLine);
|
||||||
|
} catch (error) {
|
||||||
|
// Silent fail on log errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed: buildStreamingToolMessage function
|
||||||
|
// Now using centralized config from hook-prompt-renderer.js
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||||
|
|
||||||
|
process.stdin.on('end', async () => {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = input ? JSON.parse(input) : {};
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('PostToolUse: JSON parse error', { error: error.message });
|
||||||
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tool_name, tool_response, prompt, cwd, timestamp } = payload;
|
||||||
|
const project = cwd ? getProjectName(cwd) : 'unknown';
|
||||||
|
|
||||||
|
// Return immediately - process async in background (don't block next tool)
|
||||||
|
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)) {
|
||||||
|
debugLog('PostToolUse: No streaming session found', { project });
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||||
|
const sdkSessionId = sessionData.sdkSessionId;
|
||||||
|
|
||||||
|
// Convert tool response to string
|
||||||
|
const toolResponseStr = typeof tool_response === 'string'
|
||||||
|
? tool_response
|
||||||
|
: JSON.stringify(tool_response);
|
||||||
|
|
||||||
|
// Build message for SDK using centralized config
|
||||||
|
const message = renderToolMessage({
|
||||||
|
toolName: tool_name,
|
||||||
|
toolResponse: toolResponseStr,
|
||||||
|
userPrompt: prompt || '',
|
||||||
|
timestamp: timestamp || new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to SDK and wait for processing to complete using centralized config
|
||||||
|
const response = query({
|
||||||
|
prompt: message,
|
||||||
|
options: {
|
||||||
|
model: HOOK_CONFIG.sdk.model,
|
||||||
|
resume: sdkSessionId,
|
||||||
|
allowedTools: HOOK_CONFIG.sdk.allowedTools,
|
||||||
|
maxTokens: HOOK_CONFIG.sdk.maxTokensTool,
|
||||||
|
cwd // Must match where transcript was created
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consume the stream to let SDK fully process
|
||||||
|
for await (const msg of response) {
|
||||||
|
debugLog('PostToolUse: SDK message', { type: msg.type, subtype: msg.subtype });
|
||||||
|
|
||||||
|
// SDK messages are structured differently than we expected
|
||||||
|
// - type: 'assistant' contains the assistant's response with content blocks
|
||||||
|
// - Content blocks can be text or tool_use
|
||||||
|
// - type: 'user' contains tool results
|
||||||
|
// - type: 'result' is the final summary
|
||||||
|
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
debugLog('PostToolUse: SDK text', { text: block.text?.slice(0, 200) });
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
debugLog('PostToolUse: SDK tool_use', {
|
||||||
|
tool: block.name,
|
||||||
|
input: JSON.stringify(block.input).slice(0, 200)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'user' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
debugLog('PostToolUse: SDK tool_result', {
|
||||||
|
tool_use_id: block.tool_use_id,
|
||||||
|
content: typeof block.content === 'string' ? block.content.slice(0, 300) : JSON.stringify(block.content).slice(0, 300)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result') {
|
||||||
|
debugLog('PostToolUse: SDK result', {
|
||||||
|
subtype: msg.subtype,
|
||||||
|
is_error: msg.is_error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('PostToolUse: SDK finished processing', { tool_name, sdkSessionId });
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('PostToolUse: Error sending to SDK', { error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit cleanly after async processing completes
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Start Hook (SDK Version)
|
||||||
|
*
|
||||||
|
* Calls the CLI to load relevant context from ChromaDB at session start.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHookResponse, debugLog } from './shared/hook-helpers.js';
|
||||||
|
|
||||||
|
// Read stdin
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
input += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdin.on('end', async () => {
|
||||||
|
const payload = input ? JSON.parse(input) : {};
|
||||||
|
|
||||||
|
debugLog('SessionStart hook invoked (SDK version)', { cwd: payload.cwd });
|
||||||
|
|
||||||
|
const { cwd, source } = payload;
|
||||||
|
|
||||||
|
// Run on startup or /clear
|
||||||
|
if (source !== 'startup' && source !== 'clear') {
|
||||||
|
const response = createHookResponse('SessionStart', true);
|
||||||
|
console.log(JSON.stringify(response));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the CLI to load context
|
||||||
|
const { executeCliCommand } = await import('./shared/hook-helpers.js');
|
||||||
|
|
||||||
|
const result = await executeCliCommand('claude-mem', ['load-context', '--format', 'session-start']);
|
||||||
|
|
||||||
|
if (result.success && result.stdout) {
|
||||||
|
// Use the CLI output directly as context (it's already formatted)
|
||||||
|
const response = createHookResponse('SessionStart', true, {
|
||||||
|
context: result.stdout
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(response));
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
// Return without context
|
||||||
|
const response = createHookResponse('SessionStart', true);
|
||||||
|
console.log(JSON.stringify(response));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue without context on error
|
||||||
|
const response = createHookResponse('SessionStart', true);
|
||||||
|
console.log(JSON.stringify(response));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -39,29 +39,31 @@ export function createHookResponse(hookType, success, options = {}) {
|
|||||||
if (hookType === 'SessionStart') {
|
if (hookType === 'SessionStart') {
|
||||||
if (success && options.context) {
|
if (success && options.context) {
|
||||||
return {
|
return {
|
||||||
continue: true,
|
|
||||||
suppressOutput: true,
|
|
||||||
hookSpecificOutput: {
|
hookSpecificOutput: {
|
||||||
hookEventName: 'SessionStart',
|
hookEventName: 'SessionStart',
|
||||||
additionalContext: options.context
|
additionalContext: options.context
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else if (success) {
|
} else {
|
||||||
// No context - just suppress output without any message
|
|
||||||
return {
|
return {
|
||||||
continue: true,
|
continue: true,
|
||||||
suppressOutput: true
|
suppressOutput: true
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
continue: true, // Continue even on context loading failure
|
|
||||||
suppressOutput: true,
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
additionalContext: `Context loading encountered an issue: ${options.error || 'Unknown error'}. Starting without previous context.`
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hookType === 'UserPromptSubmit' || hookType === 'PostToolUse') {
|
||||||
|
return {
|
||||||
|
continue: true,
|
||||||
|
suppressOutput: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hookType === 'Stop') {
|
||||||
|
return {
|
||||||
|
continue: true,
|
||||||
|
suppressOutput: true
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic response for unknown hook types
|
// Generic response for unknown hook types
|
||||||
@@ -115,9 +117,10 @@ export function formatSessionStartContext(contextData) {
|
|||||||
*/
|
*/
|
||||||
export async function executeCliCommand(command, args = [], options = {}) {
|
export async function executeCliCommand(command, args = [], options = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
const { input, ...spawnOptions } = options;
|
||||||
const process = spawn(command, args, {
|
const process = spawn(command, args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
...options
|
...spawnOptions
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -135,6 +138,13 @@ export async function executeCliCommand(command, args = [], options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input && process.stdin) {
|
||||||
|
process.stdin.write(input);
|
||||||
|
process.stdin.end();
|
||||||
|
} else if (process.stdin) {
|
||||||
|
process.stdin.end();
|
||||||
|
}
|
||||||
|
|
||||||
process.on('close', (code) => {
|
process.on('close', (code) => {
|
||||||
resolve({
|
resolve({
|
||||||
stdout: stdout.trim(),
|
stdout: stdout.trim(),
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// src/prompts/hook-prompts.config.ts
|
||||||
|
var HOOK_CONFIG = {
|
||||||
|
maxUserPromptLength: 200,
|
||||||
|
maxToolResponseLength: 20000,
|
||||||
|
sdk: {
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
allowedTools: ["Bash"],
|
||||||
|
maxTokensSystem: 8192,
|
||||||
|
maxTokensTool: 8192,
|
||||||
|
maxTokensEnd: 2048
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
|
||||||
|
|
||||||
|
# SESSION CONTEXT
|
||||||
|
- Project: {{project}}
|
||||||
|
- Session: {{sessionId}}
|
||||||
|
- Date: {{date}}
|
||||||
|
- User Request: "{{userPrompt}}"
|
||||||
|
|
||||||
|
# YOUR JOB
|
||||||
|
|
||||||
|
## FIRST: Generate Session Title
|
||||||
|
|
||||||
|
IMMEDIATELY generate a title and subtitle for this session based on the user request.
|
||||||
|
|
||||||
|
Use this bash command:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem update-session-metadata \\
|
||||||
|
--project "{{project}}" \\
|
||||||
|
--session "{{sessionId}}" \\
|
||||||
|
--title "Short title (3-6 words)" \\
|
||||||
|
--subtitle "One sentence description (max 20 words)"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Example for "Help me add dark mode to my app":
|
||||||
|
- Title: "Dark Mode Implementation"
|
||||||
|
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
|
||||||
|
|
||||||
|
## THEN: Process Tool Responses
|
||||||
|
|
||||||
|
You will receive a stream of tool responses. For each one:
|
||||||
|
|
||||||
|
1. ANALYZE: Does this contain information worth remembering?
|
||||||
|
2. DECIDE: Should I store this or skip it?
|
||||||
|
3. EXTRACT: What are the key semantic concepts?
|
||||||
|
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
|
||||||
|
5. STORE: Use bash to save the hierarchical memory
|
||||||
|
6. TRACK: Keep count of stored memories (001, 002, 003...)
|
||||||
|
|
||||||
|
# WHAT TO STORE
|
||||||
|
|
||||||
|
Store these:
|
||||||
|
- File contents with logic, algorithms, or patterns
|
||||||
|
- Search results revealing project structure
|
||||||
|
- Build errors or test failures with context
|
||||||
|
- Code revealing architecture or design decisions
|
||||||
|
- Git diffs with significant changes
|
||||||
|
- Command outputs showing system state
|
||||||
|
|
||||||
|
Skip these:
|
||||||
|
- Simple status checks (git status with no changes)
|
||||||
|
- Trivial edits (one-line config changes)
|
||||||
|
- Repeated operations
|
||||||
|
- Binary data or noise
|
||||||
|
- Anything without semantic value
|
||||||
|
|
||||||
|
# HIERARCHICAL MEMORY FORMAT
|
||||||
|
|
||||||
|
Each memory has FOUR components:
|
||||||
|
|
||||||
|
## 1. TITLE (3-8 words)
|
||||||
|
A scannable headline that captures the core action or topic.
|
||||||
|
Examples:
|
||||||
|
- "SDK Transcript Cleanup Implementation"
|
||||||
|
- "Hook System Architecture Analysis"
|
||||||
|
- "ChromaDB Migration Planning"
|
||||||
|
|
||||||
|
## 2. SUBTITLE (max 24 words)
|
||||||
|
A concise, memorable summary that captures the essence of the change.
|
||||||
|
Examples:
|
||||||
|
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
|
||||||
|
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
|
||||||
|
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Clear and descriptive
|
||||||
|
- Focus on the outcome or benefit
|
||||||
|
- Use active voice when possible
|
||||||
|
- Keep it professional and informative
|
||||||
|
|
||||||
|
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
|
||||||
|
Individual, searchable statements that can be vector-embedded separately.
|
||||||
|
Each fact is ONE specific piece of information.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
|
||||||
|
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
|
||||||
|
- "Uses fs.unlink with graceful error handling for missing files"
|
||||||
|
- "Checks two transcript path formats for backward compatibility"
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Start with filename or component when relevant
|
||||||
|
- Be specific: include paths, function names, actual values
|
||||||
|
- Each fact stands alone (no pronouns like "it" or "this")
|
||||||
|
- 50-150 characters target
|
||||||
|
- Focus on searchable technical details
|
||||||
|
|
||||||
|
## 4. NARRATIVE (512-1024 tokens, same as current format)
|
||||||
|
The full contextual story for deep dives:
|
||||||
|
|
||||||
|
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
|
||||||
|
|
||||||
|
This is the detailed explanation for when someone needs full context.
|
||||||
|
|
||||||
|
# STORAGE COMMAND FORMAT
|
||||||
|
|
||||||
|
Store using this EXACT bash command structure:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-memory \\
|
||||||
|
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
|
||||||
|
--title "Your Title Here" \\
|
||||||
|
--subtitle "Your concise subtitle here" \\
|
||||||
|
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
|
||||||
|
--concepts '["concept1", "concept2", "concept3"]' \\
|
||||||
|
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
|
||||||
|
--project "{{project}}" \\
|
||||||
|
--session "{{sessionId}}" \\
|
||||||
|
--date "{{date}}"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
CRITICAL FORMATTING RULES:
|
||||||
|
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
|
||||||
|
- Use double quotes inside the JSON arrays: "item"
|
||||||
|
- Use double quotes around simple string values: --title "Title"
|
||||||
|
- Escape any quotes in the content properly
|
||||||
|
- Sequential numbering: 001, 002, 003, etc.
|
||||||
|
|
||||||
|
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
|
||||||
|
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
|
||||||
|
|
||||||
|
# EXAMPLE MEMORY
|
||||||
|
|
||||||
|
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
|
||||||
|
|
||||||
|
Your storage command:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-memory \\
|
||||||
|
--id "claude-mem_abc123_2025-10-01_001" \\
|
||||||
|
--title "SDK Transcript Auto-Cleanup" \\
|
||||||
|
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
|
||||||
|
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
|
||||||
|
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
|
||||||
|
--files '["hooks/stop-streaming.js"]' \\
|
||||||
|
--project "claude-mem" \\
|
||||||
|
--session "abc123" \\
|
||||||
|
--date "2025-10-01"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# STATE TRACKING
|
||||||
|
|
||||||
|
CRITICAL: Keep track of your memory counter across all tool messages.
|
||||||
|
- Start at 001
|
||||||
|
- Increment for each stored memory
|
||||||
|
- Never repeat numbers
|
||||||
|
- Each session has separate numbering
|
||||||
|
|
||||||
|
# SESSION END
|
||||||
|
|
||||||
|
At the end (when I send "SESSION ENDING"), generate an overview using:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# IMPORTANT REMINDERS
|
||||||
|
|
||||||
|
- You're processing a DIFFERENT Claude Code session (not your own)
|
||||||
|
- Use Bash tool to call claude-mem commands
|
||||||
|
- Keep subtitles clear and informative (max 24 words)
|
||||||
|
- Each fact is ONE specific thing (not multiple ideas)
|
||||||
|
- Be selective - quality over quantity
|
||||||
|
- Always increment memory numbers
|
||||||
|
- Facts should be searchable (specific file names, paths, functions)
|
||||||
|
|
||||||
|
Ready for tool responses.`;
|
||||||
|
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
|
||||||
|
|
||||||
|
Tool: {{toolName}}
|
||||||
|
User Context: "{{userPrompt}}"
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
{{toolResponse}}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Analyze and store if meaningful.`;
|
||||||
|
var END_MESSAGE = `# SESSION ENDING
|
||||||
|
|
||||||
|
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
|
||||||
|
|
||||||
|
Store it using Bash:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Focus on: what was done, current state, key decisions, outcomes.`;
|
||||||
|
var PROMPTS = {
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
tool: TOOL_MESSAGE,
|
||||||
|
end: END_MESSAGE
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/prompts/hook-prompt-renderer.ts
|
||||||
|
function substituteVariables(template, variables) {
|
||||||
|
let result = template;
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
const placeholder = `{{${key}}}`;
|
||||||
|
result = result.split(placeholder).join(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function truncate(text, maxLength) {
|
||||||
|
if (text.length <= maxLength)
|
||||||
|
return text;
|
||||||
|
return text.slice(0, maxLength) + (text.length > maxLength ? "..." : "");
|
||||||
|
}
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
const timePart = timestamp.split("T")[1];
|
||||||
|
if (!timePart)
|
||||||
|
return "";
|
||||||
|
return timePart.slice(0, 8);
|
||||||
|
}
|
||||||
|
function renderSystemPrompt(variables) {
|
||||||
|
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
|
||||||
|
return substituteVariables(PROMPTS.system, {
|
||||||
|
project: variables.project,
|
||||||
|
sessionId: variables.sessionId,
|
||||||
|
date: variables.date,
|
||||||
|
userPrompt: userPromptTruncated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderToolMessage(variables) {
|
||||||
|
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
|
||||||
|
const toolResponseTruncated = truncate(variables.toolResponse, HOOK_CONFIG.maxToolResponseLength);
|
||||||
|
const timeFormatted = formatTime(variables.timestamp);
|
||||||
|
return substituteVariables(PROMPTS.tool, {
|
||||||
|
toolName: variables.toolName,
|
||||||
|
toolResponse: toolResponseTruncated,
|
||||||
|
userPrompt: userPromptTruncated,
|
||||||
|
timestamp: variables.timestamp,
|
||||||
|
timeFormatted
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderEndMessage(variables) {
|
||||||
|
return substituteVariables(PROMPTS.end, {
|
||||||
|
project: variables.project,
|
||||||
|
sessionId: variables.sessionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderPrompt(type, variables) {
|
||||||
|
switch (type) {
|
||||||
|
case "system":
|
||||||
|
return renderSystemPrompt(variables);
|
||||||
|
case "tool":
|
||||||
|
return renderToolMessage(variables);
|
||||||
|
case "end":
|
||||||
|
return renderEndMessage(variables);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown prompt type: ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
renderToolMessage,
|
||||||
|
renderSystemPrompt,
|
||||||
|
renderPrompt,
|
||||||
|
renderEndMessage,
|
||||||
|
PROMPTS,
|
||||||
|
HOOK_CONFIG
|
||||||
|
};
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// src/prompts/hook-prompts.config.ts
|
||||||
|
var HOOK_CONFIG = {
|
||||||
|
maxUserPromptLength: 200,
|
||||||
|
maxToolResponseLength: 20000,
|
||||||
|
sdk: {
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
allowedTools: ["Bash"],
|
||||||
|
maxTokensSystem: 8192,
|
||||||
|
maxTokensTool: 8192,
|
||||||
|
maxTokensEnd: 2048
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
|
||||||
|
|
||||||
|
# SESSION CONTEXT
|
||||||
|
- Project: {{project}}
|
||||||
|
- Session: {{sessionId}}
|
||||||
|
- Date: {{date}}
|
||||||
|
- User Request: "{{userPrompt}}"
|
||||||
|
|
||||||
|
# YOUR JOB
|
||||||
|
|
||||||
|
## FIRST: Generate Session Title
|
||||||
|
|
||||||
|
IMMEDIATELY generate a title and subtitle for this session based on the user request.
|
||||||
|
|
||||||
|
Use this bash command:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem update-session-metadata \\
|
||||||
|
--project "{{project}}" \\
|
||||||
|
--session "{{sessionId}}" \\
|
||||||
|
--title "Short title (3-6 words)" \\
|
||||||
|
--subtitle "One sentence description (max 20 words)"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Example for "Help me add dark mode to my app":
|
||||||
|
- Title: "Dark Mode Implementation"
|
||||||
|
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
|
||||||
|
|
||||||
|
## THEN: Process Tool Responses
|
||||||
|
|
||||||
|
You will receive a stream of tool responses. For each one:
|
||||||
|
|
||||||
|
1. ANALYZE: Does this contain information worth remembering?
|
||||||
|
2. DECIDE: Should I store this or skip it?
|
||||||
|
3. EXTRACT: What are the key semantic concepts?
|
||||||
|
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
|
||||||
|
5. STORE: Use bash to save the hierarchical memory
|
||||||
|
6. TRACK: Keep count of stored memories (001, 002, 003...)
|
||||||
|
|
||||||
|
# WHAT TO STORE
|
||||||
|
|
||||||
|
Store these:
|
||||||
|
- File contents with logic, algorithms, or patterns
|
||||||
|
- Search results revealing project structure
|
||||||
|
- Build errors or test failures with context
|
||||||
|
- Code revealing architecture or design decisions
|
||||||
|
- Git diffs with significant changes
|
||||||
|
- Command outputs showing system state
|
||||||
|
|
||||||
|
Skip these:
|
||||||
|
- Simple status checks (git status with no changes)
|
||||||
|
- Trivial edits (one-line config changes)
|
||||||
|
- Repeated operations
|
||||||
|
- Binary data or noise
|
||||||
|
- Anything without semantic value
|
||||||
|
|
||||||
|
# HIERARCHICAL MEMORY FORMAT
|
||||||
|
|
||||||
|
Each memory has FOUR components:
|
||||||
|
|
||||||
|
## 1. TITLE (3-8 words)
|
||||||
|
A scannable headline that captures the core action or topic.
|
||||||
|
Examples:
|
||||||
|
- "SDK Transcript Cleanup Implementation"
|
||||||
|
- "Hook System Architecture Analysis"
|
||||||
|
- "ChromaDB Migration Planning"
|
||||||
|
|
||||||
|
## 2. SUBTITLE (max 24 words)
|
||||||
|
A concise, memorable summary that captures the essence of the change.
|
||||||
|
Examples:
|
||||||
|
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
|
||||||
|
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
|
||||||
|
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Clear and descriptive
|
||||||
|
- Focus on the outcome or benefit
|
||||||
|
- Use active voice when possible
|
||||||
|
- Keep it professional and informative
|
||||||
|
|
||||||
|
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
|
||||||
|
Individual, searchable statements that can be vector-embedded separately.
|
||||||
|
Each fact is ONE specific piece of information.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
|
||||||
|
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
|
||||||
|
- "Uses fs.unlink with graceful error handling for missing files"
|
||||||
|
- "Checks two transcript path formats for backward compatibility"
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Start with filename or component when relevant
|
||||||
|
- Be specific: include paths, function names, actual values
|
||||||
|
- Each fact stands alone (no pronouns like "it" or "this")
|
||||||
|
- 50-150 characters target
|
||||||
|
- Focus on searchable technical details
|
||||||
|
|
||||||
|
## 4. NARRATIVE (512-1024 tokens, same as current format)
|
||||||
|
The full contextual story for deep dives:
|
||||||
|
|
||||||
|
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
|
||||||
|
|
||||||
|
This is the detailed explanation for when someone needs full context.
|
||||||
|
|
||||||
|
# STORAGE COMMAND FORMAT
|
||||||
|
|
||||||
|
Store using this EXACT bash command structure:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-memory \\
|
||||||
|
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
|
||||||
|
--title "Your Title Here" \\
|
||||||
|
--subtitle "Your concise subtitle here" \\
|
||||||
|
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
|
||||||
|
--concepts '["concept1", "concept2", "concept3"]' \\
|
||||||
|
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
|
||||||
|
--project "{{project}}" \\
|
||||||
|
--session "{{sessionId}}" \\
|
||||||
|
--date "{{date}}"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
CRITICAL FORMATTING RULES:
|
||||||
|
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
|
||||||
|
- Use double quotes inside the JSON arrays: "item"
|
||||||
|
- Use double quotes around simple string values: --title "Title"
|
||||||
|
- Escape any quotes in the content properly
|
||||||
|
- Sequential numbering: 001, 002, 003, etc.
|
||||||
|
|
||||||
|
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
|
||||||
|
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
|
||||||
|
|
||||||
|
# EXAMPLE MEMORY
|
||||||
|
|
||||||
|
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
|
||||||
|
|
||||||
|
Your storage command:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-memory \\
|
||||||
|
--id "claude-mem_abc123_2025-10-01_001" \\
|
||||||
|
--title "SDK Transcript Auto-Cleanup" \\
|
||||||
|
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
|
||||||
|
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
|
||||||
|
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
|
||||||
|
--files '["hooks/stop-streaming.js"]' \\
|
||||||
|
--project "claude-mem" \\
|
||||||
|
--session "abc123" \\
|
||||||
|
--date "2025-10-01"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# STATE TRACKING
|
||||||
|
|
||||||
|
CRITICAL: Keep track of your memory counter across all tool messages.
|
||||||
|
- Start at 001
|
||||||
|
- Increment for each stored memory
|
||||||
|
- Never repeat numbers
|
||||||
|
- Each session has separate numbering
|
||||||
|
|
||||||
|
# SESSION END
|
||||||
|
|
||||||
|
At the end (when I send "SESSION ENDING"), generate an overview using:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# IMPORTANT REMINDERS
|
||||||
|
|
||||||
|
- You're processing a DIFFERENT Claude Code session (not your own)
|
||||||
|
- Use Bash tool to call claude-mem commands
|
||||||
|
- Keep subtitles clear and informative (max 24 words)
|
||||||
|
- Each fact is ONE specific thing (not multiple ideas)
|
||||||
|
- Be selective - quality over quantity
|
||||||
|
- Always increment memory numbers
|
||||||
|
- Facts should be searchable (specific file names, paths, functions)
|
||||||
|
|
||||||
|
Ready for tool responses.`;
|
||||||
|
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
|
||||||
|
|
||||||
|
Tool: {{toolName}}
|
||||||
|
User Context: "{{userPrompt}}"
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
{{toolResponse}}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Analyze and store if meaningful.`;
|
||||||
|
var END_MESSAGE = `# SESSION ENDING
|
||||||
|
|
||||||
|
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
|
||||||
|
|
||||||
|
Store it using Bash:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Focus on: what was done, current state, key decisions, outcomes.`;
|
||||||
|
var PROMPTS = {
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
tool: TOOL_MESSAGE,
|
||||||
|
end: END_MESSAGE
|
||||||
|
};
|
||||||
|
export {
|
||||||
|
TOOL_MESSAGE,
|
||||||
|
SYSTEM_PROMPT,
|
||||||
|
PROMPTS,
|
||||||
|
HOOK_CONFIG,
|
||||||
|
END_MESSAGE
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path resolver utility for Claude Memory hooks
|
||||||
|
* Provides proper path handling using environment variables
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join, basename } from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base data directory for claude-mem
|
||||||
|
* @returns {string} Data directory path
|
||||||
|
*/
|
||||||
|
export function getDataDir() {
|
||||||
|
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the settings file path
|
||||||
|
* @returns {string} Settings file path
|
||||||
|
*/
|
||||||
|
export function getSettingsPath() {
|
||||||
|
return join(getDataDir(), 'settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the archives directory path
|
||||||
|
* @returns {string} Archives directory path
|
||||||
|
*/
|
||||||
|
export function getArchivesDir() {
|
||||||
|
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the logs directory path
|
||||||
|
* @returns {string} Logs directory path
|
||||||
|
*/
|
||||||
|
export function getLogsDir() {
|
||||||
|
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the compact flag file path
|
||||||
|
* @returns {string} Compact flag file path
|
||||||
|
*/
|
||||||
|
export function getCompactFlagPath() {
|
||||||
|
return join(getDataDir(), '.compact-running');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the claude-mem package root directory
|
||||||
|
* @returns {Promise<string>} Package root path
|
||||||
|
*/
|
||||||
|
export async function getPackageRoot() {
|
||||||
|
// Method 1: Check if we're running from development
|
||||||
|
const devPath = join(homedir(), 'Scripts', 'claude-mem-source');
|
||||||
|
const { existsSync } = await import('fs');
|
||||||
|
if (existsSync(join(devPath, 'package.json'))) {
|
||||||
|
return devPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Follow the binary symlink
|
||||||
|
try {
|
||||||
|
const { execSync } = await import('child_process');
|
||||||
|
const { realpathSync } = await import('fs');
|
||||||
|
const binPath = execSync('which claude-mem', { encoding: 'utf8' }).trim();
|
||||||
|
const realBinPath = realpathSync(binPath);
|
||||||
|
// Binary is typically at package_root/dist/claude-mem.min.js
|
||||||
|
return join(realBinPath, '../..');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
throw new Error('Cannot locate claude-mem package root');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the project root directory
|
||||||
|
* Uses CLAUDE_PROJECT_DIR environment variable if available, otherwise falls back to cwd
|
||||||
|
* @returns {string} Project root path
|
||||||
|
*/
|
||||||
|
export function getProjectRoot() {
|
||||||
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives project name from CLAUDE_PROJECT_DIR or current working directory
|
||||||
|
* Priority: CLAUDE_PROJECT_DIR > cwd parameter > process.cwd()
|
||||||
|
* @param {string} [cwd] - Optional current working directory from hook payload
|
||||||
|
* @returns {string} Project name (basename of project directory)
|
||||||
|
*/
|
||||||
|
export function getProjectName(cwd) {
|
||||||
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || cwd || process.cwd();
|
||||||
|
return basename(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all common paths used by hooks
|
||||||
|
* @returns {Object} Object containing all common paths
|
||||||
|
*/
|
||||||
|
export function getPaths() {
|
||||||
|
return {
|
||||||
|
dataDir: getDataDir(),
|
||||||
|
settingsPath: getSettingsPath(),
|
||||||
|
archivesDir: getArchivesDir(),
|
||||||
|
logsDir: getLogsDir(),
|
||||||
|
compactFlagPath: getCompactFlagPath()
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Hook - Simple Orchestrator
|
||||||
|
*
|
||||||
|
* Signals session end to SDK, which generates and stores the overview via CLI.
|
||||||
|
* Cleans up SDK transcript from UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 = {}) {
|
||||||
|
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(HOOKS_LOG, logLine);
|
||||||
|
process.stderr.write(logLine);
|
||||||
|
} catch (error) {
|
||||||
|
// Silent fail on log errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||||
|
|
||||||
|
process.stdin.on('end', async () => {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = input ? JSON.parse(input) : {};
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('Stop: JSON parse error', { error: error.message });
|
||||||
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cwd } = payload;
|
||||||
|
const project = cwd ? getProjectName(cwd) : 'unknown';
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
debugLog('Stop: No streaming session found', { project });
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||||
|
const sdkSessionId = sessionData.sdkSessionId;
|
||||||
|
const claudeSessionId = sessionData.claudeSessionId;
|
||||||
|
|
||||||
|
debugLog('Stop: Ending SDK session', { sdkSessionId, claudeSessionId });
|
||||||
|
|
||||||
|
// Build end message - SDK will call `claude-mem store-overview` and `chroma_add_documents`
|
||||||
|
const message = renderEndMessage({
|
||||||
|
project,
|
||||||
|
sessionId: claudeSessionId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send end message and wait for SDK to complete
|
||||||
|
const response = query({
|
||||||
|
prompt: message,
|
||||||
|
options: {
|
||||||
|
model: HOOK_CONFIG.sdk.model,
|
||||||
|
resume: sdkSessionId,
|
||||||
|
allowedTools: HOOK_CONFIG.sdk.allowedTools,
|
||||||
|
maxTokens: HOOK_CONFIG.sdk.maxTokensEnd,
|
||||||
|
cwd // Must match where transcript was created
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consume the response stream (wait for SDK to finish storing via CLI)
|
||||||
|
for await (const msg of response) {
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'tool_use') {
|
||||||
|
debugLog('Stop: SDK tool call', { tool: block.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('Stop: SDK session ended', { sdkSessionId });
|
||||||
|
|
||||||
|
// Delete SDK memories transcript from Claude Code UI
|
||||||
|
const sanitizedCwd = cwd.replace(/\//g, '-');
|
||||||
|
const projectsDir = path.join(process.env.HOME, '.claude', 'projects', sanitizedCwd);
|
||||||
|
const memoriesTranscriptPath = path.join(projectsDir, `${sdkSessionId}.jsonl`);
|
||||||
|
|
||||||
|
if (fs.existsSync(memoriesTranscriptPath)) {
|
||||||
|
fs.unlinkSync(memoriesTranscriptPath);
|
||||||
|
debugLog('Stop: Cleaned up memories transcript', { memoriesTranscriptPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up session file
|
||||||
|
fs.unlinkSync(sessionFile);
|
||||||
|
debugLog('Stop: Session ended and cleaned up', { project });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('Stop: Error ending session', { error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit cleanly after async processing completes
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
Executable
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Prompt Submit Hook - Streaming SDK Version
|
||||||
|
*
|
||||||
|
* Starts a streaming SDK session that will process tool responses in real-time.
|
||||||
|
* Saves the SDK session ID for post-tool-use and stop hooks to resume.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 = {}) {
|
||||||
|
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(HOOKS_LOG, logLine);
|
||||||
|
process.stderr.write(logLine);
|
||||||
|
} catch (error) {
|
||||||
|
// Silent fail on log errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed: buildStreamingSystemPrompt function
|
||||||
|
// Now using centralized config from hook-prompt-renderer.js
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||||
|
|
||||||
|
process.stdin.on('end', async () => {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = input ? JSON.parse(input) : {};
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('UserPromptSubmit: JSON parse error', { error: error.message });
|
||||||
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prompt, cwd, session_id, timestamp } = payload;
|
||||||
|
const project = cwd ? getProjectName(cwd) : 'unknown';
|
||||||
|
const date = timestamp ? timestamp.split('T')[0] : new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
debugLog('UserPromptSubmit: Starting streaming session', { project, session_id });
|
||||||
|
|
||||||
|
// Generate title and subtitle non-blocking
|
||||||
|
if (prompt && session_id && project) {
|
||||||
|
import('child_process').then(({ spawn }) => {
|
||||||
|
const titleProcess = spawn('claude-mem', [
|
||||||
|
'generate-title',
|
||||||
|
'--save',
|
||||||
|
'--project', project,
|
||||||
|
'--session', session_id,
|
||||||
|
prompt
|
||||||
|
], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
detached: true
|
||||||
|
});
|
||||||
|
titleProcess.unref();
|
||||||
|
}).catch(error => {
|
||||||
|
debugLog('UserPromptSubmit: Error spawning title generator', { error: error.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build system prompt using centralized config
|
||||||
|
const systemPrompt = renderSystemPrompt({
|
||||||
|
project,
|
||||||
|
sessionId: session_id,
|
||||||
|
date,
|
||||||
|
userPrompt: prompt || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start SDK session using centralized config
|
||||||
|
const response = query({
|
||||||
|
prompt: systemPrompt,
|
||||||
|
options: {
|
||||||
|
model: HOOK_CONFIG.sdk.model,
|
||||||
|
allowedTools: HOOK_CONFIG.sdk.allowedTools,
|
||||||
|
maxTokens: HOOK_CONFIG.sdk.maxTokensSystem,
|
||||||
|
cwd // SDK will save transcript in this directory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for session ID from init message and consume entire stream
|
||||||
|
let sdkSessionId = null;
|
||||||
|
for await (const message of response) {
|
||||||
|
if (message.type === 'system' && message.subtype === 'init') {
|
||||||
|
sdkSessionId = message.session_id;
|
||||||
|
debugLog('UserPromptSubmit: Got SDK session ID', { sdkSessionId });
|
||||||
|
}
|
||||||
|
// Don't break - consume entire stream so transcript gets written
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
debugLog('UserPromptSubmit: SDK session started', { sdkSessionId, sessionFile });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLog('UserPromptSubmit: Error starting SDK session', { error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success to Claude Code
|
||||||
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-Compact Hook for Claude Memory System
|
|
||||||
*
|
|
||||||
* Updated to use the centralized PromptOrchestrator and HookTemplates system.
|
|
||||||
* This hook validates the pre-compact request and executes compression using
|
|
||||||
* standardized response templates for consistent Claude Code integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { loadCliCommand } from './shared/config-loader.js';
|
|
||||||
import { getLogsDir } from './shared/path-resolver.js';
|
|
||||||
import {
|
|
||||||
createHookResponse,
|
|
||||||
executeCliCommand,
|
|
||||||
validateHookPayload,
|
|
||||||
debugLog
|
|
||||||
} from './shared/hook-helpers.js';
|
|
||||||
|
|
||||||
|
|
||||||
// Set up stdin immediately
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
|
|
||||||
|
|
||||||
|
|
||||||
// Read input from stdin
|
|
||||||
let input = '';
|
|
||||||
process.stdin.on('data', chunk => {
|
|
||||||
input += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
process.stdin.on('end', async () => {
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load CLI command inside try-catch to handle config errors properly
|
|
||||||
const cliCommand = loadCliCommand();
|
|
||||||
|
|
||||||
const payload = JSON.parse(input);
|
|
||||||
debugLog('Pre-compact hook started', { payload });
|
|
||||||
|
|
||||||
// Validate payload using centralized validation
|
|
||||||
const validation = validateHookPayload(payload, 'PreCompact');
|
|
||||||
if (!validation.valid) {
|
|
||||||
const response = createHookResponse('PreCompact', false, { reason: validation.error });
|
|
||||||
debugLog('Validation failed', { response });
|
|
||||||
// Exit silently - validation failure is expected flow control
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for environment-based blocking conditions
|
|
||||||
if (payload.trigger === 'auto' && process.env.DISABLE_AUTO_COMPRESSION === 'true') {
|
|
||||||
const response = createHookResponse('PreCompact', false, {
|
|
||||||
reason: 'Auto-compression disabled by configuration'
|
|
||||||
});
|
|
||||||
debugLog('Auto-compression disabled', { response });
|
|
||||||
// Exit silently - disabled compression is expected flow control
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute compression using standardized CLI execution helper
|
|
||||||
debugLog('Executing compression command', {
|
|
||||||
command: cliCommand,
|
|
||||||
args: ['compress', payload.transcript_path]
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await executeCliCommand(cliCommand, ['compress', payload.transcript_path]);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
const response = createHookResponse('PreCompact', false, {
|
|
||||||
reason: `Compression failed: ${result.stderr || 'Unknown error'}`
|
|
||||||
});
|
|
||||||
debugLog('Compression command failed', { stderr: result.stderr, response });
|
|
||||||
console.log(`claude-mem error: compression failed, see logs at ${getLogsDir()}`);
|
|
||||||
process.exit(1); // Exit with error code for actual compression failure
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - exit silently (suppressOutput is true)
|
|
||||||
debugLog('Compression completed successfully');
|
|
||||||
process.exit(0);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const response = createHookResponse('PreCompact', false, {
|
|
||||||
reason: `Hook execution error: ${error.message}`
|
|
||||||
});
|
|
||||||
debugLog('Pre-compact hook error', { error: error.message, response });
|
|
||||||
console.log(`claude-mem error: hook failed, see logs at ${getLogsDir()}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session End Hook - Handles session end events including /clear
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { loadCliCommand } from './shared/config-loader.js';
|
|
||||||
import { getSettingsPath, getArchivesDir } from './shared/path-resolver.js';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { existsSync, readFileSync } from 'fs';
|
|
||||||
|
|
||||||
const cliCommand = loadCliCommand();
|
|
||||||
|
|
||||||
// Check if save-on-clear is enabled
|
|
||||||
function isSaveOnClearEnabled() {
|
|
||||||
const settingsPath = getSettingsPath();
|
|
||||||
if (existsSync(settingsPath)) {
|
|
||||||
try {
|
|
||||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
||||||
return settings.saveMemoriesOnClear === true;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up stdin immediately before any async operations
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
|
|
||||||
|
|
||||||
// Read input
|
|
||||||
let input = '';
|
|
||||||
process.stdin.on('data', chunk => {
|
|
||||||
input += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
process.stdin.on('end', async () => {
|
|
||||||
const data = JSON.parse(input);
|
|
||||||
|
|
||||||
// Check if this is a clear event and save-on-clear is enabled
|
|
||||||
if (data.reason === 'clear' && isSaveOnClearEnabled()) {
|
|
||||||
console.error('🧠 Saving memories before clearing context...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the CLI to compress current transcript
|
|
||||||
execSync(`${cliCommand} compress --output ${getArchivesDir()}`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: { ...process.env, CLAUDE_MEM_SILENT: 'true' }
|
|
||||||
});
|
|
||||||
|
|
||||||
console.error('✅ Memories saved successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[session-end] Failed to save memories:', error.message);
|
|
||||||
// Don't block the clear operation if memory saving fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always continue
|
|
||||||
console.log(JSON.stringify({ continue: true }));
|
|
||||||
});
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session Start Hook - Load context when Claude Code starts
|
|
||||||
*
|
|
||||||
* Updated to use the centralized PromptOrchestrator and HookTemplates system.
|
|
||||||
* This hook loads previous session context using standardized formatting and
|
|
||||||
* provides rich context messages for Claude Code integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import { loadCliCommand } from './shared/config-loader.js';
|
|
||||||
import {
|
|
||||||
createHookResponse,
|
|
||||||
formatSessionStartContext,
|
|
||||||
executeCliCommand,
|
|
||||||
parseContextData,
|
|
||||||
validateHookPayload,
|
|
||||||
debugLog
|
|
||||||
} from './shared/hook-helpers.js';
|
|
||||||
|
|
||||||
const cliCommand = loadCliCommand();
|
|
||||||
|
|
||||||
// Set up stdin immediately before any async operations
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
|
|
||||||
|
|
||||||
// Read input from stdin
|
|
||||||
let input = '';
|
|
||||||
process.stdin.on('data', chunk => {
|
|
||||||
input += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
process.stdin.on('end', async () => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(input);
|
|
||||||
debugLog('Session start hook started', { payload });
|
|
||||||
|
|
||||||
// Validate payload using centralized validation
|
|
||||||
const validation = validateHookPayload(payload, 'SessionStart');
|
|
||||||
if (!validation.valid) {
|
|
||||||
debugLog('Payload validation failed', { error: validation.error });
|
|
||||||
// For session start, continue even with invalid payload but log the error
|
|
||||||
const response = createHookResponse('SessionStart', false, {
|
|
||||||
error: `Payload validation failed: ${validation.error}`
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip load-context when source is "resume" to avoid duplicate context
|
|
||||||
if (payload.source === 'resume') {
|
|
||||||
debugLog('Skipping load-context for resume source');
|
|
||||||
// Output valid JSON response with suppressOutput for resume
|
|
||||||
const response = createHookResponse('SessionStart', true);
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract project name from current working directory
|
|
||||||
const projectName = path.basename(process.cwd());
|
|
||||||
|
|
||||||
// Load context using standardized CLI execution helper
|
|
||||||
const contextResult = await executeCliCommand(cliCommand, [
|
|
||||||
'load-context',
|
|
||||||
'--format', 'session-start',
|
|
||||||
'--project', projectName
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!contextResult.success) {
|
|
||||||
debugLog('Context loading failed', { stderr: contextResult.stderr });
|
|
||||||
// Don't fail the session start, just provide error context
|
|
||||||
const response = createHookResponse('SessionStart', false, {
|
|
||||||
error: contextResult.stderr || 'Failed to load context'
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawContext = contextResult.stdout;
|
|
||||||
debugLog('Raw context loaded', { contextLength: rawContext.length });
|
|
||||||
|
|
||||||
// Check if the output is actually an error message (starts with ❌)
|
|
||||||
if (rawContext && rawContext.trim().startsWith('❌')) {
|
|
||||||
debugLog('Detected error message in stdout', { rawContext });
|
|
||||||
// Extract the clean error message without the emoji and format
|
|
||||||
const errorMatch = rawContext.match(/❌\s*[^:]+:\s*([^\n]+)(?:\n\n💡\s*(.+))?/);
|
|
||||||
let errorMsg = 'No previous memories found';
|
|
||||||
let suggestion = '';
|
|
||||||
|
|
||||||
if (errorMatch) {
|
|
||||||
errorMsg = errorMatch[1] || errorMsg;
|
|
||||||
suggestion = errorMatch[2] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a clean response without duplicating the error formatting
|
|
||||||
const response = createHookResponse('SessionStart', false, {
|
|
||||||
error: errorMsg + (suggestion ? `. ${suggestion}` : '')
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rawContext || !rawContext.trim()) {
|
|
||||||
debugLog('No context available, creating empty response');
|
|
||||||
// No context available - use standardized empty response
|
|
||||||
const response = createHookResponse('SessionStart', true);
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse context data and format using centralized templates
|
|
||||||
const contextData = parseContextData(rawContext);
|
|
||||||
contextData.projectName = projectName;
|
|
||||||
|
|
||||||
// If we have raw context (not structured data), use it directly
|
|
||||||
let formattedContext;
|
|
||||||
if (contextData.rawContext) {
|
|
||||||
formattedContext = contextData.rawContext;
|
|
||||||
} else {
|
|
||||||
// Use standardized formatting for structured context
|
|
||||||
formattedContext = formatSessionStartContext(contextData);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog('Context formatted successfully', {
|
|
||||||
memoryCount: contextData.memoryCount,
|
|
||||||
hasStructuredData: !contextData.rawContext
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create standardized session start response using HookTemplates
|
|
||||||
const response = createHookResponse('SessionStart', true, {
|
|
||||||
context: formattedContext
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
process.exit(0);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
debugLog('Session start hook error', { error: error.message });
|
|
||||||
// Even on error, continue the session with error information
|
|
||||||
const response = createHookResponse('SessionStart', false, {
|
|
||||||
error: `Hook execution error: ${error.message}`
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify(response));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts project name from transcript path
|
|
||||||
* @param {string} transcriptPath - Path to transcript file
|
|
||||||
* @returns {string|null} Extracted project name or null
|
|
||||||
*/
|
|
||||||
function extractProjectName(transcriptPath) {
|
|
||||||
if (!transcriptPath) return null;
|
|
||||||
|
|
||||||
// Look for project pattern: /path/to/PROJECT_NAME/.claude/
|
|
||||||
// Need to get PROJECT_NAME, not the parent directory
|
|
||||||
const parts = transcriptPath.split(path.sep);
|
|
||||||
const claudeIndex = parts.indexOf('.claude');
|
|
||||||
|
|
||||||
if (claudeIndex > 0) {
|
|
||||||
// Get the directory immediately before .claude
|
|
||||||
return parts[claudeIndex - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to directory containing the transcript
|
|
||||||
const dir = path.dirname(transcriptPath);
|
|
||||||
return path.basename(dir);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path resolver utility for Claude Memory hooks
|
|
||||||
* Provides proper path handling using environment variables
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the base data directory for claude-mem
|
|
||||||
* @returns {string} Data directory path
|
|
||||||
*/
|
|
||||||
export function getDataDir() {
|
|
||||||
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the settings file path
|
|
||||||
* @returns {string} Settings file path
|
|
||||||
*/
|
|
||||||
export function getSettingsPath() {
|
|
||||||
return join(getDataDir(), 'settings.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the archives directory path
|
|
||||||
* @returns {string} Archives directory path
|
|
||||||
*/
|
|
||||||
export function getArchivesDir() {
|
|
||||||
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the logs directory path
|
|
||||||
* @returns {string} Logs directory path
|
|
||||||
*/
|
|
||||||
export function getLogsDir() {
|
|
||||||
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the compact flag file path
|
|
||||||
* @returns {string} Compact flag file path
|
|
||||||
*/
|
|
||||||
export function getCompactFlagPath() {
|
|
||||||
return join(getDataDir(), '.compact-running');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all common paths used by hooks
|
|
||||||
* @returns {Object} Object containing all common paths
|
|
||||||
*/
|
|
||||||
export function getPaths() {
|
|
||||||
return {
|
|
||||||
dataDir: getDataDir(),
|
|
||||||
settingsPath: getSettingsPath(),
|
|
||||||
archivesDir: getArchivesDir(),
|
|
||||||
logsDir: getLogsDir(),
|
|
||||||
compactFlagPath: getCompactFlagPath()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+5
-8
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mem",
|
"name": "claude-mem",
|
||||||
"version": "3.7.2",
|
"version": "3.9.9",
|
||||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
"claude-code",
|
"claude-agent-sdk",
|
||||||
"mcp",
|
"mcp",
|
||||||
"memory",
|
"memory",
|
||||||
"compression",
|
"compression",
|
||||||
@@ -36,21 +36,18 @@
|
|||||||
"claude-mem": "./dist/claude-mem.min.js"
|
"claude-mem": "./dist/claude-mem.min.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-code": "^1.0.88",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@modelcontextprotocol/sdk": "^0.5.0",
|
|
||||||
"boxen": "^8.0.1",
|
"boxen": "^8.0.1",
|
||||||
"chalk": "^5.6.0",
|
"chalk": "^5.6.0",
|
||||||
"chromadb": "^3.0.14",
|
|
||||||
"commander": "^14.0.0",
|
"commander": "^14.0.0",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"gradient-string": "^3.0.0",
|
"gradient-string": "^3.0.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8"
|
||||||
"oh-my-logo": "^0.3.2"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"hooks",
|
"hook-templates",
|
||||||
"commands",
|
"commands",
|
||||||
"src",
|
"src",
|
||||||
".mcp.json",
|
".mcp.json",
|
||||||
|
|||||||
+98
-87
@@ -7,20 +7,26 @@ import { Command } from 'commander';
|
|||||||
import { PACKAGE_NAME, PACKAGE_VERSION, PACKAGE_DESCRIPTION } from '../shared/config.js';
|
import { PACKAGE_NAME, PACKAGE_VERSION, PACKAGE_DESCRIPTION } from '../shared/config.js';
|
||||||
|
|
||||||
// Import command handlers
|
// Import command handlers
|
||||||
import { compress } from '../commands/compress.js';
|
|
||||||
import { install } from '../commands/install.js';
|
import { install } from '../commands/install.js';
|
||||||
import { uninstall } from '../commands/uninstall.js';
|
import { uninstall } from '../commands/uninstall.js';
|
||||||
import { status } from '../commands/status.js';
|
import { status } from '../commands/status.js';
|
||||||
import { logs } from '../commands/logs.js';
|
import { logs } from '../commands/logs.js';
|
||||||
import { loadContext } from '../commands/load-context.js';
|
import { loadContext } from '../commands/load-context.js';
|
||||||
import { trash } from '../commands/trash.js';
|
import { trash } from '../commands/trash.js';
|
||||||
|
import { viewTrash } from '../commands/trash-view.js';
|
||||||
|
import { emptyTrash } from '../commands/trash-empty.js';
|
||||||
import { restore } from '../commands/restore.js';
|
import { restore } from '../commands/restore.js';
|
||||||
import { save } from '../commands/save.js';
|
|
||||||
import { changelog } from '../commands/changelog.js';
|
import { changelog } from '../commands/changelog.js';
|
||||||
// Cloud functionality disabled - incomplete setup
|
import { doctor } from '../commands/doctor.js';
|
||||||
// import { cloudCommand } from '../commands/cloud.js';
|
import { storeMemory } from '../commands/store-memory.js';
|
||||||
import { importHistory } from '../commands/import-history.js';
|
import { storeOverview } from '../commands/store-overview.js';
|
||||||
import { TranscriptCompressor } from '../core/compression/TranscriptCompressor.js';
|
import { updateSessionMetadata } from '../commands/update-session-metadata.js';
|
||||||
|
import { generateTitle } from '../commands/generate-title.js';
|
||||||
|
import {
|
||||||
|
executeChromaMCPTool,
|
||||||
|
loadChromaMCPTools,
|
||||||
|
generateCommandOptions
|
||||||
|
} from '../commands/chroma-mcp.js';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
// </Block> =======================================
|
// </Block> =======================================
|
||||||
@@ -35,19 +41,6 @@ program
|
|||||||
// </Block> =======================================
|
// </Block> =======================================
|
||||||
|
|
||||||
// <Block> 1.3 ====================================
|
// <Block> 1.3 ====================================
|
||||||
// Compress Command Definition
|
|
||||||
// Natural pattern: Define command with its options and handler
|
|
||||||
// Compress command
|
|
||||||
program
|
|
||||||
.command('compress [transcript]')
|
|
||||||
.description('Compress a Claude Code transcript into memory')
|
|
||||||
.option('--output <path>', 'Output directory for compressed files')
|
|
||||||
.option('--dry-run', 'Show what would be compressed without doing it')
|
|
||||||
.option('-v, --verbose', 'Show detailed output')
|
|
||||||
.action(compress);
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 1.4 ====================================
|
|
||||||
// Install Command Definition
|
// Install Command Definition
|
||||||
// Natural pattern: Define command with its options and handler
|
// Natural pattern: Define command with its options and handler
|
||||||
// Install command
|
// Install command
|
||||||
@@ -86,6 +79,20 @@ program
|
|||||||
.command('status')
|
.command('status')
|
||||||
.description('Check installation status of Claude Memory System')
|
.description('Check installation status of Claude Memory System')
|
||||||
.action(status);
|
.action(status);
|
||||||
|
|
||||||
|
// Doctor command
|
||||||
|
program
|
||||||
|
.command('doctor')
|
||||||
|
.description('Run environment and pipeline diagnostics for rolling memory')
|
||||||
|
.option('--json', 'Output JSON instead of text')
|
||||||
|
.action(async (options: any) => {
|
||||||
|
try {
|
||||||
|
await doctor(options);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`doctor failed: ${error.message || error}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
// </Block> =======================================
|
// </Block> =======================================
|
||||||
|
|
||||||
// <Block> 1.7 ====================================
|
// <Block> 1.7 ====================================
|
||||||
@@ -148,20 +155,14 @@ const trashCmd = program
|
|||||||
trashCmd
|
trashCmd
|
||||||
.command('view')
|
.command('view')
|
||||||
.description('View contents of trash bin')
|
.description('View contents of trash bin')
|
||||||
.action(async () => {
|
.action(viewTrash);
|
||||||
const { viewTrash } = await import('../commands/trash-view.js');
|
|
||||||
await viewTrash();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trash empty subcommand
|
// Trash empty subcommand
|
||||||
trashCmd
|
trashCmd
|
||||||
.command('empty')
|
.command('empty')
|
||||||
.description('Permanently delete all files in trash')
|
.description('Permanently delete all files in trash')
|
||||||
.option('-f, --force', 'Skip confirmation prompt')
|
.option('-f, --force', 'Skip confirmation prompt')
|
||||||
.action(async (options: any) => {
|
.action(emptyTrash);
|
||||||
const { emptyTrash } = await import('../commands/trash-empty.js');
|
|
||||||
await emptyTrash(options);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore command
|
// Restore command
|
||||||
program
|
program
|
||||||
@@ -170,15 +171,39 @@ program
|
|||||||
.action(restore);
|
.action(restore);
|
||||||
// </Block> =======================================
|
// </Block> =======================================
|
||||||
|
|
||||||
// Cloud command
|
// Store memory command (for SDK streaming)
|
||||||
// Cloud functionality disabled - incomplete setup
|
|
||||||
// program.addCommand(cloudCommand);
|
|
||||||
|
|
||||||
// Save command
|
|
||||||
program
|
program
|
||||||
.command('save <message>')
|
.command('store-memory')
|
||||||
.description('Save a message to the memory system')
|
.description('Store a memory to all storage layers (used by SDK)')
|
||||||
.action(save);
|
.requiredOption('--id <id>', 'Memory ID')
|
||||||
|
.requiredOption('--project <project>', 'Project name')
|
||||||
|
.requiredOption('--session <session>', 'Session ID')
|
||||||
|
.requiredOption('--date <date>', 'Date (YYYY-MM-DD)')
|
||||||
|
.requiredOption('--title <title>', 'Memory title (3-8 words)')
|
||||||
|
.requiredOption('--subtitle <subtitle>', 'Memory subtitle (max 24 words)')
|
||||||
|
.requiredOption('--facts <json>', 'Atomic facts as JSON array')
|
||||||
|
.option('--concepts <json>', 'Concept tags as JSON array')
|
||||||
|
.option('--files <json>', 'Files touched as JSON array')
|
||||||
|
.action(storeMemory);
|
||||||
|
|
||||||
|
// Store overview command (for SDK streaming)
|
||||||
|
program
|
||||||
|
.command('store-overview')
|
||||||
|
.description('Store a session overview (used by SDK)')
|
||||||
|
.requiredOption('--project <project>', 'Project name')
|
||||||
|
.requiredOption('--session <session>', 'Session ID')
|
||||||
|
.requiredOption('--content <content>', 'Overview content')
|
||||||
|
.action(storeOverview);
|
||||||
|
|
||||||
|
// Update session metadata command (for SDK streaming)
|
||||||
|
program
|
||||||
|
.command('update-session-metadata')
|
||||||
|
.description('Update session title and subtitle (used by SDK)')
|
||||||
|
.requiredOption('--project <project>', 'Project name')
|
||||||
|
.requiredOption('--session <session>', 'Session ID')
|
||||||
|
.requiredOption('--title <title>', 'Session title (3-6 words)')
|
||||||
|
.option('--subtitle <subtitle>', 'Session subtitle (max 20 words)')
|
||||||
|
.action(updateSessionMetadata);
|
||||||
|
|
||||||
// Changelog command
|
// Changelog command
|
||||||
program
|
program
|
||||||
@@ -193,63 +218,49 @@ program
|
|||||||
.option('-v, --verbose', 'Show detailed output')
|
.option('-v, --verbose', 'Show detailed output')
|
||||||
.action(changelog);
|
.action(changelog);
|
||||||
|
|
||||||
// Import History command
|
// Generate title command
|
||||||
program
|
program
|
||||||
.command('import-history')
|
.command('generate-title <prompt>')
|
||||||
.description('Import historical Claude Code conversations into memory')
|
.description('Generate a session title and subtitle from a prompt')
|
||||||
.option('-v, --verbose', 'Show detailed output')
|
.option('--json', 'Output as JSON')
|
||||||
.option('-m, --multi', 'Enable multi-select mode (default is single-select)')
|
.option('--oneline', 'Output as single line (title - subtitle)')
|
||||||
.action(importHistory);
|
.option('--save', 'Save title and subtitle to session metadata')
|
||||||
|
.option('--project <name>', 'Project name (required with --save)')
|
||||||
// Migrate Index command
|
.option('--session <id>', 'Session ID (required with --save)')
|
||||||
program
|
.action(generateTitle);
|
||||||
.command('migrate-index')
|
|
||||||
.description('Migrate JSONL index to SQLite database')
|
|
||||||
.option('--force', 'Force migration even if SQLite database already has data')
|
|
||||||
.option('--keep-jsonl', 'Keep original JSONL file (archive it by default)')
|
|
||||||
.action(async (options) => {
|
|
||||||
const { migrateIndex } = await import('../commands/migrate-index.js');
|
|
||||||
await migrateIndex(options);
|
|
||||||
});
|
|
||||||
|
|
||||||
// <Block> 1.11 ===================================
|
|
||||||
// Hook Commands
|
|
||||||
// Internal commands called by hook scripts
|
|
||||||
program
|
|
||||||
.command('hook:pre-compact', { hidden: true })
|
|
||||||
.description('Internal pre-compact hook handler')
|
|
||||||
.action(async () => {
|
|
||||||
const { preCompactHook } = await import('../commands/hooks.js');
|
|
||||||
await preCompactHook();
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('hook:session-start', { hidden: true })
|
|
||||||
.description('Internal session-start hook handler')
|
|
||||||
.action(async () => {
|
|
||||||
const { sessionStartHook } = await import('../commands/hooks.js');
|
|
||||||
await sessionStartHook();
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('hook:session-end', { hidden: true })
|
|
||||||
.description('Internal session-end hook handler')
|
|
||||||
.action(async () => {
|
|
||||||
const { sessionEndHook } = await import('../commands/hooks.js');
|
|
||||||
await sessionEndHook();
|
|
||||||
});
|
|
||||||
|
|
||||||
// </Block> =======================================
|
// </Block> =======================================
|
||||||
|
|
||||||
// Debug command to show filtered output
|
// <Block> 1.12 ===================================
|
||||||
program
|
// Dynamic Chroma MCP Commands
|
||||||
.command('debug-filter')
|
// Natural pattern: Register all Chroma MCP tools as CLI commands
|
||||||
.description('Show filtered transcript output (first 5 messages)')
|
try {
|
||||||
.argument('<transcript-path>', 'Path to transcript file')
|
const chromaTools = loadChromaMCPTools();
|
||||||
.action((transcriptPath) => {
|
|
||||||
const compressor = new TranscriptCompressor();
|
for (const tool of chromaTools) {
|
||||||
compressor.showFilteredOutput(transcriptPath);
|
const cmd = program
|
||||||
|
.command(tool.name)
|
||||||
|
.description(tool.description || `Execute ${tool.name} MCP tool`);
|
||||||
|
|
||||||
|
// Add options from tool schema
|
||||||
|
const options = generateCommandOptions(tool.inputSchema);
|
||||||
|
for (const opt of options) {
|
||||||
|
if (opt.required) {
|
||||||
|
cmd.requiredOption(opt.flag, opt.description);
|
||||||
|
} else {
|
||||||
|
cmd.option(opt.flag, opt.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set action handler
|
||||||
|
cmd.action(async (options: OptionValues) => {
|
||||||
|
await executeChromaMCPTool(tool.name, options);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Warning: Could not load Chroma MCP tools:', error);
|
||||||
|
}
|
||||||
|
// </Block> =======================================
|
||||||
|
|
||||||
// <Block> 1.11 ===================================
|
// <Block> 1.11 ===================================
|
||||||
// CLI Execution
|
// CLI Execution
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OptionValues } from 'commander';
|
import { OptionValues } from 'commander';
|
||||||
import { query } from '@anthropic-ai/claude-code';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getClaudePath } from '../shared/settings.js';
|
import { getClaudePath } from '../shared/settings.js';
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { OptionValues } from 'commander';
|
||||||
|
import ChromaMCPClient from '../../chroma-mcp-tools/chroma-mcp-client.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic Chroma MCP tool executor
|
||||||
|
* Dynamically calls any Chroma MCP tool with provided arguments
|
||||||
|
*/
|
||||||
|
export async function executeChromaMCPTool(toolName: string, options: OptionValues): Promise<void> {
|
||||||
|
const client = new ChromaMCPClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Convert commander options to tool arguments
|
||||||
|
const toolArgs = convertOptionsToArgs(toolName, options);
|
||||||
|
|
||||||
|
// Call the MCP tool
|
||||||
|
const result = await client.callTool(toolName, toolArgs);
|
||||||
|
|
||||||
|
// Parse and format the result nicely
|
||||||
|
const formatted = formatMCPResult(result);
|
||||||
|
console.log(formatted);
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Unknown error calling MCP tool',
|
||||||
|
tool: toolName
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
await client.disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format MCP tool result for clean CLI output
|
||||||
|
*/
|
||||||
|
function formatMCPResult(result: any): string {
|
||||||
|
// If result has content array (MCP protocol format)
|
||||||
|
if (result?.content && Array.isArray(result.content)) {
|
||||||
|
const textContent = result.content
|
||||||
|
.filter((item: any) => item.type === 'text')
|
||||||
|
.map((item: any) => item.text)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
// Try to parse as JSON for prettier output
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(textContent);
|
||||||
|
return JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
// Not JSON, return as-is
|
||||||
|
return textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If result is already an object, pretty print it
|
||||||
|
if (typeof result === 'object') {
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to string
|
||||||
|
return String(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert CLI options to MCP tool arguments
|
||||||
|
* Handles type conversion and array parsing
|
||||||
|
*/
|
||||||
|
function convertOptionsToArgs(toolName: string, options: OptionValues): Record<string, any> {
|
||||||
|
const args: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(options)) {
|
||||||
|
// Skip commander internal properties
|
||||||
|
if (key.startsWith('_') || typeof value === 'function') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse JSON strings
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
args[key] = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
args[key] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load Chroma MCP tool definitions from JSON
|
||||||
|
*/
|
||||||
|
export function loadChromaMCPTools(): Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: any;
|
||||||
|
}> {
|
||||||
|
// Try multiple path resolutions for dev vs production
|
||||||
|
const possiblePaths = [
|
||||||
|
path.join(__dirname, '../../chroma-mcp-tools/CHROMA_MCP_TOOLS.json'),
|
||||||
|
path.join(process.cwd(), 'chroma-mcp-tools/CHROMA_MCP_TOOLS.json'),
|
||||||
|
path.join(__dirname, '../chroma-mcp-tools/CHROMA_MCP_TOOLS.json')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const toolsPath of possiblePaths) {
|
||||||
|
if (fs.existsSync(toolsPath)) {
|
||||||
|
const toolsJson = fs.readFileSync(toolsPath, 'utf-8');
|
||||||
|
return JSON.parse(toolsJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not find CHROMA_MCP_TOOLS.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CLI command options from MCP tool schema
|
||||||
|
*/
|
||||||
|
export function generateCommandOptions(schema: any): Array<{
|
||||||
|
flag: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
type: string;
|
||||||
|
}> {
|
||||||
|
const options: Array<{
|
||||||
|
flag: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
type: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (!schema.properties) return options;
|
||||||
|
|
||||||
|
const required = schema.required || [];
|
||||||
|
|
||||||
|
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||||
|
const prop = propSchema as any;
|
||||||
|
const isRequired = required.includes(propName);
|
||||||
|
|
||||||
|
// Determine type
|
||||||
|
let type = 'string';
|
||||||
|
if (prop.type === 'integer' || prop.type === 'number') {
|
||||||
|
type = 'number';
|
||||||
|
} else if (prop.type === 'array') {
|
||||||
|
type = 'array';
|
||||||
|
} else if (prop.type === 'object') {
|
||||||
|
type = 'json';
|
||||||
|
} else if (prop.anyOf) {
|
||||||
|
// Handle nullable types
|
||||||
|
const nonNullType = prop.anyOf.find((t: any) => t.type !== 'null');
|
||||||
|
if (nonNullType?.type === 'integer' || nonNullType?.type === 'number') {
|
||||||
|
type = 'number';
|
||||||
|
} else if (nonNullType?.type === 'array') {
|
||||||
|
type = 'array';
|
||||||
|
} else if (nonNullType?.type === 'object') {
|
||||||
|
type = 'json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build flag
|
||||||
|
const flag = isRequired
|
||||||
|
? `--${propName} <${type}>`
|
||||||
|
: `--${propName} [${type}]`;
|
||||||
|
|
||||||
|
// Build description
|
||||||
|
let description = prop.title || propName;
|
||||||
|
if (prop.default !== undefined) {
|
||||||
|
description += ` (default: ${JSON.stringify(prop.default)})`;
|
||||||
|
}
|
||||||
|
if (type === 'array') {
|
||||||
|
description += ' (JSON array)';
|
||||||
|
} else if (type === 'json') {
|
||||||
|
description += ' (JSON object)';
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
flag,
|
||||||
|
description,
|
||||||
|
required: isRequired,
|
||||||
|
type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { OptionValues } from 'commander';
|
|
||||||
import { basename, dirname } from 'path';
|
|
||||||
import {
|
|
||||||
createLoadingMessage,
|
|
||||||
createCompletionMessage,
|
|
||||||
createOperationSummary,
|
|
||||||
createUserFriendlyError
|
|
||||||
} from '../prompts/templates/context/ContextTemplates.js';
|
|
||||||
|
|
||||||
export async function compress(transcript?: string, options: OptionValues = {}): Promise<void> {
|
|
||||||
console.log(createLoadingMessage('compressing'));
|
|
||||||
|
|
||||||
if (!transcript) {
|
|
||||||
console.log(createUserFriendlyError('Compression', 'No transcript file provided', 'Please provide a path to a transcript file'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Import and run compression
|
|
||||||
const { TranscriptCompressor } = await import('../core/compression/TranscriptCompressor.js');
|
|
||||||
const compressor = new TranscriptCompressor({
|
|
||||||
verbose: options.verbose || false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionId = options.sessionId || basename(transcript, '.jsonl');
|
|
||||||
const archivePath = await compressor.compress(transcript, sessionId);
|
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
|
|
||||||
console.log(createCompletionMessage('Compression', undefined, `Session archived as ${basename(archivePath)}`));
|
|
||||||
console.log(createOperationSummary('compress', { count: 1, duration, details: `Session: ${sessionId}` }));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
console.log(createUserFriendlyError(
|
|
||||||
'Compression',
|
|
||||||
errorMessage,
|
|
||||||
'Check that the transcript file exists and you have write permissions'
|
|
||||||
));
|
|
||||||
throw error; // Re-throw to maintain existing error handling behavior
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { OptionValues } from 'commander';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathDiscovery } from '../services/path-discovery.js';
|
||||||
|
import { createStores } from '../services/sqlite/index.js';
|
||||||
|
import { rollingLog } from '../shared/rolling-log.js';
|
||||||
|
|
||||||
|
type CheckStatus = 'pass' | 'fail' | 'warn';
|
||||||
|
|
||||||
|
interface CheckResult {
|
||||||
|
name: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printCheck(result: CheckResult): void {
|
||||||
|
const icon =
|
||||||
|
result.status === 'pass' ? '✅' : result.status === 'warn' ? '⚠️ ' : '❌';
|
||||||
|
const message = result.details ? `${result.name}: ${result.details}` : result.name;
|
||||||
|
console.log(`${icon} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doctor(options: OptionValues = {}): Promise<void> {
|
||||||
|
const discovery = PathDiscovery.getInstance();
|
||||||
|
const checks: CheckResult[] = [];
|
||||||
|
|
||||||
|
// Data directory
|
||||||
|
try {
|
||||||
|
const dataDir = discovery.getDataDirectory();
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
checks.push({ name: `Data directory created at ${dataDir}`, status: 'warn' });
|
||||||
|
} else {
|
||||||
|
const stats = fs.statSync(dataDir);
|
||||||
|
let writable = false;
|
||||||
|
try {
|
||||||
|
fs.accessSync(dataDir, fs.constants.W_OK);
|
||||||
|
writable = true;
|
||||||
|
} catch {}
|
||||||
|
checks.push({
|
||||||
|
name: `Data directory ${dataDir}`,
|
||||||
|
status: stats.isDirectory() && writable ? 'pass' : 'fail',
|
||||||
|
details: stats.isDirectory() && writable ? 'accessible' : 'not writable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
checks.push({
|
||||||
|
name: 'Data directory',
|
||||||
|
status: 'fail',
|
||||||
|
details: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite connectivity
|
||||||
|
let stores; // reuse for queue check
|
||||||
|
try {
|
||||||
|
stores = await createStores();
|
||||||
|
const sessionCount = stores.sessions.count();
|
||||||
|
checks.push({
|
||||||
|
name: 'SQLite database',
|
||||||
|
status: 'pass',
|
||||||
|
details: `${sessionCount} session${sessionCount === 1 ? '' : 's'} present`
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
checks.push({
|
||||||
|
name: 'SQLite database',
|
||||||
|
status: 'fail',
|
||||||
|
details: error?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chroma connectivity
|
||||||
|
try {
|
||||||
|
const chromaDir = discovery.getChromaDirectory();
|
||||||
|
const chromaExists = fs.existsSync(chromaDir);
|
||||||
|
checks.push({
|
||||||
|
name: 'Chroma vector store',
|
||||||
|
status: chromaExists ? 'pass' : 'warn',
|
||||||
|
details: chromaExists ? `data dir ${path.resolve(chromaDir)}` : 'Not yet initialized'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
checks.push({
|
||||||
|
name: 'Chroma vector store',
|
||||||
|
status: 'warn',
|
||||||
|
details: error?.message || 'Unable to check Chroma directory'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify({ checks }, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log('claude-mem doctor');
|
||||||
|
console.log('=================');
|
||||||
|
checks.forEach(printCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
rollingLog('info', 'doctor run completed', {
|
||||||
|
status: checks.map((c) => c.status)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a session title and subtitle from a user prompt
|
||||||
|
* CLI command that uses Agent SDK (like changelog.ts)
|
||||||
|
*/
|
||||||
|
export async function generateTitle(prompt: string, options: OptionValues): Promise<void> {
|
||||||
|
if (!prompt || prompt.trim().length === 0) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Prompt is required'
|
||||||
|
}));
|
||||||
|
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:
|
||||||
|
1. A concise title (3-8 words)
|
||||||
|
2. A one-sentence subtitle (max 20 words)
|
||||||
|
|
||||||
|
TITLE GUIDELINES:
|
||||||
|
- 3-8 words maximum
|
||||||
|
- Scannable and clear
|
||||||
|
- Captures the core action or topic
|
||||||
|
- Professional and informative
|
||||||
|
- Examples:
|
||||||
|
* "Dark Mode Implementation"
|
||||||
|
* "Authentication Bug Fix"
|
||||||
|
* "API Rate Limiting Setup"
|
||||||
|
* "React Component Refactoring"
|
||||||
|
|
||||||
|
SUBTITLE GUIDELINES:
|
||||||
|
- One sentence, max 20 words
|
||||||
|
- Descriptive and specific
|
||||||
|
- Focus on the outcome or benefit
|
||||||
|
- Use active voice when possible
|
||||||
|
- Examples:
|
||||||
|
* "Adding theme toggle and dark color scheme support to the application"
|
||||||
|
* "Resolving login timeout issue affecting user session persistence"
|
||||||
|
* "Implementing request throttling to prevent API quota exhaustion"
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
You must output EXACTLY two lines:
|
||||||
|
Line 1: Title only (no prefix, no quotes)
|
||||||
|
Line 2: Subtitle only (no prefix, no quotes)
|
||||||
|
|
||||||
|
EXAMPLE:
|
||||||
|
|
||||||
|
User request: "Help me add dark mode to my app"
|
||||||
|
|
||||||
|
Output:
|
||||||
|
Dark Mode Implementation
|
||||||
|
Adding theme toggle and dark color scheme support to the application
|
||||||
|
|
||||||
|
USER REQUEST:
|
||||||
|
${prompt}
|
||||||
|
|
||||||
|
Now generate the title and subtitle (two lines exactly):`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await query({
|
||||||
|
prompt: systemPrompt,
|
||||||
|
options: {
|
||||||
|
allowedTools: [],
|
||||||
|
pathToClaudeCodeExecutable: getClaudePath()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract text from response (same pattern as changelog.ts)
|
||||||
|
let fullResponse = '';
|
||||||
|
if (response && typeof response === 'object' && Symbol.asyncIterator in response) {
|
||||||
|
for await (const message of response) {
|
||||||
|
if (message?.type === 'assistant' && message?.message?.content) {
|
||||||
|
const content = message.message.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
fullResponse += content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
fullResponse += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response - expecting exactly 2 lines
|
||||||
|
const lines = fullResponse.trim().split('\n').filter(line => line.trim().length > 0);
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Could not generate title and subtitle',
|
||||||
|
response: fullResponse
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: `Failed to save metadata: ${error.message}`
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output format depends on options
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
title,
|
||||||
|
subtitle
|
||||||
|
}, null, 2));
|
||||||
|
} else if (options.oneline) {
|
||||||
|
console.log(`${title} - ${subtitle}`);
|
||||||
|
} else {
|
||||||
|
console.log(title);
|
||||||
|
console.log(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Unknown error generating title'
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook command handlers for binary distribution
|
|
||||||
* These execute the actual hook logic embedded in the binary
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { basename, sep } from 'path';
|
|
||||||
import { compress } from './compress.js';
|
|
||||||
import { loadContext } from './load-context.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-compact hook handler
|
|
||||||
* Runs compression on the Claude Code transcript
|
|
||||||
*/
|
|
||||||
export async function preCompactHook(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Read hook data from stdin (Claude Code sends JSON)
|
|
||||||
let inputData = '';
|
|
||||||
|
|
||||||
// Set up stdin to read data
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
|
|
||||||
// Collect all input data
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
inputData += chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the JSON input
|
|
||||||
let transcriptPath: string | undefined;
|
|
||||||
|
|
||||||
if (inputData) {
|
|
||||||
try {
|
|
||||||
const hookData = JSON.parse(inputData);
|
|
||||||
transcriptPath = hookData.transcript_path;
|
|
||||||
} catch (parseError) {
|
|
||||||
// If JSON parsing fails, treat the input as a direct path
|
|
||||||
transcriptPath = inputData.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to environment variable or command line argument
|
|
||||||
if (!transcriptPath) {
|
|
||||||
transcriptPath = process.env.TRANSCRIPT_PATH || process.argv[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!transcriptPath) {
|
|
||||||
console.log('🗜️ Compressing session transcript...');
|
|
||||||
console.log('❌ No transcript path provided to pre-compact hook');
|
|
||||||
console.log('Hook data received:', inputData || 'none');
|
|
||||||
console.log('Environment TRANSCRIPT_PATH:', process.env.TRANSCRIPT_PATH || 'not set');
|
|
||||||
console.log('Command line args:', process.argv.slice(2));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run compression with the transcript path
|
|
||||||
await compress(transcriptPath, { dryRun: false });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Pre-compact hook failed:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session-start hook handler
|
|
||||||
* Loads context for the new session
|
|
||||||
*/
|
|
||||||
export async function sessionStartHook(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Read hook data from stdin (Claude Code sends JSON)
|
|
||||||
let inputData = '';
|
|
||||||
|
|
||||||
// Set up stdin to read data
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
|
|
||||||
// Collect all input data
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
inputData += chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the JSON input to get the current working directory
|
|
||||||
let project: string | undefined;
|
|
||||||
|
|
||||||
if (inputData) {
|
|
||||||
try {
|
|
||||||
const hookData = JSON.parse(inputData);
|
|
||||||
// Extract project name from cwd if provided
|
|
||||||
if (hookData.cwd) {
|
|
||||||
project = basename(hookData.cwd);
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// If JSON parsing fails, continue without project filtering
|
|
||||||
console.error('Failed to parse session-start hook data:', parseError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no project from hook data, try to get from current working directory
|
|
||||||
if (!project) {
|
|
||||||
project = basename(process.cwd());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load context with session-start format and project filtering
|
|
||||||
await loadContext({ format: 'session-start', count: '10', project });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Session-start hook failed:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session-end hook handler
|
|
||||||
* Compresses session transcript when ending with /clear
|
|
||||||
*/
|
|
||||||
export async function sessionEndHook(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Read hook data from stdin (Claude Code sends JSON)
|
|
||||||
let inputData = '';
|
|
||||||
|
|
||||||
// Set up stdin to read data
|
|
||||||
process.stdin.setEncoding('utf8');
|
|
||||||
|
|
||||||
// Collect all input data
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
inputData += chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the JSON input to check the reason for session end
|
|
||||||
if (inputData) {
|
|
||||||
try {
|
|
||||||
const hookData = JSON.parse(inputData);
|
|
||||||
|
|
||||||
// If reason is "clear", compress the session transcript before it's deleted
|
|
||||||
if (hookData.reason === 'clear' && hookData.transcript_path) {
|
|
||||||
console.log('🗜️ Compressing current session before /clear...');
|
|
||||||
await compress(hookData.transcript_path, { dryRun: false });
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// If JSON parsing fails, log but don't fail the hook
|
|
||||||
console.error('Failed to parse hook data:', parseError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Session ended successfully');
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Session-end hook failed:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import * as p from '@clack/prompts';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import { TranscriptCompressor } from '../core/compression/TranscriptCompressor.js';
|
|
||||||
import { TitleGenerator, TitleGenerationRequest } from '../core/titles/TitleGenerator.js';
|
|
||||||
import { getStorageProvider, needsMigration } from '../shared/storage.js';
|
|
||||||
|
|
||||||
interface ConversationMetadata {
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: string;
|
|
||||||
messageCount: number;
|
|
||||||
branch?: string;
|
|
||||||
cwd: string;
|
|
||||||
fileSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConversationItem extends ConversationMetadata {
|
|
||||||
filePath: string;
|
|
||||||
projectName: string;
|
|
||||||
parsedDate: Date;
|
|
||||||
relativeDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes}B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeDate(date: Date): string {
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'just now';
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
|
||||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
|
||||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
||||||
return `${Math.floor(diffDays / 365)}y ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTimestamp(timestamp: string, fallbackPath: string): Date {
|
|
||||||
try {
|
|
||||||
const parsed = new Date(timestamp);
|
|
||||||
if (!isNaN(parsed.getTime())) return parsed;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Fallback: try to extract from filename
|
|
||||||
const match = fallbackPath.match(/(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})/);
|
|
||||||
if (match) {
|
|
||||||
const [_, year, month, day, hour, minute, second] = match;
|
|
||||||
return new Date(
|
|
||||||
parseInt(year),
|
|
||||||
parseInt(month) - 1,
|
|
||||||
parseInt(day),
|
|
||||||
parseInt(hour),
|
|
||||||
parseInt(minute),
|
|
||||||
parseInt(second)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: file stats
|
|
||||||
const stats = fs.statSync(fallbackPath);
|
|
||||||
return stats.mtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFirstUserMessage(filePath: string): string {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.trim().split('\n').filter(Boolean);
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(line);
|
|
||||||
if (message.type === 'user' && message.message?.content) {
|
|
||||||
const messageContent = message.message.content;
|
|
||||||
if (Array.isArray(messageContent)) {
|
|
||||||
const textContent = messageContent
|
|
||||||
.filter(item => item.type === 'text')
|
|
||||||
.map(item => item.text)
|
|
||||||
.join(' ');
|
|
||||||
if (textContent.trim()) return textContent.trim();
|
|
||||||
} else if (typeof messageContent === 'string') {
|
|
||||||
return messageContent.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Conversation'; // Fallback
|
|
||||||
} catch {
|
|
||||||
return 'Conversation'; // Fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadImportedSessions(): Promise<Set<string>> {
|
|
||||||
try {
|
|
||||||
// Check if migration is needed and warn the user
|
|
||||||
if (await needsMigration()) {
|
|
||||||
console.warn('⚠️ JSONL to SQLite migration recommended. Run: claude-mem migrate-index');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storage = await getStorageProvider();
|
|
||||||
|
|
||||||
// Use storage provider to get all session IDs efficiently
|
|
||||||
return await storage.getAllSessionIds();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to load imported sessions, proceeding with empty set:', error);
|
|
||||||
return new Set<string>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scanConversations(): Promise<{ conversations: ConversationItem[]; skippedCount: number }> {
|
|
||||||
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
||||||
|
|
||||||
if (!fs.existsSync(claudeDir)) {
|
|
||||||
return { conversations: [], skippedCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects = fs.readdirSync(claudeDir)
|
|
||||||
.filter(dir => fs.statSync(path.join(claudeDir, dir)).isDirectory());
|
|
||||||
|
|
||||||
const conversations: ConversationItem[] = [];
|
|
||||||
const importedSessionIds = await loadImportedSessions();
|
|
||||||
let skippedCount = 0;
|
|
||||||
|
|
||||||
for (const project of projects) {
|
|
||||||
const projectDir = path.join(claudeDir, project);
|
|
||||||
const files = fs.readdirSync(projectDir)
|
|
||||||
.filter(file => file.endsWith('.jsonl'))
|
|
||||||
.map(file => path.join(projectDir, file));
|
|
||||||
|
|
||||||
for (const filePath of files) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.trim().split('\n').filter(Boolean);
|
|
||||||
|
|
||||||
// Parse first line for metadata
|
|
||||||
const firstLine = JSON.parse(lines[0]);
|
|
||||||
const messageCount = lines.length;
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
const fileSize = stats.size;
|
|
||||||
|
|
||||||
const metadata: ConversationMetadata = {
|
|
||||||
sessionId: firstLine.sessionId || path.basename(filePath, '.jsonl'),
|
|
||||||
timestamp: firstLine.timestamp || stats.mtime.toISOString(),
|
|
||||||
messageCount,
|
|
||||||
branch: firstLine.branch,
|
|
||||||
cwd: firstLine.cwd || projectDir,
|
|
||||||
fileSize
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip if already imported
|
|
||||||
if (importedSessionIds.has(metadata.sessionId)) {
|
|
||||||
skippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectName = path.basename(path.dirname(filePath));
|
|
||||||
const parsedDate = parseTimestamp(metadata.timestamp, filePath);
|
|
||||||
const relativeDate = formatRelativeDate(parsedDate);
|
|
||||||
|
|
||||||
conversations.push({
|
|
||||||
filePath,
|
|
||||||
...metadata,
|
|
||||||
projectName,
|
|
||||||
parsedDate,
|
|
||||||
relativeDate
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { conversations, skippedCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function importHistory(options: { verbose?: boolean; multi?: boolean } = {}) {
|
|
||||||
console.clear();
|
|
||||||
|
|
||||||
p.intro(chalk.bgCyan.black(' CLAUDE-MEM IMPORT '));
|
|
||||||
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Scanning conversation history');
|
|
||||||
|
|
||||||
const { conversations, skippedCount } = await scanConversations();
|
|
||||||
|
|
||||||
if (conversations.length === 0) {
|
|
||||||
s.stop('No new conversations found');
|
|
||||||
const message = skippedCount > 0
|
|
||||||
? `All ${skippedCount} conversation${skippedCount === 1 ? ' is' : 's are'} already imported.`
|
|
||||||
: 'No conversations found.';
|
|
||||||
p.outro(chalk.yellow(message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by date (newest first)
|
|
||||||
conversations.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
|
|
||||||
|
|
||||||
const statusMessage = skippedCount > 0
|
|
||||||
? `Found ${conversations.length} new conversation${conversations.length === 1 ? '' : 's'} (${skippedCount} already imported)`
|
|
||||||
: `Found ${conversations.length} new conversation${conversations.length === 1 ? '' : 's'}`;
|
|
||||||
s.stop(statusMessage);
|
|
||||||
|
|
||||||
// Group conversations by project for better organization
|
|
||||||
const projectGroups = conversations.reduce((acc, conv) => {
|
|
||||||
if (!acc[conv.projectName]) acc[conv.projectName] = [];
|
|
||||||
acc[conv.projectName].push(conv);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, ConversationItem[]>);
|
|
||||||
|
|
||||||
// Create selection options
|
|
||||||
const importMode = await p.select({
|
|
||||||
message: 'How would you like to import?',
|
|
||||||
options: [
|
|
||||||
{ value: 'browse', label: 'Browse by Project', hint: 'Select project then conversations' },
|
|
||||||
{ value: 'project', label: 'Import Entire Project', hint: 'Select project and import all conversations' },
|
|
||||||
{ value: 'recent', label: 'Recent Conversations', hint: 'Import most recent across all projects' },
|
|
||||||
{ value: 'search', label: 'Search', hint: 'Search for specific conversations' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(importMode)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedConversations: ConversationItem[] = [];
|
|
||||||
|
|
||||||
if (importMode === 'browse') {
|
|
||||||
// Project selection
|
|
||||||
const projectOptions = Object.entries(projectGroups)
|
|
||||||
.sort((a, b) => b[1][0].parsedDate.getTime() - a[1][0].parsedDate.getTime())
|
|
||||||
.map(([project, convs]) => ({
|
|
||||||
value: project,
|
|
||||||
label: project,
|
|
||||||
hint: `${convs.length} conversation${convs.length === 1 ? '' : 's'}, latest: ${convs[0].relativeDate}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
const selectedProject = await p.select({
|
|
||||||
message: 'Select a project',
|
|
||||||
options: projectOptions
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selectedProject)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectConvs = projectGroups[selectedProject as string];
|
|
||||||
|
|
||||||
// Ask about title generation
|
|
||||||
const generateTitles = await p.confirm({
|
|
||||||
message: 'Would you like to generate titles for easier browsing?',
|
|
||||||
initialValue: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(generateTitles)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateTitles) {
|
|
||||||
await processTitleGeneration(projectConvs, selectedProject as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conversation selection within project
|
|
||||||
const titleGenerator = new TitleGenerator();
|
|
||||||
const convOptions = projectConvs.map(conv => {
|
|
||||||
const title = titleGenerator.getTitleForSession(conv.sessionId);
|
|
||||||
const displayTitle = title ? `"${title}" • ` : '';
|
|
||||||
return {
|
|
||||||
value: conv.sessionId,
|
|
||||||
label: `${displayTitle}${conv.relativeDate} • ${conv.messageCount} messages • ${formatFileSize(conv.fileSize)}`,
|
|
||||||
hint: conv.branch ? `branch: ${conv.branch}` : undefined
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.multi) {
|
|
||||||
const selected = await p.multiselect({
|
|
||||||
message: `Select conversations from ${selectedProject} (Space to select, Enter to confirm)`,
|
|
||||||
options: convOptions,
|
|
||||||
required: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selected)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedIds = selected as string[];
|
|
||||||
selectedConversations = projectConvs.filter(c => selectedIds.includes(c.sessionId));
|
|
||||||
} else {
|
|
||||||
// Single select with continuous import
|
|
||||||
let continueImporting = true;
|
|
||||||
const importedInSession = new Set<string>();
|
|
||||||
|
|
||||||
while (continueImporting && projectConvs.length > importedInSession.size) {
|
|
||||||
const availableConvs = projectConvs.filter(c => !importedInSession.has(c.sessionId));
|
|
||||||
|
|
||||||
if (availableConvs.length === 0) break;
|
|
||||||
|
|
||||||
const titleGenerator = new TitleGenerator();
|
|
||||||
const convOptions = availableConvs.map(conv => {
|
|
||||||
const title = titleGenerator.getTitleForSession(conv.sessionId);
|
|
||||||
const displayTitle = title ? `"${title}" • ` : '';
|
|
||||||
return {
|
|
||||||
value: conv.sessionId,
|
|
||||||
label: `${displayTitle}${conv.relativeDate} • ${conv.messageCount} messages • ${formatFileSize(conv.fileSize)}`,
|
|
||||||
hint: conv.branch ? `branch: ${conv.branch}` : undefined
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const selected = await p.select({
|
|
||||||
message: `Select a conversation (${importedInSession.size}/${projectConvs.length} imported)`,
|
|
||||||
options: [
|
|
||||||
...convOptions,
|
|
||||||
{ value: 'done', label: '✅ Done importing', hint: 'Exit import mode' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selected) || selected === 'done') {
|
|
||||||
continueImporting = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conv = availableConvs.find(c => c.sessionId === selected);
|
|
||||||
if (conv) {
|
|
||||||
selectedConversations = [conv];
|
|
||||||
await processImport(selectedConversations, options.verbose);
|
|
||||||
importedInSession.add(conv.sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (importedInSession.size > 0) {
|
|
||||||
p.outro(chalk.green(`✅ Imported ${importedInSession.size} conversation${importedInSession.size === 1 ? '' : 's'}`));
|
|
||||||
} else {
|
|
||||||
p.outro(chalk.yellow('No conversations imported'));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (importMode === 'project') {
|
|
||||||
// Project selection for importing entire project
|
|
||||||
const projectOptions = Object.entries(projectGroups)
|
|
||||||
.sort((a, b) => b[1][0].parsedDate.getTime() - a[1][0].parsedDate.getTime())
|
|
||||||
.map(([project, convs]) => ({
|
|
||||||
value: project,
|
|
||||||
label: project,
|
|
||||||
hint: `${convs.length} conversation${convs.length === 1 ? '' : 's'}, latest: ${convs[0].relativeDate}`
|
|
||||||
}));
|
|
||||||
|
|
||||||
const selectedProject = await p.select({
|
|
||||||
message: 'Select a project to import all conversations',
|
|
||||||
options: projectOptions
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selectedProject)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectConvs = projectGroups[selectedProject as string];
|
|
||||||
|
|
||||||
// Ask about title generation
|
|
||||||
const generateTitles = await p.confirm({
|
|
||||||
message: 'Would you like to generate titles for easier browsing?',
|
|
||||||
initialValue: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(generateTitles)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateTitles) {
|
|
||||||
await processTitleGeneration(projectConvs, selectedProject as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirm = await p.confirm({
|
|
||||||
message: `Import all ${projectConvs.length} conversation${projectConvs.length === 1 ? '' : 's'} from ${selectedProject}?`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(confirm) || !confirm) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedConversations = projectConvs;
|
|
||||||
|
|
||||||
} else if (importMode === 'recent') {
|
|
||||||
const limit = await p.text({
|
|
||||||
message: 'How many recent conversations?',
|
|
||||||
placeholder: '10',
|
|
||||||
initialValue: '10',
|
|
||||||
validate: (value) => {
|
|
||||||
const num = parseInt(value);
|
|
||||||
if (isNaN(num) || num < 1) return 'Please enter a valid number';
|
|
||||||
if (num > conversations.length) return `Only ${conversations.length} available`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(limit)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = parseInt(limit as string);
|
|
||||||
selectedConversations = conversations.slice(0, count);
|
|
||||||
|
|
||||||
} else if (importMode === 'search') {
|
|
||||||
const searchTerm = await p.text({
|
|
||||||
message: 'Search conversations (project name or session ID)',
|
|
||||||
placeholder: 'Enter search term'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(searchTerm)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const term = (searchTerm as string).toLowerCase();
|
|
||||||
const matches = conversations.filter(c =>
|
|
||||||
c.projectName.toLowerCase().includes(term) ||
|
|
||||||
c.sessionId.toLowerCase().includes(term) ||
|
|
||||||
(c.branch && c.branch.toLowerCase().includes(term))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
p.outro(chalk.yellow('No matching conversations found'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleGenerator = new TitleGenerator();
|
|
||||||
const matchOptions = matches.map(conv => {
|
|
||||||
const title = titleGenerator.getTitleForSession(conv.sessionId);
|
|
||||||
const displayTitle = title ? `"${title}" • ` : '';
|
|
||||||
return {
|
|
||||||
value: conv.sessionId,
|
|
||||||
label: `${displayTitle}${conv.projectName} • ${conv.relativeDate} • ${conv.messageCount} msgs`,
|
|
||||||
hint: formatFileSize(conv.fileSize)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const selected = await p.multiselect({
|
|
||||||
message: `Found ${matches.length} matches. Select to import:`,
|
|
||||||
options: matchOptions,
|
|
||||||
required: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selected)) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedIds = selected as string[];
|
|
||||||
selectedConversations = matches.filter(c => selectedIds.includes(c.sessionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the import
|
|
||||||
if (selectedConversations.length > 0) {
|
|
||||||
await processImport(selectedConversations, options.verbose);
|
|
||||||
p.outro(chalk.green(`✅ Successfully imported ${selectedConversations.length} conversation${selectedConversations.length === 1 ? '' : 's'}`));
|
|
||||||
} else {
|
|
||||||
p.outro(chalk.yellow('No conversations selected for import'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processTitleGeneration(conversations: ConversationItem[], projectName: string) {
|
|
||||||
const titleGenerator = new TitleGenerator();
|
|
||||||
const existingTitles = titleGenerator.getExistingTitles();
|
|
||||||
|
|
||||||
// Filter conversations that don't have titles yet
|
|
||||||
const conversationsNeedingTitles = conversations.filter(conv => !existingTitles.has(conv.sessionId));
|
|
||||||
|
|
||||||
if (conversationsNeedingTitles.length === 0) {
|
|
||||||
p.note('All conversations already have titles!', 'Title Generation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start(`Generating titles for ${conversationsNeedingTitles.length} conversations...`);
|
|
||||||
|
|
||||||
const requests: TitleGenerationRequest[] = conversationsNeedingTitles.map(conv => ({
|
|
||||||
sessionId: conv.sessionId,
|
|
||||||
projectName: projectName,
|
|
||||||
firstMessage: extractFirstUserMessage(conv.filePath)
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await titleGenerator.batchGenerateTitles(requests);
|
|
||||||
s.stop(`✅ Generated ${conversationsNeedingTitles.length} titles`);
|
|
||||||
} catch (error) {
|
|
||||||
s.stop(`❌ Failed to generate titles`);
|
|
||||||
console.error(chalk.red(`Error: ${error}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processImport(conversations: ConversationItem[], verbose?: boolean) {
|
|
||||||
const s = p.spinner();
|
|
||||||
|
|
||||||
for (let i = 0; i < conversations.length; i++) {
|
|
||||||
const conv = conversations[i];
|
|
||||||
const progress = conversations.length > 1 ? `[${i + 1}/${conversations.length}] ` : '';
|
|
||||||
|
|
||||||
s.start(`${progress}Importing ${conv.projectName} (${conv.relativeDate})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract project name from the conversation's cwd field
|
|
||||||
const projectName = path.basename(conv.cwd);
|
|
||||||
|
|
||||||
// Use TranscriptCompressor to process
|
|
||||||
const compressor = new TranscriptCompressor();
|
|
||||||
await compressor.compress(conv.filePath, conv.sessionId, projectName);
|
|
||||||
|
|
||||||
s.stop(`${progress}Imported ${conv.projectName} (${conv.messageCount} messages)`);
|
|
||||||
|
|
||||||
if (verbose) {
|
|
||||||
p.note(`Session: ${conv.sessionId}\nSize: ${formatFileSize(conv.fileSize)}\nBranch: ${conv.branch || 'main'}`, 'Details');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
s.stop(`${progress}Failed to import ${conv.projectName}`);
|
|
||||||
if (verbose) {
|
|
||||||
console.error(chalk.red(`Error: ${error}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+171
-127
@@ -211,11 +211,13 @@ function detectExistingInstallation(): {
|
|||||||
scope: undefined as InstallScope | undefined
|
scope: undefined as InstallScope | undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for hooks
|
// Check for runtime hooks (installed to user's hooks directory from hook-templates/)
|
||||||
const hooksDir = PathDiscovery.getHooksDirectory();
|
const runtimeHooksDir = PathDiscovery.getHooksDirectory();
|
||||||
result.hasHooks = existsSync(hooksDir) &&
|
result.hasHooks = existsSync(runtimeHooksDir) &&
|
||||||
existsSync(join(hooksDir, 'pre-compact.js')) &&
|
existsSync(join(runtimeHooksDir, 'session-start.js')) &&
|
||||||
existsSync(join(hooksDir, 'session-start.js'));
|
existsSync(join(runtimeHooksDir, 'stop.js')) &&
|
||||||
|
existsSync(join(runtimeHooksDir, 'user-prompt-submit.js')) &&
|
||||||
|
existsSync(join(runtimeHooksDir, 'post-tool-use.js'));
|
||||||
|
|
||||||
// Check for Chroma MCP server configuration
|
// Check for Chroma MCP server configuration
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
const pathDiscovery = PathDiscovery.getInstance();
|
||||||
@@ -223,23 +225,19 @@ function detectExistingInstallation(): {
|
|||||||
const projectMcpPath = pathDiscovery.getProjectMcpConfigPath();
|
const projectMcpPath = pathDiscovery.getProjectMcpConfigPath();
|
||||||
|
|
||||||
if (existsSync(userMcpPath)) {
|
if (existsSync(userMcpPath)) {
|
||||||
try {
|
|
||||||
const config = JSON.parse(readFileSync(userMcpPath, 'utf8'));
|
const config = JSON.parse(readFileSync(userMcpPath, 'utf8'));
|
||||||
if (config.mcpServers?.['claude-mem']) {
|
if (config.mcpServers?.['claude-mem']) {
|
||||||
result.hasChromaMcp = true;
|
result.hasChromaMcp = true;
|
||||||
result.scope = 'user';
|
result.scope = 'user';
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(projectMcpPath)) {
|
if (existsSync(projectMcpPath)) {
|
||||||
try {
|
|
||||||
const config = JSON.parse(readFileSync(projectMcpPath, 'utf8'));
|
const config = JSON.parse(readFileSync(projectMcpPath, 'utf8'));
|
||||||
if (config.mcpServers?.['claude-mem']) {
|
if (config.mcpServers?.['claude-mem']) {
|
||||||
result.hasChromaMcp = true;
|
result.hasChromaMcp = true;
|
||||||
result.scope = 'project';
|
result.scope = 'project';
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for settings
|
// Check for settings
|
||||||
@@ -370,10 +368,10 @@ async function backupExistingConfig(): Promise<string | null> {
|
|||||||
try {
|
try {
|
||||||
mkdirSync(backupDir, { recursive: true });
|
mkdirSync(backupDir, { recursive: true });
|
||||||
|
|
||||||
// Backup hooks if they exist
|
// Backup runtime hooks if they exist
|
||||||
const hooksDir = pathDiscovery.getHooksDirectory();
|
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||||
if (existsSync(hooksDir)) {
|
if (existsSync(runtimeHooksDir)) {
|
||||||
copyFileRecursively(hooksDir, join(backupDir, 'hooks'));
|
copyFileRecursively(runtimeHooksDir, join(backupDir, 'hooks'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup settings
|
// Backup settings
|
||||||
@@ -432,25 +430,43 @@ function copyFileRecursively(src: string, dest: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeHookFiles(timeout: number = 180000): void {
|
/**
|
||||||
|
* Install hook files from package hook-templates/ to runtime hooks directory
|
||||||
|
*
|
||||||
|
* This copies hook template files from the installed package's hook-templates/ directory
|
||||||
|
* to the user's runtime hooks directory at ~/.claude-mem/hooks/
|
||||||
|
*
|
||||||
|
* Hook files installed:
|
||||||
|
* - session-start.js - SessionStart hook (no matcher field required)
|
||||||
|
* - stop.js - Stop hook (no matcher field required)
|
||||||
|
* - user-prompt-submit.js - UserPromptSubmit hook (no matcher field required)
|
||||||
|
* - post-tool-use.js - PostToolUse hook (matcher field REQUIRED in settings.json)
|
||||||
|
*
|
||||||
|
* Official docs: https://docs.claude.com/en/docs/claude-code/hooks
|
||||||
|
* Local docs: /Users/alexnewman/Scripts/claude-mem-source/docs/HOOK_PROMPTS.md
|
||||||
|
*
|
||||||
|
* @param timeout - Hook timeout in MILLISECONDS for config.json (converted to seconds in settings.json)
|
||||||
|
* @param force - Force overwrite of existing hook files
|
||||||
|
*/
|
||||||
|
function writeHookFiles(timeout: number = 180000, force: boolean = false): void {
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
const pathDiscovery = PathDiscovery.getInstance();
|
||||||
const hooksDir = pathDiscovery.getHooksDirectory();
|
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||||
|
|
||||||
// Find the installed package hooks directory
|
// Find the installed package hook-templates directory
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
// DYNAMIC DISCOVERY: Find hooks by walking up from current location
|
// DYNAMIC DISCOVERY: Find hook-templates by walking up from current location
|
||||||
let currentDir = __dirname;
|
let currentDir = __dirname;
|
||||||
let packageHooksDir: string | null = null;
|
let packageHookTemplatesDir: string | null = null;
|
||||||
|
|
||||||
// Walk up the tree to find the hooks directory
|
// Walk up the tree to find the hook-templates directory
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const hooksPath = join(currentDir, 'hooks');
|
const hookTemplatesPath = join(currentDir, 'hook-templates');
|
||||||
|
|
||||||
// Check if this directory has the hook files
|
// Check if this directory has the hook template files
|
||||||
if (existsSync(join(hooksPath, 'pre-compact.js'))) {
|
if (existsSync(join(hookTemplatesPath, 'session-start.js'))) {
|
||||||
packageHooksDir = hooksPath;
|
packageHookTemplatesDir = hookTemplatesPath;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,46 +479,45 @@ function writeHookFiles(timeout: number = 180000): void {
|
|||||||
currentDir = parentDir;
|
currentDir = parentDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we still haven't found it, use PathDiscovery to find package hooks
|
// If we still haven't found it, use PathDiscovery to find package hook templates
|
||||||
if (!packageHooksDir) {
|
if (!packageHookTemplatesDir) {
|
||||||
try {
|
packageHookTemplatesDir = pathDiscovery.findPackageHookTemplatesDirectory();
|
||||||
packageHooksDir = pathDiscovery.findPackageHooksDirectory();
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Cannot dynamically locate hooks directory. The package may be corrupted.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy hook files from the package instead of creating wrappers
|
// Copy hook template files from the package to runtime hooks directory
|
||||||
const hooks = ['pre-compact.js', 'session-start.js', 'session-end.js'];
|
const hookFiles = ['session-start.js', 'stop.js', 'user-prompt-submit.js', 'post-tool-use.js'];
|
||||||
|
|
||||||
for (const hookName of hooks) {
|
for (const hookFile of hookFiles) {
|
||||||
const sourcePath = join(packageHooksDir, hookName);
|
const runtimeHookPath = join(runtimeHooksDir, hookFile);
|
||||||
const destPath = join(hooksDir, hookName);
|
const sourceTemplatePath = join(packageHookTemplatesDir, hookFile);
|
||||||
|
|
||||||
if (existsSync(sourcePath)) {
|
copyFileSync(sourceTemplatePath, runtimeHookPath);
|
||||||
copyFileSync(sourcePath, destPath);
|
chmodSync(runtimeHookPath, 0o755);
|
||||||
chmodSync(destPath, 0o755);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Copy shared directory if it exists
|
// Copy shared directory if it exists in hook-templates
|
||||||
const sourceSharedDir = join(packageHooksDir, 'shared');
|
if (packageHookTemplatesDir) {
|
||||||
const destSharedDir = join(hooksDir, 'shared');
|
const sourceSharedTemplateDir = join(packageHookTemplatesDir, 'shared');
|
||||||
|
const runtimeSharedDir = join(runtimeHooksDir, 'shared');
|
||||||
|
|
||||||
if (existsSync(sourceSharedDir)) {
|
if (existsSync(sourceSharedTemplateDir)) {
|
||||||
copyFileRecursively(sourceSharedDir, destSharedDir);
|
copyFileRecursively(sourceSharedTemplateDir, runtimeSharedDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write configuration with custom timeout
|
// Write runtime hook configuration with custom timeout
|
||||||
const hookConfigPath = join(hooksDir, 'config.json');
|
// NOTE: This config.json uses MILLISECONDS for internal hook timeout tracking
|
||||||
|
// However, settings.json uses SECONDS per official Claude Code docs
|
||||||
|
// See configureHooks() function for the milliseconds → seconds conversion
|
||||||
|
const runtimeHookConfigPath = join(runtimeHooksDir, 'config.json');
|
||||||
const hookConfig = {
|
const hookConfig = {
|
||||||
packageName: PACKAGE_NAME,
|
packageName: PACKAGE_NAME,
|
||||||
cliCommand: PACKAGE_NAME,
|
cliCommand: PACKAGE_NAME,
|
||||||
backend: 'chroma',
|
backend: 'chroma',
|
||||||
timeout
|
timeout // Milliseconds (e.g., 180000ms = 3 minutes)
|
||||||
};
|
};
|
||||||
writeFileSync(hookConfigPath, JSON.stringify(hookConfig, null, 2));
|
writeFileSync(runtimeHookConfigPath, JSON.stringify(hookConfig, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -627,10 +642,12 @@ async function installChromaMcp(): Promise<boolean> {
|
|||||||
|
|
||||||
async function configureHooks(settingsPath: string, config: InstallConfig): Promise<void> {
|
async function configureHooks(settingsPath: string, config: InstallConfig): Promise<void> {
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
const pathDiscovery = PathDiscovery.getInstance();
|
||||||
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
|
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||||
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
|
// Runtime hooks (copied from hook-templates/ during installation)
|
||||||
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
|
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
|
||||||
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
|
const stopScript = join(runtimeHooksDir, 'stop.js');
|
||||||
|
const userPromptScript = join(runtimeHooksDir, 'user-prompt-submit.js');
|
||||||
|
const postToolScript = join(runtimeHooksDir, 'post-tool-use.js');
|
||||||
|
|
||||||
let settings: any = {};
|
let settings: any = {};
|
||||||
if (existsSync(settingsPath)) {
|
if (existsSync(settingsPath)) {
|
||||||
@@ -650,99 +667,125 @@ async function configureHooks(settingsPath: string, config: InstallConfig): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove existing claude-mem hooks to ensure clean installation/update
|
// Remove existing claude-mem hooks to ensure clean installation/update
|
||||||
// Non-tool hooks: filter out configs where hooks contain our commands
|
// Remove both old and SDK hooks for clean reinstall
|
||||||
if (settings.hooks.PreCompact) {
|
const hookTypes = ['SessionStart', 'Stop', 'UserPromptSubmit', 'PostToolUse'];
|
||||||
settings.hooks.PreCompact = settings.hooks.PreCompact.filter((cfg: any) =>
|
|
||||||
!cfg.hooks?.some((hook: any) =>
|
|
||||||
hook.command?.includes(PACKAGE_NAME) || hook.command?.includes('pre-compact.js')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (!settings.hooks.PreCompact.length) delete settings.hooks.PreCompact;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.hooks.SessionStart) {
|
for (const hookType of hookTypes) {
|
||||||
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((cfg: any) =>
|
if (settings.hooks[hookType]) {
|
||||||
|
settings.hooks[hookType] = settings.hooks[hookType].filter((cfg: any) =>
|
||||||
!cfg.hooks?.some((hook: any) =>
|
!cfg.hooks?.some((hook: any) =>
|
||||||
hook.command?.includes(PACKAGE_NAME) || hook.command?.includes('session-start.js')
|
hook.command?.includes(PACKAGE_NAME) ||
|
||||||
|
hook.command?.includes('session-start.js') ||
|
||||||
|
hook.command?.includes('stop.js') ||
|
||||||
|
hook.command?.includes('user-prompt-submit.js') ||
|
||||||
|
hook.command?.includes('post-tool-use.js')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (!settings.hooks.SessionStart.length) delete settings.hooks.SessionStart;
|
if (!settings.hooks[hookType].length) delete settings.hooks[hookType];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.hooks.SessionEnd) {
|
|
||||||
settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter((cfg: any) =>
|
|
||||||
!cfg.hooks?.some((hook: any) =>
|
|
||||||
hook.command?.includes(PACKAGE_NAME) || hook.command?.includes('session-end.js')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (!settings.hooks.SessionEnd.length) delete settings.hooks.SessionEnd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔒 LOCKED by @docs-agent | Change to 🔑 to allow @docs-agent edits
|
* 🔒 LOCKED by @docs-agent | Change to 🔑 to allow @docs-agent edits
|
||||||
*
|
*
|
||||||
* OFFICIAL DOCS: Claude Code Hooks Configuration v2025
|
* OFFICIAL DOCS: Claude Code Hooks Configuration
|
||||||
* Last Verified: 2025-08-31
|
* Source: https://docs.claude.com/en/docs/claude-code/hooks
|
||||||
|
* Last Verified: 2025-10-02
|
||||||
*
|
*
|
||||||
* Hook Configuration Structure Requirements:
|
* Hook Configuration Structure Requirements (from official docs):
|
||||||
* - Tool-related hooks (PreToolUse, PostToolUse): Use 'matcher' field for tool patterns
|
|
||||||
* - Non-tool hooks (PreCompact, SessionStart, SessionEnd, etc.): NO matcher/pattern field
|
|
||||||
*
|
*
|
||||||
* Correct Non-Tool Hook Structure:
|
* Tool Hooks (PreToolUse, PostToolUse):
|
||||||
* {
|
* - MUST include 'matcher' field with tool name pattern (supports regex/wildcards)
|
||||||
* hooks: [{
|
* - Example: { matcher: "*", hooks: [{ type: "command", command: "...", timeout: 180 }] }
|
||||||
* type: "command",
|
|
||||||
* command: "/path/to/script.js"
|
|
||||||
* }]
|
|
||||||
* }
|
|
||||||
*
|
*
|
||||||
* @see https://docs.anthropic.com/en/docs/claude-code/hooks
|
* Non-Tool Hooks (SessionStart, Stop, UserPromptSubmit, SessionEnd, etc.):
|
||||||
* @see docs/claude-code/hook-configuration.md for full documentation
|
* - MUST NOT include 'matcher' or 'pattern' field
|
||||||
|
* - Example: { hooks: [{ type: "command", command: "...", timeout: 60 }] }
|
||||||
|
*
|
||||||
|
* All Hooks:
|
||||||
|
* - type: Must be "command" (only supported type)
|
||||||
|
* - timeout: Optional, in SECONDS (default: 60), not milliseconds
|
||||||
|
* - command: Absolute path to executable script
|
||||||
|
*
|
||||||
|
* @see https://docs.claude.com/en/docs/claude-code/hooks - Official hook documentation
|
||||||
|
* @see /Users/alexnewman/Scripts/claude-mem-source/docs/reference/AGENT_SDK.md - Lines 514-671 for SDK hook types
|
||||||
|
* @see /Users/alexnewman/Scripts/claude-mem-source/docs/HOOK_PROMPTS.md - Local hook implementation guide
|
||||||
*/
|
*/
|
||||||
// Add PreCompact hook - Non-tool hook (no matcher field)
|
|
||||||
if (!settings.hooks.PreCompact) {
|
|
||||||
settings.hooks.PreCompact = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ CORRECT: Non-tool hooks have no 'pattern' or 'matcher' field
|
|
||||||
settings.hooks.PreCompact.push({
|
|
||||||
hooks: [
|
|
||||||
{
|
|
||||||
type: "command",
|
|
||||||
command: preCompactScript,
|
|
||||||
timeout: 180
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add SessionStart hook - Non-tool hook (no matcher field)
|
// Add SessionStart hook - Non-tool hook (no matcher field)
|
||||||
|
// Official docs: https://docs.claude.com/en/docs/claude-code/hooks
|
||||||
|
// Hook type: SessionStart (non-tool event - no matcher required)
|
||||||
if (!settings.hooks.SessionStart) {
|
if (!settings.hooks.SessionStart) {
|
||||||
settings.hooks.SessionStart = [];
|
settings.hooks.SessionStart = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ CORRECT: Non-tool hooks have no 'pattern' or 'matcher' field
|
// ✅ CORRECT: Non-tool hooks have no 'pattern' or 'matcher' field
|
||||||
|
// Timeout is 180 SECONDS (3 minutes) - sufficient for context loading
|
||||||
settings.hooks.SessionStart.push({
|
settings.hooks.SessionStart.push({
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
type: "command",
|
type: "command", // Required field - only "command" type supported
|
||||||
command: sessionStartScript,
|
command: sessionStartScript, // Absolute path to hook script
|
||||||
timeout: 180
|
timeout: 180 // Seconds (not milliseconds) - per official docs
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add SessionEnd hook (only if the file exists)
|
// Add Stop hook - Non-tool hook (no matcher field)
|
||||||
if (existsSync(sessionEndScript)) {
|
// Official docs: https://docs.claude.com/en/docs/claude-code/hooks
|
||||||
if (!settings.hooks.SessionEnd) {
|
// Hook type: Stop (non-tool event - no matcher required)
|
||||||
settings.hooks.SessionEnd = [];
|
if (existsSync(stopScript)) {
|
||||||
|
if (!settings.hooks.Stop) {
|
||||||
|
settings.hooks.Stop = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ CORRECT: Non-tool hooks have no 'pattern' or 'matcher' field
|
// ✅ CORRECT: Non-tool hooks have no 'matcher' field
|
||||||
settings.hooks.SessionEnd.push({
|
// Timeout is 60 SECONDS (1 minute) - sufficient for session overview generation
|
||||||
|
settings.hooks.Stop.push({
|
||||||
hooks: [{
|
hooks: [{
|
||||||
type: "command",
|
type: "command", // Required field - only "command" type supported
|
||||||
command: sessionEndScript,
|
command: stopScript, // Absolute path to hook script
|
||||||
timeout: 180
|
timeout: 60 // Seconds (not milliseconds) - per official docs
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add UserPromptSubmit hook - Non-tool hook (no matcher field)
|
||||||
|
// Official docs: https://docs.claude.com/en/docs/claude-code/hooks
|
||||||
|
// Hook type: UserPromptSubmit (non-tool event - no matcher required)
|
||||||
|
if (existsSync(userPromptScript)) {
|
||||||
|
if (!settings.hooks.UserPromptSubmit) {
|
||||||
|
settings.hooks.UserPromptSubmit = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT: Non-tool hooks have no 'matcher' field
|
||||||
|
// Timeout is 60 SECONDS (1 minute) - sufficient for real-time prompt capture
|
||||||
|
settings.hooks.UserPromptSubmit.push({
|
||||||
|
hooks: [{
|
||||||
|
type: "command", // Required field - only "command" type supported
|
||||||
|
command: userPromptScript, // Absolute path to hook script
|
||||||
|
timeout: 60 // Seconds (not milliseconds) - per official docs
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add PostToolUse hook - TOOL HOOK (requires matcher field)
|
||||||
|
// Official docs: https://docs.claude.com/en/docs/claude-code/hooks
|
||||||
|
// Hook type: PostToolUse (tool-related event - matcher REQUIRED)
|
||||||
|
if (existsSync(postToolScript)) {
|
||||||
|
if (!settings.hooks.PostToolUse) {
|
||||||
|
settings.hooks.PostToolUse = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT: Tool hooks MUST have 'matcher' field
|
||||||
|
// matcher: "*" matches all tools (supports regex/wildcards per official docs)
|
||||||
|
// Timeout is 180 SECONDS (3 minutes) - allows async compression via Agent SDK
|
||||||
|
settings.hooks.PostToolUse.push({
|
||||||
|
matcher: "*", // REQUIRED for tool hooks - matches all tools
|
||||||
|
hooks: [{
|
||||||
|
type: "command", // Required field - only "command" type supported
|
||||||
|
command: postToolScript, // Absolute path to hook script
|
||||||
|
timeout: 180 // Seconds (not milliseconds) - per official docs
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -765,7 +808,6 @@ async function configureSmartTrashAlias(): Promise<void> {
|
|||||||
for (const configPath of shellConfigs) {
|
for (const configPath of shellConfigs) {
|
||||||
if (!existsSync(configPath)) continue;
|
if (!existsSync(configPath)) continue;
|
||||||
|
|
||||||
try {
|
|
||||||
let content = readFileSync(configPath, 'utf8');
|
let content = readFileSync(configPath, 'utf8');
|
||||||
|
|
||||||
// Check if alias already exists
|
// Check if alias already exists
|
||||||
@@ -778,9 +820,6 @@ async function configureSmartTrashAlias(): Promise<void> {
|
|||||||
content += aliasBlock;
|
content += aliasBlock;
|
||||||
|
|
||||||
writeFileSync(configPath, content);
|
writeFileSync(configPath, content);
|
||||||
} catch (error) {
|
|
||||||
// Silent fail - not critical
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,14 +928,21 @@ async function verifyInstallation(): Promise<void> {
|
|||||||
|
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
|
|
||||||
// Check hooks
|
// Check runtime hooks (installed from hook-templates/)
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
const pathDiscovery = PathDiscovery.getInstance();
|
||||||
const hooksDir = pathDiscovery.getHooksDirectory();
|
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||||
if (!existsSync(join(hooksDir, 'pre-compact.js'))) {
|
|
||||||
issues.push('Pre-compact hook not found');
|
const requiredRuntimeHooks = [
|
||||||
|
'session-start.js',
|
||||||
|
'stop.js',
|
||||||
|
'user-prompt-submit.js',
|
||||||
|
'post-tool-use.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const runtimeHook of requiredRuntimeHooks) {
|
||||||
|
if (!existsSync(join(runtimeHooksDir, runtimeHook))) {
|
||||||
|
issues.push(`${runtimeHook} not found`);
|
||||||
}
|
}
|
||||||
if (!existsSync(join(hooksDir, 'session-start.js'))) {
|
|
||||||
issues.push('Session-start hook not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
@@ -1005,7 +1051,7 @@ export async function install(options: OptionValues = {}): Promise<void> {
|
|||||||
name: 'Installing memory hooks',
|
name: 'Installing memory hooks',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
writeHookFiles(config.hookTimeout);
|
writeHookFiles(config.hookTimeout, config.forceReinstall);
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1032,9 +1078,7 @@ export async function install(options: OptionValues = {}): Promise<void> {
|
|||||||
let userSettings: Settings = {};
|
let userSettings: Settings = {};
|
||||||
|
|
||||||
if (existsSync(userSettingsPath)) {
|
if (existsSync(userSettingsPath)) {
|
||||||
try {
|
|
||||||
userSettings = JSON.parse(readFileSync(userSettingsPath, 'utf8'));
|
userSettings = JSON.parse(readFileSync(userSettingsPath, 'utf8'));
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userSettings.backend = 'chroma';
|
userSettings.backend = 'chroma';
|
||||||
|
|||||||
+176
-48
@@ -10,7 +10,10 @@ import {
|
|||||||
outputSessionStartContent
|
outputSessionStartContent
|
||||||
} from '../prompts/templates/context/ContextTemplates.js';
|
} from '../prompts/templates/context/ContextTemplates.js';
|
||||||
import { getStorageProvider, needsMigration } from '../shared/storage.js';
|
import { getStorageProvider, needsMigration } from '../shared/storage.js';
|
||||||
import { MemoryRow, OverviewRow, SessionRow } from '../services/sqlite/types.js';
|
import { MemoryRow, OverviewRow } from '../services/sqlite/types.js';
|
||||||
|
import { createStores } from '../services/sqlite/index.js';
|
||||||
|
import { getRollingSettings } from '../shared/rolling-settings.js';
|
||||||
|
import { rollingLog } from '../shared/rolling-log.js';
|
||||||
|
|
||||||
interface TrashStatus {
|
interface TrashStatus {
|
||||||
folderCount: number;
|
folderCount: number;
|
||||||
@@ -19,6 +22,45 @@ interface TrashStatus {
|
|||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateHeader(date = new Date()): string {
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordWrap(text: string, maxWidth: number, prefix: string): string {
|
||||||
|
const words = text.split(' ');
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = prefix;
|
||||||
|
const continuationPrefix = ' '.repeat(prefix.length);
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const needsSpace = currentLine !== prefix && currentLine !== continuationPrefix;
|
||||||
|
const testLine = currentLine + (needsSpace ? ' ' : '') + word;
|
||||||
|
|
||||||
|
if (testLine.length <= maxWidth) {
|
||||||
|
currentLine = testLine;
|
||||||
|
} else {
|
||||||
|
if (currentLine.trim()) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
currentLine = continuationPrefix + word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine.trim()) {
|
||||||
|
lines.push(currentLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function buildProjectMatcher(projectName: string): (value?: string) => boolean {
|
function buildProjectMatcher(projectName: string): (value?: string) => boolean {
|
||||||
const aliases = new Set<string>();
|
const aliases = new Set<string>();
|
||||||
aliases.add(projectName);
|
aliases.add(projectName);
|
||||||
@@ -67,6 +109,124 @@ function getTrashStatus(): TrashStatus {
|
|||||||
return { folderCount, fileCount, totalSize, isEmpty: false };
|
return { folderCount, fileCount, totalSize, isEmpty: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderRollingSessionStart(projectOverride?: string): Promise<void> {
|
||||||
|
const settings = getRollingSettings();
|
||||||
|
|
||||||
|
if (!settings.sessionStartEnabled) {
|
||||||
|
console.log('Rolling session-start output disabled in settings.');
|
||||||
|
rollingLog('info', 'session-start output skipped (disabled)', {
|
||||||
|
project: projectOverride
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stores = await createStores();
|
||||||
|
const projectName = projectOverride || PathDiscovery.getCurrentProjectName();
|
||||||
|
|
||||||
|
// Get all overviews for this project (oldest to newest)
|
||||||
|
const allOverviews = stores.overviews.getAllForProject(projectName);
|
||||||
|
|
||||||
|
// Limit to last 10 overviews
|
||||||
|
const recentOverviews = allOverviews.slice(-10);
|
||||||
|
|
||||||
|
// If no data at all, show friendly message
|
||||||
|
if (recentOverviews.length === 0) {
|
||||||
|
console.log('===============================================================================');
|
||||||
|
console.log(`What's new | ${formatDateHeader()}`);
|
||||||
|
console.log('===============================================================================');
|
||||||
|
console.log('No previous sessions found for this project.');
|
||||||
|
console.log('Start working and claude-mem will automatically capture context for future sessions.');
|
||||||
|
console.log('===============================================================================');
|
||||||
|
const trashStatus = getTrashStatus();
|
||||||
|
if (!trashStatus.isEmpty) {
|
||||||
|
const formattedSize = formatSize(trashStatus.totalSize);
|
||||||
|
console.log(
|
||||||
|
`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`claude-mem restore\``
|
||||||
|
);
|
||||||
|
console.log('===============================================================================');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output header
|
||||||
|
console.log('===============================================================================');
|
||||||
|
console.log(`What's new | ${formatDateHeader()}`);
|
||||||
|
console.log('===============================================================================');
|
||||||
|
|
||||||
|
// Output each overview with timestamp, memory names, and files touched (oldest to newest)
|
||||||
|
recentOverviews.forEach((overview) => {
|
||||||
|
const date = new Date(overview.created_at);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = date.getHours();
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHours = hours % 12 || 12;
|
||||||
|
|
||||||
|
console.log(`[${year}-${month}-${day} at ${displayHours}:${minutes} ${ampm}]`);
|
||||||
|
|
||||||
|
// Get memories for this session to show titles, subtitles, files, and keywords
|
||||||
|
const sessionMemories = stores.memories.getBySessionId(overview.session_id);
|
||||||
|
|
||||||
|
// Extract memory titles and subtitles
|
||||||
|
const memories = sessionMemories
|
||||||
|
.map(m => ({ title: m.title, subtitle: m.subtitle }))
|
||||||
|
.filter(m => m.title);
|
||||||
|
|
||||||
|
// Extract unique files touched across all memories
|
||||||
|
const allFilesTouched = new Set<string>();
|
||||||
|
const allKeywords = new Set<string>();
|
||||||
|
|
||||||
|
sessionMemories.forEach(m => {
|
||||||
|
if (m.files_touched) {
|
||||||
|
try {
|
||||||
|
const files = JSON.parse(m.files_touched);
|
||||||
|
if (Array.isArray(files)) {
|
||||||
|
files.forEach(f => allFilesTouched.add(f));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.keywords) {
|
||||||
|
// Keywords are comma-separated
|
||||||
|
m.keywords.split(',').forEach(k => allKeywords.add(k.trim()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Always show overview content
|
||||||
|
console.log(wordWrap(overview.content, 80, ''));
|
||||||
|
|
||||||
|
// Display files touched if any
|
||||||
|
if (allFilesTouched.size > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(wordWrap(`- ${Array.from(allFilesTouched).join(', ')}`, 80, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display keywords/tags if any
|
||||||
|
if (allKeywords.size > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log(wordWrap(`Tags: ${Array.from(allKeywords).join(', ')}`, 80, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('===============================================================================');
|
||||||
|
const trashStatus = getTrashStatus();
|
||||||
|
if (!trashStatus.isEmpty) {
|
||||||
|
const formattedSize = formatSize(trashStatus.totalSize);
|
||||||
|
console.log(
|
||||||
|
`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`claude-mem restore\``
|
||||||
|
);
|
||||||
|
console.log('===============================================================================');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadContext(options: OptionValues = {}): Promise<void> {
|
export async function loadContext(options: OptionValues = {}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Check if migration is needed and warn the user
|
// Check if migration is needed and warn the user
|
||||||
@@ -84,7 +244,6 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
|
|||||||
// SQLite implementation - fetch data using storage provider
|
// SQLite implementation - fetch data using storage provider
|
||||||
let recentMemories: MemoryRow[] = [];
|
let recentMemories: MemoryRow[] = [];
|
||||||
let recentOverviews: OverviewRow[] = [];
|
let recentOverviews: OverviewRow[] = [];
|
||||||
let recentSessions: SessionRow[] = [];
|
|
||||||
|
|
||||||
// Auto-detect current project for session-start format if no project specified
|
// Auto-detect current project for session-start format if no project specified
|
||||||
let projectToUse = options.project;
|
let projectToUse = options.project;
|
||||||
@@ -92,14 +251,19 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
|
|||||||
projectToUse = PathDiscovery.getCurrentProjectName();
|
projectToUse = PathDiscovery.getCurrentProjectName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.format === 'session-start') {
|
||||||
|
await renderRollingSessionStart(projectToUse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overviewLimit = options.format === 'json' ? 5 : 3;
|
||||||
|
|
||||||
if (projectToUse) {
|
if (projectToUse) {
|
||||||
recentMemories = await storage.getRecentMemoriesForProject(projectToUse, 10);
|
recentMemories = await storage.getRecentMemoriesForProject(projectToUse, 10);
|
||||||
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, options.format === 'session-start' ? 5 : 3);
|
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, overviewLimit);
|
||||||
recentSessions = await storage.getRecentSessionsForProject(projectToUse, 5);
|
|
||||||
} else {
|
} else {
|
||||||
recentMemories = await storage.getRecentMemories(10);
|
recentMemories = await storage.getRecentMemories(10);
|
||||||
recentOverviews = await storage.getRecentOverviews(options.format === 'session-start' ? 5 : 3);
|
recentOverviews = await storage.getRecentOverviews(overviewLimit);
|
||||||
recentSessions = await storage.getRecentSessions(5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert SQLite rows to JSONL format for compatibility with existing output functions
|
// Convert SQLite rows to JSONL format for compatibility with existing output functions
|
||||||
@@ -122,48 +286,12 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
|
|||||||
timestamp: row.created_at
|
timestamp: row.created_at
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const sessionsAsJSON = recentSessions.map(row => ({
|
|
||||||
type: 'session',
|
|
||||||
session_id: row.session_id,
|
|
||||||
project: row.project,
|
|
||||||
timestamp: row.created_at
|
|
||||||
}));
|
|
||||||
|
|
||||||
// If no data found, show appropriate messages
|
// If no data found, show appropriate messages
|
||||||
if (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0 && sessionsAsJSON.length === 0) {
|
if (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0) {
|
||||||
if (options.format === 'session-start') {
|
|
||||||
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the same output logic as the original implementation
|
if (options.format === 'json') {
|
||||||
if (options.format === 'session-start') {
|
|
||||||
// Combine them for the display
|
|
||||||
const recentObjects = [...sessionsAsJSON, ...memoriesAsJSON, ...overviewsAsJSON];
|
|
||||||
|
|
||||||
// Find most recent timestamp for last session info
|
|
||||||
let lastSessionTime = 'recently';
|
|
||||||
const timestamps = recentObjects
|
|
||||||
.map(obj => {
|
|
||||||
return obj.timestamp ? new Date(obj.timestamp) : null;
|
|
||||||
})
|
|
||||||
.filter(date => date !== null)
|
|
||||||
.sort((a, b) => b.getTime() - a.getTime());
|
|
||||||
|
|
||||||
if (timestamps.length > 0) {
|
|
||||||
lastSessionTime = formatTimeAgo(timestamps[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use dual-stream output for session start formatting
|
|
||||||
outputSessionStartContent({
|
|
||||||
projectName: projectToUse || 'your project',
|
|
||||||
memoryCount: memoriesAsJSON.length,
|
|
||||||
lastSessionTime,
|
|
||||||
recentObjects
|
|
||||||
});
|
|
||||||
|
|
||||||
} else if (options.format === 'json') {
|
|
||||||
// For JSON format, combine last 10 of each type
|
// For JSON format, combine last 10 of each type
|
||||||
const recentObjects = [...memoriesAsJSON, ...overviewsAsJSON];
|
const recentObjects = [...memoriesAsJSON, ...overviewsAsJSON];
|
||||||
console.log(JSON.stringify(recentObjects));
|
console.log(JSON.stringify(recentObjects));
|
||||||
@@ -189,7 +317,7 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
|
|||||||
const trashStatus = getTrashStatus();
|
const trashStatus = getTrashStatus();
|
||||||
if (!trashStatus.isEmpty) {
|
if (!trashStatus.isEmpty) {
|
||||||
const formattedSize = formatSize(trashStatus.totalSize);
|
const formattedSize = formatSize(trashStatus.totalSize);
|
||||||
console.log(`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`$ claude-mem restore\``);
|
console.log(`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`claude-mem restore\``);
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,10 +404,10 @@ async function loadContextFromJSONL(options: OptionValues = {}): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.format === 'session-start') {
|
if (options.format === 'session-start') {
|
||||||
// Get last 10 memories and last 5 overviews for session-start
|
// Get last 10 memories and last 10 overviews for session-start
|
||||||
const recentMemories = filteredMemories.slice(-10);
|
const recentMemories = filteredMemories.slice(-10);
|
||||||
const recentOverviews = filteredOverviews.slice(-5);
|
const recentOverviews = filteredOverviews.slice(-10);
|
||||||
const recentSessions = filteredSessions.slice(-5);
|
const recentSessions = filteredSessions.slice(-10);
|
||||||
|
|
||||||
// Combine them for the display
|
// Combine them for the display
|
||||||
const recentObjects = [...recentSessions, ...recentMemories, ...recentOverviews];
|
const recentObjects = [...recentSessions, ...recentMemories, ...recentOverviews];
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
import { OptionValues } from 'commander';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { PathDiscovery } from '../services/path-discovery.js';
|
|
||||||
import {
|
|
||||||
createStores,
|
|
||||||
SessionInput,
|
|
||||||
MemoryInput,
|
|
||||||
OverviewInput,
|
|
||||||
DiagnosticInput,
|
|
||||||
normalizeTimestamp
|
|
||||||
} from '../services/sqlite/index.js';
|
|
||||||
|
|
||||||
interface MigrationStats {
|
|
||||||
totalLines: number;
|
|
||||||
skippedLines: number;
|
|
||||||
invalidJson: number;
|
|
||||||
sessionsCreated: number;
|
|
||||||
memoriesCreated: number;
|
|
||||||
overviewsCreated: number;
|
|
||||||
diagnosticsCreated: number;
|
|
||||||
orphanedOverviews: number;
|
|
||||||
orphanedMemories: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate claude-mem index from JSONL to SQLite
|
|
||||||
*/
|
|
||||||
export async function migrateIndex(options: OptionValues = {}): Promise<void> {
|
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
|
||||||
const indexPath = pathDiscovery.getIndexPath();
|
|
||||||
const backupPath = `${indexPath}.backup-${Date.now()}`;
|
|
||||||
|
|
||||||
console.log('🔄 Starting JSONL to SQLite migration...');
|
|
||||||
console.log(`📁 Index file: ${indexPath}`);
|
|
||||||
|
|
||||||
// Check if JSONL file exists
|
|
||||||
if (!fs.existsSync(indexPath)) {
|
|
||||||
console.log('ℹ️ No JSONL index file found - nothing to migrate');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Initialize SQLite database and stores
|
|
||||||
console.log('🏗️ Initializing SQLite database...');
|
|
||||||
const stores = await createStores();
|
|
||||||
|
|
||||||
// Check if we already have data in SQLite
|
|
||||||
const existingSessions = stores.sessions.count();
|
|
||||||
if (existingSessions > 0 && !options.force) {
|
|
||||||
console.log(`⚠️ SQLite database already contains ${existingSessions} sessions.`);
|
|
||||||
console.log(' Use --force to migrate anyway (will skip duplicates)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create backup of JSONL file
|
|
||||||
if (!options.keepJsonl) {
|
|
||||||
console.log(`💾 Creating backup: ${path.basename(backupPath)}`);
|
|
||||||
fs.copyFileSync(indexPath, backupPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and parse JSONL file
|
|
||||||
console.log('📖 Reading JSONL index file...');
|
|
||||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
|
||||||
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
const stats: MigrationStats = {
|
|
||||||
totalLines: lines.length,
|
|
||||||
skippedLines: 0,
|
|
||||||
invalidJson: 0,
|
|
||||||
sessionsCreated: 0,
|
|
||||||
memoriesCreated: 0,
|
|
||||||
overviewsCreated: 0,
|
|
||||||
diagnosticsCreated: 0,
|
|
||||||
orphanedOverviews: 0,
|
|
||||||
orphanedMemories: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`📝 Processing ${stats.totalLines} lines...`);
|
|
||||||
|
|
||||||
// Parse all lines first
|
|
||||||
const records: any[] = [];
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Skip lines that don't look like JSON
|
|
||||||
if (!line.trim().startsWith('{')) {
|
|
||||||
stats.skippedLines++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = JSON.parse(line);
|
|
||||||
if (record && typeof record === 'object') {
|
|
||||||
records.push({ ...record, _lineNumber: i + 1 });
|
|
||||||
} else {
|
|
||||||
stats.skippedLines++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
stats.invalidJson++;
|
|
||||||
console.warn(`⚠️ Invalid JSON at line ${i + 1}: ${line.substring(0, 50)}...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ Parsed ${records.length} valid records`);
|
|
||||||
|
|
||||||
// Group records by type
|
|
||||||
const sessions = records.filter(r => r.type === 'session');
|
|
||||||
const memories = records.filter(r => r.type === 'memory');
|
|
||||||
const overviews = records.filter(r => r.type === 'overview');
|
|
||||||
const diagnostics = records.filter(r => r.type === 'diagnostic');
|
|
||||||
const unknown = records.filter(r => !['session', 'memory', 'overview', 'diagnostic'].includes(r.type));
|
|
||||||
|
|
||||||
if (unknown.length > 0) {
|
|
||||||
console.log(`⚠️ Found ${unknown.length} records with unknown types - will skip`);
|
|
||||||
stats.skippedLines += unknown.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session tracking
|
|
||||||
const sessionIds = new Set(sessions.map(s => s.session_id));
|
|
||||||
const orphanedSessionIds = new Set();
|
|
||||||
|
|
||||||
// Migrate sessions first
|
|
||||||
console.log('💾 Migrating sessions...');
|
|
||||||
for (const sessionData of sessions) {
|
|
||||||
try {
|
|
||||||
const { isoString } = normalizeTimestamp(sessionData.timestamp);
|
|
||||||
|
|
||||||
const sessionInput: SessionInput = {
|
|
||||||
session_id: sessionData.session_id,
|
|
||||||
project: sessionData.project || 'unknown',
|
|
||||||
created_at: isoString,
|
|
||||||
source: 'legacy-jsonl'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip if session already exists (when using --force)
|
|
||||||
if (!stores.sessions.has(sessionInput.session_id)) {
|
|
||||||
stores.sessions.create(sessionInput);
|
|
||||||
stats.sessionsCreated++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`⚠️ Failed to migrate session ${sessionData.session_id}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate memories
|
|
||||||
console.log('🧠 Migrating memories...');
|
|
||||||
for (const memoryData of memories) {
|
|
||||||
try {
|
|
||||||
const { isoString } = normalizeTimestamp(memoryData.timestamp);
|
|
||||||
|
|
||||||
// Check if session exists, create orphaned session if needed
|
|
||||||
if (!sessionIds.has(memoryData.session_id)) {
|
|
||||||
if (!orphanedSessionIds.has(memoryData.session_id)) {
|
|
||||||
orphanedSessionIds.add(memoryData.session_id);
|
|
||||||
|
|
||||||
const orphanedSession: SessionInput = {
|
|
||||||
session_id: memoryData.session_id,
|
|
||||||
project: memoryData.project || 'unknown',
|
|
||||||
created_at: isoString,
|
|
||||||
source: 'legacy-jsonl'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!stores.sessions.has(orphanedSession.session_id)) {
|
|
||||||
stores.sessions.create(orphanedSession);
|
|
||||||
stats.sessionsCreated++;
|
|
||||||
stats.orphanedMemories++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const memoryInput: MemoryInput = {
|
|
||||||
session_id: memoryData.session_id,
|
|
||||||
text: memoryData.text || '',
|
|
||||||
document_id: memoryData.document_id,
|
|
||||||
keywords: memoryData.keywords,
|
|
||||||
created_at: isoString,
|
|
||||||
project: memoryData.project || 'unknown',
|
|
||||||
archive_basename: memoryData.archive,
|
|
||||||
origin: 'transcript'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip duplicate document_ids
|
|
||||||
if (!memoryInput.document_id || !stores.memories.hasDocumentId(memoryInput.document_id)) {
|
|
||||||
stores.memories.create(memoryInput);
|
|
||||||
stats.memoriesCreated++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`⚠️ Failed to migrate memory ${memoryData.document_id}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate overviews
|
|
||||||
console.log('📋 Migrating overviews...');
|
|
||||||
for (const overviewData of overviews) {
|
|
||||||
try {
|
|
||||||
const { isoString } = normalizeTimestamp(overviewData.timestamp);
|
|
||||||
|
|
||||||
// Check if session exists, create orphaned session if needed
|
|
||||||
if (!sessionIds.has(overviewData.session_id)) {
|
|
||||||
if (!orphanedSessionIds.has(overviewData.session_id)) {
|
|
||||||
orphanedSessionIds.add(overviewData.session_id);
|
|
||||||
|
|
||||||
const orphanedSession: SessionInput = {
|
|
||||||
session_id: overviewData.session_id,
|
|
||||||
project: overviewData.project || 'unknown',
|
|
||||||
created_at: isoString,
|
|
||||||
source: 'legacy-jsonl'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!stores.sessions.has(orphanedSession.session_id)) {
|
|
||||||
stores.sessions.create(orphanedSession);
|
|
||||||
stats.sessionsCreated++;
|
|
||||||
stats.orphanedOverviews++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const overviewInput: OverviewInput = {
|
|
||||||
session_id: overviewData.session_id,
|
|
||||||
content: overviewData.content || '',
|
|
||||||
created_at: isoString,
|
|
||||||
project: overviewData.project || 'unknown',
|
|
||||||
origin: 'claude'
|
|
||||||
};
|
|
||||||
|
|
||||||
stores.overviews.upsert(overviewInput);
|
|
||||||
stats.overviewsCreated++;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`⚠️ Failed to migrate overview ${overviewData.session_id}: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate diagnostics
|
|
||||||
console.log('🩺 Migrating diagnostics...');
|
|
||||||
for (const diagnosticData of diagnostics) {
|
|
||||||
try {
|
|
||||||
const { isoString } = normalizeTimestamp(diagnosticData.timestamp);
|
|
||||||
|
|
||||||
const diagnosticInput: DiagnosticInput = {
|
|
||||||
session_id: diagnosticData.session_id,
|
|
||||||
message: diagnosticData.message || '',
|
|
||||||
severity: 'warn',
|
|
||||||
created_at: isoString,
|
|
||||||
project: diagnosticData.project || 'unknown',
|
|
||||||
origin: 'compressor'
|
|
||||||
};
|
|
||||||
|
|
||||||
stores.diagnostics.create(diagnosticInput);
|
|
||||||
stats.diagnosticsCreated++;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`⚠️ Failed to migrate diagnostic: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print migration summary
|
|
||||||
console.log('\n✅ Migration completed successfully!');
|
|
||||||
console.log('\n📊 Migration Summary:');
|
|
||||||
console.log(` Total lines processed: ${stats.totalLines}`);
|
|
||||||
console.log(` Skipped lines: ${stats.skippedLines}`);
|
|
||||||
console.log(` Invalid JSON lines: ${stats.invalidJson}`);
|
|
||||||
console.log(` Sessions created: ${stats.sessionsCreated}`);
|
|
||||||
console.log(` Memories created: ${stats.memoriesCreated}`);
|
|
||||||
console.log(` Overviews created: ${stats.overviewsCreated}`);
|
|
||||||
console.log(` Diagnostics created: ${stats.diagnosticsCreated}`);
|
|
||||||
|
|
||||||
if (stats.orphanedOverviews > 0 || stats.orphanedMemories > 0) {
|
|
||||||
console.log(` Orphaned records (sessions synthesized): ${stats.orphanedOverviews + stats.orphanedMemories}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archive or keep JSONL file
|
|
||||||
if (options.keepJsonl) {
|
|
||||||
console.log(`\n💾 Original JSONL file preserved: ${indexPath}`);
|
|
||||||
console.log(` SQLite database is now the primary index`);
|
|
||||||
} else {
|
|
||||||
const archiveDir = path.join(pathDiscovery.getDataDirectory(), 'archive', 'legacy');
|
|
||||||
fs.mkdirSync(archiveDir, { recursive: true });
|
|
||||||
|
|
||||||
const archivedPath = path.join(archiveDir, `claude-mem-index-${Date.now()}.jsonl`);
|
|
||||||
fs.renameSync(indexPath, archivedPath);
|
|
||||||
|
|
||||||
console.log(`\n📦 Original JSONL file archived: ${path.basename(archivedPath)}`);
|
|
||||||
console.log(` Backup available at: ${path.basename(backupPath)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n🎉 Migration complete! You can now use claude-mem with SQLite backend.');
|
|
||||||
console.log(' Run `claude-mem load-context` to verify the migration worked.');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n❌ Migration failed:', error);
|
|
||||||
|
|
||||||
// Restore backup if we created one
|
|
||||||
if (fs.existsSync(backupPath) && !fs.existsSync(indexPath)) {
|
|
||||||
console.log('🔄 Restoring backup...');
|
|
||||||
fs.renameSync(backupPath, indexPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { OptionValues } from 'commander';
|
|
||||||
import { appendFileSync } from 'fs';
|
|
||||||
import { PathDiscovery } from '../services/path-discovery.js';
|
|
||||||
import { getStorageProvider, needsMigration } from '../shared/storage.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a descriptive session ID from the message content
|
|
||||||
* Takes first few meaningful words and creates a readable identifier
|
|
||||||
*/
|
|
||||||
function generateSessionId(message: string): string {
|
|
||||||
// Remove punctuation and split into words
|
|
||||||
const words = message
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(word => word.length > 2); // Skip short words like 'a', 'is', 'to'
|
|
||||||
|
|
||||||
// Take first 3-4 meaningful words, max 30 chars
|
|
||||||
const sessionWords = words.slice(0, 4).join('-');
|
|
||||||
const truncated = sessionWords.length > 30 ? sessionWords.substring(0, 27) + '...' : sessionWords;
|
|
||||||
|
|
||||||
// Add timestamp suffix to ensure uniqueness
|
|
||||||
const timestamp = new Date().toISOString().substring(11, 19).replace(/:/g, '');
|
|
||||||
|
|
||||||
return `${truncated}-${timestamp}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save command - stores a message using the configured storage provider
|
|
||||||
*/
|
|
||||||
export async function save(message: string, options: OptionValues = {}): Promise<void> {
|
|
||||||
// Debug: Log what we receive
|
|
||||||
appendFileSync('/Users/alexnewman/.claude-mem/save-debug.log',
|
|
||||||
`[${new Date().toISOString()}] Received message: "${message}" (type: ${typeof message}, length: ${message?.length})\n`,
|
|
||||||
'utf8');
|
|
||||||
|
|
||||||
if (!message || message.trim() === '') {
|
|
||||||
console.error('Error: Message is required');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const projectName = PathDiscovery.getCurrentProjectName();
|
|
||||||
const sessionId = generateSessionId(message);
|
|
||||||
const documentId = `${projectName}_${sessionId}_overview`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if migration is needed
|
|
||||||
if (await needsMigration()) {
|
|
||||||
console.warn('⚠️ JSONL to SQLite migration recommended. Run: claude-mem migrate-index');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get storage provider (SQLite preferred, JSONL fallback)
|
|
||||||
const storage = await getStorageProvider();
|
|
||||||
|
|
||||||
// Ensure session exists or create it
|
|
||||||
if (!await storage.hasSession(sessionId)) {
|
|
||||||
await storage.createSession({
|
|
||||||
session_id: sessionId,
|
|
||||||
project: projectName,
|
|
||||||
created_at: timestamp,
|
|
||||||
source: 'save'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert the overview
|
|
||||||
await storage.upsertOverview({
|
|
||||||
session_id: sessionId,
|
|
||||||
content: message,
|
|
||||||
created_at: timestamp,
|
|
||||||
project: projectName,
|
|
||||||
origin: 'manual'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return JSON response for hook compatibility
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
document_id: documentId,
|
|
||||||
session_id: sessionId,
|
|
||||||
project: projectName,
|
|
||||||
timestamp: timestamp,
|
|
||||||
backend: storage.backend,
|
|
||||||
suppressOutput: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving message:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+56
-19
@@ -3,6 +3,9 @@ import { join, resolve, dirname } from 'path';
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { PathDiscovery } from '../services/path-discovery.js';
|
import { PathDiscovery } from '../services/path-discovery.js';
|
||||||
|
import { DatabaseManager } from '../services/sqlite/Database.js';
|
||||||
|
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -10,12 +13,13 @@ export async function status(): Promise<void> {
|
|||||||
console.log('🔍 Claude Memory System Status Check');
|
console.log('🔍 Claude Memory System Status Check');
|
||||||
console.log('=====================================\n');
|
console.log('=====================================\n');
|
||||||
|
|
||||||
console.log('📂 Installed Hook Scripts:');
|
console.log('📂 Runtime Hook Scripts (installed from hook-templates/):');
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
const pathDiscovery = PathDiscovery.getInstance();
|
||||||
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
|
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||||
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
|
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
|
||||||
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
|
const stopScript = join(runtimeHooksDir, 'stop.js');
|
||||||
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
|
const userPromptScript = join(runtimeHooksDir, 'user-prompt-submit.js');
|
||||||
|
const postToolScript = join(runtimeHooksDir, 'post-tool-use.js');
|
||||||
|
|
||||||
const checkScript = (path: string, name: string) => {
|
const checkScript = (path: string, name: string) => {
|
||||||
if (existsSync(path)) {
|
if (existsSync(path)) {
|
||||||
@@ -25,9 +29,10 @@ export async function status(): Promise<void> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkScript(preCompactScript, 'pre-compact.js');
|
|
||||||
checkScript(sessionStartScript, 'session-start.js');
|
checkScript(sessionStartScript, 'session-start.js');
|
||||||
checkScript(sessionEndScript, 'session-end.js');
|
checkScript(stopScript, 'stop.js');
|
||||||
|
checkScript(userPromptScript, 'user-prompt-submit.js');
|
||||||
|
checkScript(postToolScript, 'post-tool-use.js');
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -44,27 +49,34 @@ export async function status(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const settings = JSON.parse(readFileSync(path, 'utf8'));
|
const settings = JSON.parse(readFileSync(path, 'utf8'));
|
||||||
|
|
||||||
const hasPreCompact = settings.hooks?.PreCompact?.some((matcher: any) =>
|
|
||||||
matcher.hooks?.some((hook: any) =>
|
|
||||||
hook.command?.includes('pre-compact.js') || hook.command?.includes('claude-mem')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSessionStart = settings.hooks?.SessionStart?.some((matcher: any) =>
|
const hasSessionStart = settings.hooks?.SessionStart?.some((matcher: any) =>
|
||||||
matcher.hooks?.some((hook: any) =>
|
matcher.hooks?.some((hook: any) =>
|
||||||
hook.command?.includes('session-start.js') || hook.command?.includes('claude-mem')
|
hook.command?.includes('session-start.js') || hook.command?.includes('claude-mem')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSessionEnd = settings.hooks?.SessionEnd?.some((matcher: any) =>
|
const hasStop = settings.hooks?.Stop?.some((matcher: any) =>
|
||||||
matcher.hooks?.some((hook: any) =>
|
matcher.hooks?.some((hook: any) =>
|
||||||
hook.command?.includes('session-end.js') || hook.command?.includes('claude-mem')
|
hook.command?.includes('stop.js') || hook.command?.includes('claude-mem')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasUserPrompt = settings.hooks?.UserPromptSubmit?.some((matcher: any) =>
|
||||||
|
matcher.hooks?.some((hook: any) =>
|
||||||
|
hook.command?.includes('user-prompt-submit.js') || hook.command?.includes('claude-mem')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasPostTool = settings.hooks?.PostToolUse?.some((matcher: any) =>
|
||||||
|
matcher.hooks?.some((hook: any) =>
|
||||||
|
hook.command?.includes('post-tool-use.js') || hook.command?.includes('claude-mem')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(` PreCompact: ${hasPreCompact ? '✅' : '❌'}`);
|
|
||||||
console.log(` SessionStart: ${hasSessionStart ? '✅' : '❌'}`);
|
console.log(` SessionStart: ${hasSessionStart ? '✅' : '❌'}`);
|
||||||
console.log(` SessionEnd: ${hasSessionEnd ? '✅' : '❌'}`);
|
console.log(` Stop: ${hasStop ? '✅' : '❌'}`);
|
||||||
|
console.log(` UserPromptSubmit: ${hasUserPrompt ? '✅' : '❌'}`);
|
||||||
|
console.log(` PostToolUse: ${hasPostTool ? '✅' : '❌'}`);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(` ⚠️ Could not parse settings`);
|
console.log(` ⚠️ Could not parse settings`);
|
||||||
@@ -139,6 +151,31 @@ export async function status(): Promise<void> {
|
|||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
console.log('🤖 Claude Agent SDK Sessions:');
|
||||||
|
try {
|
||||||
|
const dbManager = DatabaseManager.getInstance();
|
||||||
|
await dbManager.initialize();
|
||||||
|
const sessionStore = new SessionStore();
|
||||||
|
const sessions = sessionStore.getAll();
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log(chalk.gray(' No active sessions'));
|
||||||
|
} else {
|
||||||
|
const activeCount = sessions.filter(s => {
|
||||||
|
const daysSinceUse = (Date.now() - s.last_used_epoch) / (1000 * 60 * 60 * 24);
|
||||||
|
return daysSinceUse < 7;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
console.log(` 📊 Total sessions: ${sessions.length}`);
|
||||||
|
console.log(` ✅ Active (< 7 days): ${activeCount}`);
|
||||||
|
console.log(chalk.dim(` 💡 View details: claude-mem sessions list`));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(chalk.gray(' ⚠️ Could not load session info'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
console.log('📊 Summary:');
|
console.log('📊 Summary:');
|
||||||
const globalPath = pathDiscovery.getClaudeSettingsPath();
|
const globalPath = pathDiscovery.getClaudeSettingsPath();
|
||||||
const projectPath = join(process.cwd(), '.claude', 'settings.json');
|
const projectPath = join(process.cwd(), '.claude', 'settings.json');
|
||||||
@@ -149,7 +186,7 @@ export async function status(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
if (existsSync(globalPath)) {
|
if (existsSync(globalPath)) {
|
||||||
const settings = JSON.parse(readFileSync(globalPath, 'utf8'));
|
const settings = JSON.parse(readFileSync(globalPath, 'utf8'));
|
||||||
if (settings.hooks?.PreCompact || settings.hooks?.SessionStart || settings.hooks?.SessionEnd) {
|
if (settings.hooks?.SessionStart || settings.hooks?.Stop || settings.hooks?.PostToolUse) {
|
||||||
isInstalled = true;
|
isInstalled = true;
|
||||||
installLocation = 'Global';
|
installLocation = 'Global';
|
||||||
}
|
}
|
||||||
@@ -157,7 +194,7 @@ export async function status(): Promise<void> {
|
|||||||
|
|
||||||
if (existsSync(projectPath)) {
|
if (existsSync(projectPath)) {
|
||||||
const settings = JSON.parse(readFileSync(projectPath, 'utf8'));
|
const settings = JSON.parse(readFileSync(projectPath, 'utf8'));
|
||||||
if (settings.hooks?.PreCompact || settings.hooks?.SessionStart || settings.hooks?.SessionEnd) {
|
if (settings.hooks?.SessionStart || settings.hooks?.Stop || settings.hooks?.PostToolUse) {
|
||||||
isInstalled = true;
|
isInstalled = true;
|
||||||
installLocation = installLocation === 'Global' ? 'Global + Project' : 'Project';
|
installLocation = installLocation === 'Global' ? 'Global + Project' : 'Project';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { OptionValues } from 'commander';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import { createStores } from '../services/sqlite/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a memory to all three storage layers
|
||||||
|
* Called by SDK via bash during streaming memory capture
|
||||||
|
*/
|
||||||
|
export async function storeMemory(options: OptionValues): Promise<void> {
|
||||||
|
const { id, project, session, date, title, subtitle, facts, concepts, files } = options;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!id || !project || !session || !date) {
|
||||||
|
console.error('Error: All fields required: --id, --project, --session, --date');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate hierarchical fields (required for v2 format)
|
||||||
|
if (!title || !subtitle || !facts) {
|
||||||
|
console.error('Error: Hierarchical format required: --title, --subtitle, --facts');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stores = await createStores();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Ensure session exists
|
||||||
|
const sessionExists = await stores.sessions.has(session);
|
||||||
|
if (!sessionExists) {
|
||||||
|
await stores.sessions.create({
|
||||||
|
session_id: session,
|
||||||
|
project,
|
||||||
|
created_at: timestamp,
|
||||||
|
source: 'save'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON arrays if provided as strings
|
||||||
|
let factsArray: string | undefined;
|
||||||
|
let conceptsArray: string | undefined;
|
||||||
|
let filesArray: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
factsArray = facts ? JSON.stringify(JSON.parse(facts)) : undefined;
|
||||||
|
} catch (e) {
|
||||||
|
factsArray = facts; // Store as-is if not valid JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
conceptsArray = concepts ? JSON.stringify(JSON.parse(concepts)) : undefined;
|
||||||
|
} catch (e) {
|
||||||
|
conceptsArray = concepts; // Store as-is if not valid JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
filesArray = files ? JSON.stringify(JSON.parse(files)) : undefined;
|
||||||
|
} catch (e) {
|
||||||
|
filesArray = files; // Store as-is if not valid JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 1: SQLite Memory Index
|
||||||
|
const memoryExists = stores.memories.hasDocumentId(id);
|
||||||
|
if (!memoryExists) {
|
||||||
|
stores.memories.create({
|
||||||
|
document_id: id,
|
||||||
|
text: '', // Deprecated: hierarchical fields replace narrative text
|
||||||
|
keywords: '',
|
||||||
|
session_id: session,
|
||||||
|
project,
|
||||||
|
created_at: timestamp,
|
||||||
|
origin: 'streaming-sdk',
|
||||||
|
// Hierarchical fields (v2)
|
||||||
|
title: title || undefined,
|
||||||
|
subtitle: subtitle || undefined,
|
||||||
|
facts: factsArray,
|
||||||
|
concepts: conceptsArray,
|
||||||
|
files_touched: filesArray
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: ChromaDB - Store hierarchical memory
|
||||||
|
if (factsArray) {
|
||||||
|
const factsJson = JSON.parse(factsArray);
|
||||||
|
const conceptsJson = conceptsArray ? JSON.parse(conceptsArray) : [];
|
||||||
|
const filesJson = filesArray ? JSON.parse(filesArray) : [];
|
||||||
|
|
||||||
|
// Store each atomic fact as a separate ChromaDB document
|
||||||
|
factsJson.forEach((fact: string, idx: number) => {
|
||||||
|
spawnSync('claude-mem', [
|
||||||
|
'chroma_add_documents',
|
||||||
|
'--collection_name', 'claude_memories',
|
||||||
|
'--documents', JSON.stringify([fact]),
|
||||||
|
'--ids', JSON.stringify([`${id}_fact_${String(idx).padStart(3, '0')}`]),
|
||||||
|
'--metadatas', JSON.stringify([{
|
||||||
|
type: 'fact',
|
||||||
|
parent_id: id,
|
||||||
|
fact_index: idx,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
project,
|
||||||
|
session_id: session,
|
||||||
|
created_at: timestamp,
|
||||||
|
created_at_epoch: Date.parse(timestamp),
|
||||||
|
keywords: '',
|
||||||
|
concepts: JSON.stringify(conceptsJson),
|
||||||
|
files_touched: JSON.stringify(filesJson),
|
||||||
|
origin: 'streaming-sdk'
|
||||||
|
}])
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store full narrative with hierarchical metadata
|
||||||
|
spawnSync('claude-mem', [
|
||||||
|
'chroma_add_documents',
|
||||||
|
'--collection_name', 'claude_memories',
|
||||||
|
'--documents', JSON.stringify([`${title}\n${subtitle}\n\n${factsJson.join('\n')}`]),
|
||||||
|
'--ids', JSON.stringify([id]),
|
||||||
|
'--metadatas', JSON.stringify([{
|
||||||
|
type: 'narrative',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
facts_count: factsJson.length,
|
||||||
|
project,
|
||||||
|
session_id: session,
|
||||||
|
created_at: timestamp,
|
||||||
|
created_at_epoch: Date.parse(timestamp),
|
||||||
|
keywords: '',
|
||||||
|
concepts: JSON.stringify(conceptsJson),
|
||||||
|
files_touched: JSON.stringify(filesJson),
|
||||||
|
origin: 'streaming-sdk'
|
||||||
|
}])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success output (SDK will see this)
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
memory_id: id,
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
date,
|
||||||
|
timestamp,
|
||||||
|
hierarchical: !!(title && subtitle && facts)
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Unknown error storing memory'
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { OptionValues } from 'commander';
|
||||||
|
import { createStores } from '../services/sqlite/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a session overview
|
||||||
|
* Called by SDK via bash at session end
|
||||||
|
*/
|
||||||
|
export async function storeOverview(options: OptionValues): Promise<void> {
|
||||||
|
const { project, session, content } = options;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!project || !session || !content) {
|
||||||
|
console.error('Error: All fields required: --project, --session, --content');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stores = await createStores();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Create one overview per session (rolling log architecture)
|
||||||
|
stores.overviews.upsert({
|
||||||
|
session_id: session,
|
||||||
|
content,
|
||||||
|
created_at: timestamp,
|
||||||
|
project,
|
||||||
|
origin: 'streaming-sdk'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success output (SDK will see this)
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
timestamp
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Unknown error storing overview'
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-11
@@ -1,8 +1,69 @@
|
|||||||
import { OptionValues } from 'commander';
|
import { OptionValues } from 'commander';
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
import { PathDiscovery } from '../services/path-discovery.js';
|
import { PathDiscovery } from '../services/path-discovery.js';
|
||||||
|
|
||||||
|
async function removeSmartTrashAlias(): Promise<boolean> {
|
||||||
|
const homeDir = homedir();
|
||||||
|
const shellConfigs = [
|
||||||
|
join(homeDir, '.bashrc'),
|
||||||
|
join(homeDir, '.zshrc'),
|
||||||
|
join(homeDir, '.bash_profile')
|
||||||
|
];
|
||||||
|
|
||||||
|
const aliasLine = 'alias rm="claude-mem trash"';
|
||||||
|
// Handle both variations of the comment line
|
||||||
|
const commentPatterns = [
|
||||||
|
'# claude-mem smart trash alias',
|
||||||
|
'# claude-mem trash bin alias'
|
||||||
|
];
|
||||||
|
let removedFromAny = false;
|
||||||
|
|
||||||
|
for (const configPath of shellConfigs) {
|
||||||
|
if (!existsSync(configPath)) continue;
|
||||||
|
|
||||||
|
let content = readFileSync(configPath, 'utf8');
|
||||||
|
|
||||||
|
// Check if alias exists
|
||||||
|
if (!content.includes(aliasLine)) {
|
||||||
|
continue; // Not configured in this file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the alias and its comment
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const filteredLines = lines.filter((line, index) => {
|
||||||
|
// Skip the alias line
|
||||||
|
if (line.trim() === aliasLine) return false;
|
||||||
|
// Skip any claude-mem comment line if it's right before the alias
|
||||||
|
for (const commentPattern of commentPatterns) {
|
||||||
|
if (line.trim() === commentPattern &&
|
||||||
|
index + 1 < lines.length &&
|
||||||
|
lines[index + 1].trim() === aliasLine) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newContent = filteredLines.join('\n');
|
||||||
|
|
||||||
|
// Only write if content actually changed
|
||||||
|
if (newContent !== content) {
|
||||||
|
// Create backup
|
||||||
|
const backupPath = configPath + '.backup.' + Date.now();
|
||||||
|
writeFileSync(backupPath, content);
|
||||||
|
|
||||||
|
// Write updated content
|
||||||
|
writeFileSync(configPath, newContent);
|
||||||
|
console.log(`✅ Removed Smart Trash alias from ${configPath.replace(homeDir, '~')}`);
|
||||||
|
removedFromAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removedFromAny;
|
||||||
|
}
|
||||||
|
|
||||||
export async function uninstall(options: OptionValues = {}): Promise<void> {
|
export async function uninstall(options: OptionValues = {}): Promise<void> {
|
||||||
console.log('🔄 Uninstalling Claude Memory System hooks...');
|
console.log('🔄 Uninstalling Claude Memory System hooks...');
|
||||||
|
|
||||||
@@ -26,10 +87,10 @@ export async function uninstall(options: OptionValues = {}): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
const pathDiscovery = PathDiscovery.getInstance();
|
||||||
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
|
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||||
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
|
const preCompactScript = join(runtimeHooksDir, 'pre-compact.js');
|
||||||
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
|
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
|
||||||
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
|
const sessionEndScript = join(runtimeHooksDir, 'session-end.js');
|
||||||
|
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
|
|
||||||
@@ -39,7 +100,6 @@ export async function uninstall(options: OptionValues = {}): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const content = readFileSync(location.path, 'utf8');
|
const content = readFileSync(location.path, 'utf8');
|
||||||
const settings = JSON.parse(content);
|
const settings = JSON.parse(content);
|
||||||
|
|
||||||
@@ -114,20 +174,25 @@ export async function uninstall(options: OptionValues = {}): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
console.log(`ℹ️ No Claude Memory System hooks found in ${location.name} settings`);
|
console.log(`ℹ️ No Claude Memory System hooks found in ${location.name} settings`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
// Remove Smart Trash alias from shell configs
|
||||||
console.log(`⚠️ Could not process ${location.name} settings: ${error.message}`);
|
const removedAlias = await removeSmartTrashAlias();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0 || removedAlias) {
|
||||||
console.log('✨ Uninstallation complete!');
|
console.log('✨ Uninstallation complete!');
|
||||||
|
if (removedCount > 0) {
|
||||||
console.log('The Claude Memory System hooks have been removed from your settings.');
|
console.log('The Claude Memory System hooks have been removed from your settings.');
|
||||||
|
}
|
||||||
|
if (removedAlias) {
|
||||||
|
console.log('The Smart Trash alias has been removed from your shell configuration.');
|
||||||
|
console.log('⚠️ Restart your terminal for the alias removal to take effect.');
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Note: Your compressed transcripts and archives are preserved.');
|
console.log('Note: Your compressed transcripts and archives are preserved.');
|
||||||
console.log('To reinstall: claude-mem install');
|
console.log('To reinstall: claude-mem install');
|
||||||
} else {
|
} else {
|
||||||
console.log('ℹ️ No Claude Memory System hooks were found to remove.');
|
console.log('ℹ️ No Claude Memory System hooks or aliases were found to remove.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { OptionValues } from 'commander';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update session metadata (title/subtitle) in the streaming session JSON file
|
||||||
|
* Called by SDK when generating session title at the start
|
||||||
|
*/
|
||||||
|
export async function updateSessionMetadata(options: OptionValues): Promise<void> {
|
||||||
|
const { project, session, title, subtitle } = options;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!project || !session) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required fields: --project, --session'
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required field: --title'
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load existing session file
|
||||||
|
const sessionFile = path.join(SESSION_DIR, `${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;
|
||||||
|
if (subtitle) {
|
||||||
|
sessionData.promptSubtitle = subtitle;
|
||||||
|
}
|
||||||
|
sessionData.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
|
||||||
|
|
||||||
|
// Output success
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
title,
|
||||||
|
subtitle: subtitle || null,
|
||||||
|
project,
|
||||||
|
session
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Unknown error updating session metadata'
|
||||||
|
}));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-182
@@ -1,92 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Claude Memory System - Core Constants
|
* Claude Memory System - Core Constants
|
||||||
*
|
*
|
||||||
* This file contains core application constants, CLI messages,
|
* This file contains debug logging templates used throughout the application.
|
||||||
* configuration templates, and infrastructure-related constants.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CONFIGURATION TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook configuration templates for Claude settings
|
|
||||||
*/
|
|
||||||
export const HOOK_CONFIG_TEMPLATES = {
|
|
||||||
PRE_COMPACT: (scriptPath: string) => ({
|
|
||||||
pattern: "*",
|
|
||||||
hooks: [{
|
|
||||||
type: "command",
|
|
||||||
command: scriptPath,
|
|
||||||
timeout: 180
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
|
|
||||||
SESSION_START: (scriptPath: string) => ({
|
|
||||||
pattern: "*",
|
|
||||||
hooks: [{
|
|
||||||
type: "command",
|
|
||||||
command: scriptPath,
|
|
||||||
timeout: 30
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
|
|
||||||
SESSION_END: (scriptPath: string) => ({
|
|
||||||
pattern: "*",
|
|
||||||
hooks: [{
|
|
||||||
type: "command",
|
|
||||||
command: scriptPath,
|
|
||||||
timeout: 180
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CLI MESSAGES AND STATUS TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Command-line interface messages
|
|
||||||
*/
|
|
||||||
export const CLI_MESSAGES = {
|
|
||||||
INSTALLATION: {
|
|
||||||
STARTING: '🚀 Installing Claude Memory System with Chroma...',
|
|
||||||
SUCCESS: '🎉 Installation complete! Vector database ready.',
|
|
||||||
HOOKS_INSTALLED: '✅ Installed hooks to ~/.claude-mem/hooks/',
|
|
||||||
MCP_CONFIGURED: (path: string) => `✅ Configured MCP memory server in ${path}`,
|
|
||||||
EMBEDDED_READY: '🧠 Chroma initialized for persistent semantic memory',
|
|
||||||
ALREADY_INSTALLED: '⚠️ Claude Memory hooks are already installed.',
|
|
||||||
USE_FORCE: ' Use --force to overwrite existing installation.',
|
|
||||||
SETTINGS_WRITTEN: (type: string, path: string) =>
|
|
||||||
`✅ Installed hooks in ${type} settings\n Settings file: ${path}`
|
|
||||||
},
|
|
||||||
|
|
||||||
NEXT_STEPS: [
|
|
||||||
'1. Restart Claude Code to load the new hooks',
|
|
||||||
'2. Use `/clear` and `/compact` in Claude Code to save and compress session memories',
|
|
||||||
'3. New sessions will automatically load relevant context'
|
|
||||||
],
|
|
||||||
|
|
||||||
ERRORS: {
|
|
||||||
HOOKS_NOT_FOUND: '❌ Hook source files not found',
|
|
||||||
SETTINGS_WRITE_FAILED: (path: string, error: string) =>
|
|
||||||
`❌ Failed to write settings file: ${error}\n Path: ${path}`,
|
|
||||||
MCP_CONFIG_PARSE_FAILED: (error: string) =>
|
|
||||||
`⚠️ Warning: Could not parse existing MCP config: ${error}`,
|
|
||||||
MCP_CONFIG_WRITE_FAILED: (error: string) =>
|
|
||||||
`⚠️ Warning: Could not write MCP config: ${error}`,
|
|
||||||
COMPRESSION_FAILED: (error: string) => `❌ Compression failed: ${error}`,
|
|
||||||
CONTEXT_LOAD_FAILED: (error: string) => `❌ Failed to load context: ${error}`
|
|
||||||
},
|
|
||||||
|
|
||||||
STATUS: {
|
|
||||||
NO_INDEX: '📚 No memory index found. Starting fresh session.',
|
|
||||||
RECENT_MEMORIES: '🧠 Recent memories from previous sessions:',
|
|
||||||
MEMORY_COUNT: (count: number) => `📚 Showing ${count} most recent memories`,
|
|
||||||
FULL_CONTEXT_AVAILABLE: '💡 Full context available via MCP memory tools'
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// DEBUG AND LOGGING TEMPLATES
|
// DEBUG AND LOGGING TEMPLATES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -106,101 +23,3 @@ export const DEBUG_MESSAGES = {
|
|||||||
CLAUDE_PATH_FOUND: (path: string) => `🎯 Found Claude Code at: ${path}`,
|
CLAUDE_PATH_FOUND: (path: string) => `🎯 Found Claude Code at: ${path}`,
|
||||||
MCP_CONFIG_USED: (path: string) => `📋 Using MCP config: ${path}`
|
MCP_CONFIG_USED: (path: string) => `📋 Using MCP config: ${path}`
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// SEARCH AND QUERY TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memory database search templates
|
|
||||||
*/
|
|
||||||
export const SEARCH_TEMPLATES = {
|
|
||||||
SEARCH_SCRIPT: (query: string) => `
|
|
||||||
import { query } from "@anthropic-ai/claude-code";
|
|
||||||
|
|
||||||
const searchQuery = process.env.SEARCH_QUERY || '';
|
|
||||||
|
|
||||||
const result = await query({
|
|
||||||
prompt: "Search for: " + searchQuery,
|
|
||||||
options: {
|
|
||||||
mcpConfig: "~/.claude/.mcp.json",
|
|
||||||
allowedTools: ["mcp__claude-mem__chroma_query_documents"],
|
|
||||||
outputFormat: "json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
|
|
||||||
SEARCH_PREFIX: "Search for: "
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CHROMA INTEGRATION CONSTANTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chroma collection names for documents
|
|
||||||
*/
|
|
||||||
export const CHROMA_COLLECTIONS = {
|
|
||||||
DOCUMENTS: 'claude_mem_documents',
|
|
||||||
MEMORIES: 'claude_mem_memories'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default Chroma configuration values
|
|
||||||
*/
|
|
||||||
export const CHROMA_DEFAULTS = {
|
|
||||||
HOST: 'localhost:8000',
|
|
||||||
COLLECTION: 'claude_mem_documents'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chroma-specific CLI messages
|
|
||||||
*/
|
|
||||||
export const CHROMA_MESSAGES = {
|
|
||||||
CONNECTION: {
|
|
||||||
CONNECTING: '🔗 Connecting to Chroma server...',
|
|
||||||
CONNECTED: '✅ Connected to Chroma successfully',
|
|
||||||
FAILED: (error: string) => `❌ Failed to connect to Chroma: ${error}`,
|
|
||||||
DISCONNECTED: '👋 Disconnected from Chroma'
|
|
||||||
},
|
|
||||||
|
|
||||||
SEARCH: {
|
|
||||||
SEMANTIC_SEARCH: '🧠 Using semantic search with Chroma...',
|
|
||||||
KEYWORD_SEARCH: '🔍 Using keyword search with Chroma...',
|
|
||||||
HYBRID_SEARCH: '🔬 Using hybrid search with Chroma...',
|
|
||||||
RESULTS_FOUND: (count: number) => `📊 Found ${count} results in Chroma`
|
|
||||||
},
|
|
||||||
|
|
||||||
SETUP: {
|
|
||||||
STARTING_CHROMA: '🚀 Starting Chroma instance...',
|
|
||||||
CHROMA_READY: '✅ Chroma is ready and accepting connections',
|
|
||||||
INITIALIZING_COLLECTIONS: '📋 Initializing document collections...'
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chroma error messages
|
|
||||||
*/
|
|
||||||
export const CHROMA_ERRORS = {
|
|
||||||
CONNECTION_FAILED: 'Could not establish connection to Chroma server',
|
|
||||||
MCP_SERVER_NOT_FOUND: 'Chroma MCP server not found',
|
|
||||||
INVALID_COLLECTION: (collection: string) => `Invalid Chroma collection: ${collection}`,
|
|
||||||
QUERY_FAILED: (query: string, error: string) => `Query failed for '${query}': ${error}`,
|
|
||||||
DOCUMENT_CREATION_FAILED: (id: string) => `Failed to create document '${id}' in Chroma`,
|
|
||||||
COLLECTION_CREATION_FAILED: (name: string) => `Failed to create collection '${name}' in Chroma`
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export all core constants for easy importing
|
|
||||||
*/
|
|
||||||
export const CONSTANTS = {
|
|
||||||
HOOK_CONFIG_TEMPLATES,
|
|
||||||
CLI_MESSAGES,
|
|
||||||
DEBUG_MESSAGES,
|
|
||||||
SEARCH_TEMPLATES,
|
|
||||||
// Chroma constants
|
|
||||||
CHROMA_COLLECTIONS,
|
|
||||||
CHROMA_DEFAULTS,
|
|
||||||
CHROMA_MESSAGES,
|
|
||||||
CHROMA_ERRORS
|
|
||||||
} as const;
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
/**
|
|
||||||
* ChunkManager - Handles intelligent chunking of large transcripts
|
|
||||||
*
|
|
||||||
* This class manages the splitting of large filtered transcripts into chunks
|
|
||||||
* that fit within Claude's 32k token limit while preserving conversation context
|
|
||||||
* and maintaining message integrity.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ChunkMetadata {
|
|
||||||
chunkNumber: number;
|
|
||||||
totalChunks: number;
|
|
||||||
startIndex: number;
|
|
||||||
endIndex: number;
|
|
||||||
messageCount: number;
|
|
||||||
estimatedTokens: number;
|
|
||||||
sizeBytes: number;
|
|
||||||
hasOverlap: boolean;
|
|
||||||
overlapMessages?: number;
|
|
||||||
firstTimestamp?: string;
|
|
||||||
lastTimestamp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChunkingOptions {
|
|
||||||
maxTokensPerChunk?: number; // default: 28000 (leaving 4k buffer)
|
|
||||||
maxBytesPerChunk?: number; // default: 98000 (98KB)
|
|
||||||
preserveContext?: boolean; // keep context overlap between chunks
|
|
||||||
contextOverlap?: number; // messages to repeat (default: 2)
|
|
||||||
parallel?: boolean; // process chunks in parallel
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChunkedMessage {
|
|
||||||
content: string;
|
|
||||||
estimatedTokens: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChunkManager {
|
|
||||||
private static readonly DEFAULT_MAX_TOKENS = 22400; // Reduced by 20% from 28000
|
|
||||||
private static readonly DEFAULT_MAX_BYTES = 78400; // Reduced by 20% from 98000
|
|
||||||
private static readonly DEFAULT_CONTEXT_OVERLAP = 2;
|
|
||||||
private static readonly CHARS_PER_TOKEN_ESTIMATE = 3.5;
|
|
||||||
|
|
||||||
private options: Required<ChunkingOptions>;
|
|
||||||
|
|
||||||
constructor(options: ChunkingOptions = {}) {
|
|
||||||
this.options = {
|
|
||||||
maxTokensPerChunk: options.maxTokensPerChunk ?? ChunkManager.DEFAULT_MAX_TOKENS,
|
|
||||||
maxBytesPerChunk: options.maxBytesPerChunk ?? ChunkManager.DEFAULT_MAX_BYTES,
|
|
||||||
preserveContext: options.preserveContext ?? true,
|
|
||||||
contextOverlap: options.contextOverlap ?? ChunkManager.DEFAULT_CONTEXT_OVERLAP,
|
|
||||||
parallel: options.parallel ?? false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates token count for a given text
|
|
||||||
* Uses rough approximation of 3.5 characters per token
|
|
||||||
*/
|
|
||||||
public estimateTokenCount(text: string): number {
|
|
||||||
return Math.ceil(text.length / ChunkManager.CHARS_PER_TOKEN_ESTIMATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the filtered output format into structured messages
|
|
||||||
* Format: "- content"
|
|
||||||
*/
|
|
||||||
public parseFilteredOutput(filteredContent: string): ChunkedMessage[] {
|
|
||||||
const lines = filteredContent.split('\n').filter(line => line.trim());
|
|
||||||
const messages: ChunkedMessage[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Parse format: "- content"
|
|
||||||
if (line.startsWith('- ')) {
|
|
||||||
const content = line.substring(2); // Remove "- " prefix
|
|
||||||
messages.push({
|
|
||||||
content,
|
|
||||||
estimatedTokens: this.estimateTokenCount(content)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chunks the filtered transcript into manageable pieces
|
|
||||||
*/
|
|
||||||
public chunkTranscript(filteredContent: string): Array<{ content: string; metadata: ChunkMetadata }> {
|
|
||||||
const messages = this.parseFilteredOutput(filteredContent);
|
|
||||||
const chunks: Array<{ content: string; metadata: ChunkMetadata }> = [];
|
|
||||||
|
|
||||||
let currentChunk: ChunkedMessage[] = [];
|
|
||||||
let currentTokens = 0;
|
|
||||||
let currentBytes = 0;
|
|
||||||
let chunkStartIndex = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
const message = messages[i];
|
|
||||||
const messageText = this.formatMessage(message);
|
|
||||||
const messageBytes = Buffer.byteLength(messageText, 'utf8');
|
|
||||||
const messageTokens = message.estimatedTokens;
|
|
||||||
|
|
||||||
// Check if adding this message would exceed limits
|
|
||||||
if (currentChunk.length > 0 &&
|
|
||||||
(currentTokens + messageTokens > this.options.maxTokensPerChunk ||
|
|
||||||
currentBytes + messageBytes > this.options.maxBytesPerChunk)) {
|
|
||||||
|
|
||||||
// Save current chunk
|
|
||||||
const chunkContent = this.formatChunk(currentChunk);
|
|
||||||
chunks.push({
|
|
||||||
content: chunkContent,
|
|
||||||
metadata: {
|
|
||||||
chunkNumber: chunks.length + 1,
|
|
||||||
totalChunks: 0, // Will be updated after all chunks are created
|
|
||||||
startIndex: chunkStartIndex,
|
|
||||||
endIndex: i - 1,
|
|
||||||
messageCount: currentChunk.length,
|
|
||||||
estimatedTokens: currentTokens,
|
|
||||||
sizeBytes: currentBytes,
|
|
||||||
hasOverlap: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start new chunk with optional context overlap
|
|
||||||
currentChunk = [];
|
|
||||||
currentTokens = 0;
|
|
||||||
currentBytes = 0;
|
|
||||||
chunkStartIndex = i;
|
|
||||||
|
|
||||||
// Add overlap messages from previous chunk if enabled
|
|
||||||
if (this.options.preserveContext && chunks.length > 0) {
|
|
||||||
const overlapStart = Math.max(0, i - this.options.contextOverlap);
|
|
||||||
for (let j = overlapStart; j < i; j++) {
|
|
||||||
const overlapMessage = messages[j];
|
|
||||||
const overlapText = this.formatMessage(overlapMessage);
|
|
||||||
currentChunk.push(overlapMessage);
|
|
||||||
currentTokens += overlapMessage.estimatedTokens;
|
|
||||||
currentBytes += Buffer.byteLength(overlapText, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentChunk.length > 0) {
|
|
||||||
// Mark that this chunk has overlap
|
|
||||||
chunkStartIndex = overlapStart;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add message to current chunk
|
|
||||||
currentChunk.push(message);
|
|
||||||
currentTokens += messageTokens;
|
|
||||||
currentBytes += messageBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save final chunk if it has content
|
|
||||||
if (currentChunk.length > 0) {
|
|
||||||
const chunkContent = this.formatChunk(currentChunk);
|
|
||||||
chunks.push({
|
|
||||||
content: chunkContent,
|
|
||||||
metadata: {
|
|
||||||
chunkNumber: chunks.length + 1,
|
|
||||||
totalChunks: 0,
|
|
||||||
startIndex: chunkStartIndex,
|
|
||||||
endIndex: messages.length - 1,
|
|
||||||
messageCount: currentChunk.length,
|
|
||||||
estimatedTokens: currentTokens,
|
|
||||||
sizeBytes: currentBytes,
|
|
||||||
hasOverlap: this.options.preserveContext && chunks.length > 0
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update total chunks count in metadata
|
|
||||||
chunks.forEach(chunk => {
|
|
||||||
chunk.metadata.totalChunks = chunks.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a single message back to the filtered output format
|
|
||||||
*/
|
|
||||||
private formatMessage(message: ChunkedMessage): string {
|
|
||||||
return `- ${message.content}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a chunk of messages
|
|
||||||
*/
|
|
||||||
private formatChunk(messages: ChunkedMessage[]): string {
|
|
||||||
return messages.map(m => this.formatMessage(m)).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a header for a chunk file with metadata
|
|
||||||
*/
|
|
||||||
public createChunkHeader(metadata: ChunkMetadata): string {
|
|
||||||
const lines = [];
|
|
||||||
|
|
||||||
// Add timestamp range if available, otherwise chunk number
|
|
||||||
if (metadata.firstTimestamp && metadata.lastTimestamp) {
|
|
||||||
lines.push(`# ${metadata.firstTimestamp} to ${metadata.lastTimestamp} (chunk ${metadata.chunkNumber}/${metadata.totalChunks})`);
|
|
||||||
} else {
|
|
||||||
lines.push(`# Chunk ${metadata.chunkNumber} of ${metadata.totalChunks}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n') + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if content needs chunking based on size
|
|
||||||
*/
|
|
||||||
public needsChunking(content: string): boolean {
|
|
||||||
const estimatedTokens = this.estimateTokenCount(content);
|
|
||||||
const sizeBytes = Buffer.byteLength(content, 'utf8');
|
|
||||||
|
|
||||||
return estimatedTokens > this.options.maxTokensPerChunk ||
|
|
||||||
sizeBytes > this.options.maxBytesPerChunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets chunking statistics for logging
|
|
||||||
*/
|
|
||||||
public getChunkingStats(chunks: Array<{ metadata: ChunkMetadata }>): string {
|
|
||||||
const totalMessages = chunks.reduce((sum, c) => sum + c.metadata.messageCount, 0);
|
|
||||||
const totalTokens = chunks.reduce((sum, c) => sum + c.metadata.estimatedTokens, 0);
|
|
||||||
const totalBytes = chunks.reduce((sum, c) => sum + c.metadata.sizeBytes, 0);
|
|
||||||
|
|
||||||
return [
|
|
||||||
`📊 Chunking Statistics:`,
|
|
||||||
` • Total chunks: ${chunks.length}`,
|
|
||||||
` • Total messages: ${totalMessages}`,
|
|
||||||
` • Total estimated tokens: ${totalTokens.toLocaleString()}`,
|
|
||||||
` • Total size: ${(totalBytes / 1024).toFixed(1)} KB`,
|
|
||||||
` • Average tokens per chunk: ${Math.round(totalTokens / chunks.length).toLocaleString()}`,
|
|
||||||
` • Average size per chunk: ${(totalBytes / chunks.length / 1024).toFixed(1)} KB`
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,366 +0,0 @@
|
|||||||
/**
|
|
||||||
* PromptOrchestrator - Single source of truth for all prompt generation
|
|
||||||
*
|
|
||||||
* This class serves as the central orchestrator for generating different types of prompts
|
|
||||||
* used throughout the claude-mem system. It provides clear, well-typed interfaces and
|
|
||||||
* methods for creating prompts for LLM analysis, human context, and system integration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createAnalysisPrompt } from '../../prompts/templates/analysis/AnalysisTemplates.js';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CORE INTERFACES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context data for LLM analysis prompts
|
|
||||||
*/
|
|
||||||
export interface AnalysisContext {
|
|
||||||
/** The transcript content to analyze */
|
|
||||||
transcriptContent: string;
|
|
||||||
/** Session identifier */
|
|
||||||
sessionId: string;
|
|
||||||
/** Project name for context */
|
|
||||||
projectName?: string;
|
|
||||||
/** Custom analysis instructions */
|
|
||||||
customInstructions?: string;
|
|
||||||
/** Compression trigger type */
|
|
||||||
trigger?: 'manual' | 'auto';
|
|
||||||
/** Original token count */
|
|
||||||
originalTokens?: number;
|
|
||||||
/** Target compression ratio */
|
|
||||||
targetCompressionRatio?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context data for human-facing session prompts
|
|
||||||
*/
|
|
||||||
export interface SessionContext {
|
|
||||||
/** Session identifier */
|
|
||||||
sessionId: string;
|
|
||||||
/** Source of the session start */
|
|
||||||
source: 'startup' | 'compact' | 'vscode' | 'web';
|
|
||||||
/** Project name */
|
|
||||||
projectName?: string;
|
|
||||||
/** Additional context to provide to the human */
|
|
||||||
additionalContext?: string;
|
|
||||||
/** Path to the transcript file */
|
|
||||||
transcriptPath?: string;
|
|
||||||
/** Working directory */
|
|
||||||
cwd?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context data for hook response generation
|
|
||||||
*/
|
|
||||||
export interface HookContext {
|
|
||||||
/** The hook event name */
|
|
||||||
hookEventName: string;
|
|
||||||
/** Session identifier */
|
|
||||||
sessionId: string;
|
|
||||||
/** Success status */
|
|
||||||
success: boolean;
|
|
||||||
/** Optional message */
|
|
||||||
message?: string;
|
|
||||||
/** Additional data specific to the hook */
|
|
||||||
data?: Record<string, unknown>;
|
|
||||||
/** Whether to continue processing */
|
|
||||||
shouldContinue?: boolean;
|
|
||||||
/** Reason for stopping if applicable */
|
|
||||||
stopReason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generated analysis prompt for LLM consumption
|
|
||||||
*/
|
|
||||||
export interface AnalysisPrompt {
|
|
||||||
/** The formatted prompt text */
|
|
||||||
prompt: string;
|
|
||||||
/** Context used to generate the prompt */
|
|
||||||
context: AnalysisContext;
|
|
||||||
/** Prompt type identifier */
|
|
||||||
type: 'analysis';
|
|
||||||
/** Generated timestamp */
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generated session prompt for human context
|
|
||||||
*/
|
|
||||||
export interface SessionPrompt {
|
|
||||||
/** The formatted message text */
|
|
||||||
message: string;
|
|
||||||
/** Context used to generate the prompt */
|
|
||||||
context: SessionContext;
|
|
||||||
/** Prompt type identifier */
|
|
||||||
type: 'session';
|
|
||||||
/** Generated timestamp */
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generated hook response
|
|
||||||
*/
|
|
||||||
export interface HookResponse {
|
|
||||||
/** Whether to continue processing */
|
|
||||||
continue: boolean;
|
|
||||||
/** Reason for stopping if continue is false */
|
|
||||||
stopReason?: string;
|
|
||||||
/** Whether to suppress output */
|
|
||||||
suppressOutput?: boolean;
|
|
||||||
/** Hook-specific output data */
|
|
||||||
hookSpecificOutput?: Record<string, unknown>;
|
|
||||||
/** Context used to generate the response */
|
|
||||||
context: HookContext;
|
|
||||||
/** Response type identifier */
|
|
||||||
type: 'hook';
|
|
||||||
/** Generated timestamp */
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PROMPT ORCHESTRATOR CLASS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Central orchestrator for all prompt generation in the claude-mem system
|
|
||||||
*/
|
|
||||||
export class PromptOrchestrator {
|
|
||||||
private projectName: string;
|
|
||||||
|
|
||||||
constructor(projectName = 'claude-mem') {
|
|
||||||
this.projectName = projectName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an analysis prompt for LLM processing of transcript content
|
|
||||||
*/
|
|
||||||
public createAnalysisPrompt(context: AnalysisContext): AnalysisPrompt {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
const prompt = this.buildAnalysisPrompt(context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
prompt,
|
|
||||||
context,
|
|
||||||
type: 'analysis',
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a session start prompt for human context
|
|
||||||
*/
|
|
||||||
public createSessionStartPrompt(context: SessionContext): SessionPrompt {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
const message = this.buildSessionStartMessage(context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
type: 'session',
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a hook response for system integration
|
|
||||||
*/
|
|
||||||
public createHookResponse(context: HookContext): HookResponse {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
const response = this.buildHookResponse(context);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...response,
|
|
||||||
context,
|
|
||||||
type: 'hook',
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PRIVATE PROMPT BUILDERS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
private buildAnalysisPrompt(context: AnalysisContext): string {
|
|
||||||
const {
|
|
||||||
transcriptContent,
|
|
||||||
sessionId,
|
|
||||||
projectName = this.projectName,
|
|
||||||
} = context;
|
|
||||||
|
|
||||||
// Use project name as-is for consistency with directory names
|
|
||||||
const projectPrefix = projectName;
|
|
||||||
|
|
||||||
// Use the simple prompt with the transcript included
|
|
||||||
return createAnalysisPrompt(
|
|
||||||
transcriptContent,
|
|
||||||
sessionId,
|
|
||||||
projectPrefix
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildSessionStartMessage(context: SessionContext): string {
|
|
||||||
const {
|
|
||||||
sessionId,
|
|
||||||
source,
|
|
||||||
projectName = this.projectName,
|
|
||||||
additionalContext,
|
|
||||||
transcriptPath,
|
|
||||||
cwd,
|
|
||||||
} = context;
|
|
||||||
|
|
||||||
let message = `## Session Started (${source})
|
|
||||||
|
|
||||||
**Project**: ${projectName}
|
|
||||||
**Session ID**: ${sessionId} `;
|
|
||||||
|
|
||||||
if (transcriptPath) {
|
|
||||||
message += `**Transcript**: ${transcriptPath} `;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cwd) {
|
|
||||||
message += `**Working Directory**: ${cwd} `;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (additionalContext) {
|
|
||||||
message += `\n### Additional Context\n${additionalContext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
message += `\n\nMemory system is active and ready to preserve context across sessions.`;
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildHookResponse(context: HookContext): Omit<HookResponse, 'context' | 'type' | 'timestamp'> {
|
|
||||||
const {
|
|
||||||
hookEventName,
|
|
||||||
success,
|
|
||||||
message,
|
|
||||||
data,
|
|
||||||
shouldContinue = success,
|
|
||||||
stopReason,
|
|
||||||
} = context;
|
|
||||||
|
|
||||||
const response: Omit<HookResponse, 'context' | 'type' | 'timestamp'> = {
|
|
||||||
continue: shouldContinue,
|
|
||||||
suppressOutput: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!shouldContinue && stopReason) {
|
|
||||||
response.stopReason = stopReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add hook-specific output based on event type
|
|
||||||
if (hookEventName === 'SessionStart') {
|
|
||||||
response.hookSpecificOutput = {
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
additionalContext: message,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
} else if (data) {
|
|
||||||
response.hookSpecificOutput = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// UTILITY METHODS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that an AnalysisContext has required fields
|
|
||||||
*/
|
|
||||||
public validateAnalysisContext(context: Partial<AnalysisContext>): context is AnalysisContext {
|
|
||||||
return !!(context.transcriptContent && context.sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a SessionContext has required fields
|
|
||||||
*/
|
|
||||||
public validateSessionContext(context: Partial<SessionContext>): context is SessionContext {
|
|
||||||
return !!(context.sessionId && context.source);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a HookContext has required fields
|
|
||||||
*/
|
|
||||||
public validateHookContext(context: Partial<HookContext>): context is HookContext {
|
|
||||||
return !!(context.hookEventName && context.sessionId && typeof context.success === 'boolean');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the project name for this orchestrator instance
|
|
||||||
*/
|
|
||||||
public getProjectName(): string {
|
|
||||||
return this.projectName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets a new project name for this orchestrator instance
|
|
||||||
*/
|
|
||||||
public setProjectName(projectName: string): void {
|
|
||||||
this.projectName = projectName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// FACTORY FUNCTIONS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new PromptOrchestrator instance
|
|
||||||
*/
|
|
||||||
export function createPromptOrchestrator(projectName?: string): PromptOrchestrator {
|
|
||||||
return new PromptOrchestrator(projectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an analysis context from basic parameters
|
|
||||||
*/
|
|
||||||
export function createAnalysisContext(
|
|
||||||
transcriptContent: string,
|
|
||||||
sessionId: string,
|
|
||||||
options: Partial<Omit<AnalysisContext, 'transcriptContent' | 'sessionId'>> = {}
|
|
||||||
): AnalysisContext {
|
|
||||||
return {
|
|
||||||
transcriptContent,
|
|
||||||
sessionId,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a session context from basic parameters
|
|
||||||
*/
|
|
||||||
export function createSessionContext(
|
|
||||||
sessionId: string,
|
|
||||||
source: SessionContext['source'],
|
|
||||||
options: Partial<Omit<SessionContext, 'sessionId' | 'source'>> = {}
|
|
||||||
): SessionContext {
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
source,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a hook context from basic parameters
|
|
||||||
*/
|
|
||||||
export function createHookContext(
|
|
||||||
hookEventName: string,
|
|
||||||
sessionId: string,
|
|
||||||
success: boolean,
|
|
||||||
options: Partial<Omit<HookContext, 'hookEventName' | 'sessionId' | 'success'>> = {}
|
|
||||||
): HookContext {
|
|
||||||
return {
|
|
||||||
hookEventName,
|
|
||||||
sessionId,
|
|
||||||
success,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { query } from '@anthropic-ai/claude-code';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import { getClaudePath } from '../../shared/settings.js';
|
|
||||||
|
|
||||||
export interface TitleGenerationRequest {
|
|
||||||
sessionId: string;
|
|
||||||
projectName: string;
|
|
||||||
firstMessage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GeneratedTitle {
|
|
||||||
session_id: string;
|
|
||||||
generated_title: string;
|
|
||||||
timestamp: string;
|
|
||||||
project_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TitleGenerator {
|
|
||||||
private titlesIndexPath: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.titlesIndexPath = path.join(os.homedir(), '.claude-mem', 'conversation-titles.jsonl');
|
|
||||||
this.ensureTitlesIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureTitlesIndex(): void {
|
|
||||||
const dir = path.dirname(this.titlesIndexPath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(this.titlesIndexPath)) {
|
|
||||||
fs.writeFileSync(this.titlesIndexPath, '', 'utf-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateTitle(firstMessage: string): Promise<string> {
|
|
||||||
const prompt = `Generate a 3-7 word descriptive title for this conversation based on the first message.
|
|
||||||
|
|
||||||
The title should:
|
|
||||||
- Capture the main topic or intent
|
|
||||||
- Be concise and descriptive
|
|
||||||
- Use proper capitalization
|
|
||||||
- Not include "Help with" or "Question about" prefixes
|
|
||||||
|
|
||||||
First message: "${firstMessage.substring(0, 500)}"
|
|
||||||
|
|
||||||
Respond with just the title, nothing else.`;
|
|
||||||
|
|
||||||
const response = await query({
|
|
||||||
prompt,
|
|
||||||
options: {
|
|
||||||
model: 'claude-3-5-haiku-20241022',
|
|
||||||
pathToClaudeCodeExecutable: getClaudePath(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let title = '';
|
|
||||||
if (response && typeof response === 'object' && Symbol.asyncIterator in response) {
|
|
||||||
for await (const message of response) {
|
|
||||||
if (message?.content) title += message.content;
|
|
||||||
if (message?.text) title += message.text;
|
|
||||||
}
|
|
||||||
} else if (typeof response === 'string') {
|
|
||||||
title = response;
|
|
||||||
}
|
|
||||||
|
|
||||||
return title.trim().replace(/^["']|["']$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async batchGenerateTitles(requests: TitleGenerationRequest[]): Promise<GeneratedTitle[]> {
|
|
||||||
const results: GeneratedTitle[] = [];
|
|
||||||
|
|
||||||
for (const request of requests) {
|
|
||||||
try {
|
|
||||||
const title = await this.generateTitle(request.firstMessage);
|
|
||||||
|
|
||||||
const generatedTitle: GeneratedTitle = {
|
|
||||||
session_id: request.sessionId,
|
|
||||||
generated_title: title,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
project_name: request.projectName
|
|
||||||
};
|
|
||||||
|
|
||||||
results.push(generatedTitle);
|
|
||||||
this.storeTitleInIndex(generatedTitle);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to generate title for ${request.sessionId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private storeTitleInIndex(title: GeneratedTitle): void {
|
|
||||||
const line = JSON.stringify(title) + '\n';
|
|
||||||
fs.appendFileSync(this.titlesIndexPath, line, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
getExistingTitles(): Map<string, GeneratedTitle> {
|
|
||||||
const titles = new Map<string, GeneratedTitle>();
|
|
||||||
|
|
||||||
if (!fs.existsSync(this.titlesIndexPath)) {
|
|
||||||
return titles;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = fs.readFileSync(this.titlesIndexPath, 'utf-8');
|
|
||||||
const lines = content.trim().split('\n').filter(Boolean);
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const title = JSON.parse(line) as GeneratedTitle;
|
|
||||||
titles.set(title.session_id, title);
|
|
||||||
} catch (error) {
|
|
||||||
// Skip invalid lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return titles;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTitleForSession(sessionId: string): string | null {
|
|
||||||
const titles = this.getExistingTitles();
|
|
||||||
const title = titles.get(sessionId);
|
|
||||||
return title ? title.generated_title : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Hook Prompts System
|
||||||
|
|
||||||
|
This directory contains the centralized prompt configuration for all streaming hooks.
|
||||||
|
|
||||||
|
## Quick Edit Guide
|
||||||
|
|
||||||
|
**Want to change hook prompts?** Edit this file:
|
||||||
|
```
|
||||||
|
hook-prompts.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild and reinstall:
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
bun run dev:install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files in This Directory
|
||||||
|
|
||||||
|
### hook-prompts.config.ts
|
||||||
|
**EDIT THIS FILE** to change prompt content.
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
- `SYSTEM_PROMPT` - Initial instructions for SDK (190 lines)
|
||||||
|
- `TOOL_MESSAGE` - Format for tool responses (10 lines)
|
||||||
|
- `END_MESSAGE` - Session completion request (10 lines)
|
||||||
|
- `HOOK_CONFIG` - Shared settings (truncation limits, SDK options)
|
||||||
|
|
||||||
|
Uses `{{variableName}}` template syntax.
|
||||||
|
|
||||||
|
### hook-prompt-renderer.ts
|
||||||
|
**DON'T EDIT** unless adding new variables or changing rendering logic.
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
- `renderSystemPrompt()` - Processes system prompt template
|
||||||
|
- `renderToolMessage()` - Processes tool message template
|
||||||
|
- `renderEndMessage()` - Processes end message template
|
||||||
|
- Template substitution and auto-truncation logic
|
||||||
|
|
||||||
|
### templates/context/ContextTemplates.ts
|
||||||
|
Session start message formatting (separate from hook prompts).
|
||||||
|
|
||||||
|
## Template Variables Reference
|
||||||
|
|
||||||
|
### SYSTEM_PROMPT Variables
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
project: string; // Project name (e.g., "claude-mem-source")
|
||||||
|
sessionId: string; // Claude Code session ID
|
||||||
|
date: string; // YYYY-MM-DD format
|
||||||
|
userPrompt: string; // Auto-truncated to 200 chars
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TOOL_MESSAGE Variables
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
toolName: string; // Tool name (e.g., "Read", "Bash")
|
||||||
|
toolResponse: string; // Auto-truncated to 20000 chars
|
||||||
|
userPrompt: string; // Auto-truncated to 200 chars
|
||||||
|
timestamp: string; // Full ISO timestamp
|
||||||
|
timeFormatted: string; // HH:MM:SS format (auto-generated)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### END_MESSAGE Variables
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
project: string; // Project name
|
||||||
|
sessionId: string; // Claude Code session ID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Hooks
|
||||||
|
|
||||||
|
### user-prompt-submit-streaming.js
|
||||||
|
```javascript
|
||||||
|
import { renderSystemPrompt, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
|
||||||
|
|
||||||
|
const prompt = renderSystemPrompt({
|
||||||
|
project,
|
||||||
|
sessionId: session_id,
|
||||||
|
date,
|
||||||
|
userPrompt: prompt || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
query({
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
model: HOOK_CONFIG.sdk.model,
|
||||||
|
allowedTools: HOOK_CONFIG.sdk.allowedTools,
|
||||||
|
maxTokens: HOOK_CONFIG.sdk.maxTokensSystem
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### post-tool-use-streaming.js
|
||||||
|
```javascript
|
||||||
|
import { renderToolMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
|
||||||
|
|
||||||
|
const message = renderToolMessage({
|
||||||
|
toolName: tool_name,
|
||||||
|
toolResponse: toolResponseStr,
|
||||||
|
userPrompt: prompt || '',
|
||||||
|
timestamp: timestamp || new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
query({
|
||||||
|
prompt: message,
|
||||||
|
options: {
|
||||||
|
model: HOOK_CONFIG.sdk.model,
|
||||||
|
maxTokens: HOOK_CONFIG.sdk.maxTokensTool
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### stop-streaming.js
|
||||||
|
```javascript
|
||||||
|
import { renderEndMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
|
||||||
|
|
||||||
|
const message = renderEndMessage({
|
||||||
|
project,
|
||||||
|
sessionId: claudeSessionId
|
||||||
|
});
|
||||||
|
|
||||||
|
query({
|
||||||
|
prompt: message,
|
||||||
|
options: {
|
||||||
|
model: HOOK_CONFIG.sdk.model,
|
||||||
|
maxTokens: HOOK_CONFIG.sdk.maxTokensEnd
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
Edit `HOOK_CONFIG` in `hook-prompts.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const HOOK_CONFIG = {
|
||||||
|
// Truncation limits for template variables
|
||||||
|
maxUserPromptLength: 200, // Increase to show more context
|
||||||
|
maxToolResponseLength: 20000, // Increase for larger outputs
|
||||||
|
|
||||||
|
// SDK configuration
|
||||||
|
sdk: {
|
||||||
|
model: 'claude-sonnet-4-5', // Change model version
|
||||||
|
allowedTools: ['Bash'], // Add more tools if needed
|
||||||
|
maxTokensSystem: 8192, // Token limit for system prompt
|
||||||
|
maxTokensTool: 8192, // Token limit for tool messages
|
||||||
|
maxTokensEnd: 2048, // Token limit for end message
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Editing a Prompt
|
||||||
|
|
||||||
|
### Before
|
||||||
|
```typescript
|
||||||
|
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
|
||||||
|
|
||||||
|
Tool: {{toolName}}
|
||||||
|
User Context: "{{userPrompt}}"
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
{{toolResponse}}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Analyze and store if meaningful.`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### After
|
||||||
|
```typescript
|
||||||
|
export const TOOL_MESSAGE = `# Analysis Request {{timeFormatted}}
|
||||||
|
|
||||||
|
Executed: {{toolName}}
|
||||||
|
Context: "{{userPrompt}}"
|
||||||
|
Priority: High
|
||||||
|
|
||||||
|
Output:
|
||||||
|
\`\`\`
|
||||||
|
{{toolResponse}}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
IMPORTANT: Only store if this contains:
|
||||||
|
- New code patterns or logic
|
||||||
|
- Architecture decisions
|
||||||
|
- Error messages with solutions
|
||||||
|
- Configuration changes
|
||||||
|
|
||||||
|
Skip trivial operations.`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply Changes
|
||||||
|
```bash
|
||||||
|
bun run build && bun run dev:install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### DRY Compliance
|
||||||
|
- **Before**: 3 files with 188 lines of hardcoded prompts
|
||||||
|
- **After**: 1 config file with all prompts centralized
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
- Change prompts without touching hook implementation
|
||||||
|
- Type-safe template variables
|
||||||
|
- Consistent formatting across all hooks
|
||||||
|
- Version-controlled prompt history
|
||||||
|
|
||||||
|
### Flexibility
|
||||||
|
- Easy A/B testing of different instructions
|
||||||
|
- Simple to adjust truncation limits
|
||||||
|
- Quick model/token configuration changes
|
||||||
|
- Template variables prevent copy-paste errors
|
||||||
|
|
||||||
|
## Full Documentation
|
||||||
|
|
||||||
|
See `/Users/alexnewman/Scripts/claude-mem-source/docs/HOOK_PROMPTS.md` for:
|
||||||
|
- Detailed editing guide
|
||||||
|
- Troubleshooting common issues
|
||||||
|
- Adding new template variables
|
||||||
|
- Advanced customization
|
||||||
|
- Migration notes
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
/**
|
|
||||||
* Claude Memory System - Prompt-Related Constants and Templates
|
|
||||||
*
|
|
||||||
* This file contains all prompts, instructions, and output templates
|
|
||||||
* for the analysis and context priming system.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as HookTemplates from './templates/hooks/HookTemplates.js';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ANALYSIS PROMPTS AND TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity naming patterns for the knowledge graph
|
|
||||||
*/
|
|
||||||
export const ENTITY_NAMING_PATTERNS = {
|
|
||||||
component: "Component_Name",
|
|
||||||
decision: "Decision_Name",
|
|
||||||
pattern: "Pattern_Name",
|
|
||||||
tool: "Tool_Name",
|
|
||||||
fix: "Fix_Name",
|
|
||||||
workflow: "Workflow_Name"
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available entity types for classification
|
|
||||||
*/
|
|
||||||
export const ENTITY_TYPES = {
|
|
||||||
component: "component", // UI components, modules, services
|
|
||||||
pattern: "pattern", // Architectural or design patterns
|
|
||||||
workflow: "workflow", // Processes, pipelines, sequences
|
|
||||||
integration: "integration", // APIs, external services, data sources
|
|
||||||
concept: "concept", // Abstract ideas, methodologies, principles
|
|
||||||
decision: "decision", // Design choices, trade-offs, solutions
|
|
||||||
tool: "tool", // Utilities, libraries, development tools
|
|
||||||
fix: "fix" // Bug fixes, patches, workarounds
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard observation fields for entities
|
|
||||||
*/
|
|
||||||
export const OBSERVATION_FIELDS = [
|
|
||||||
"Core purpose: [what it fundamentally does]",
|
|
||||||
"Brief description: [one-line summary for session-start display]",
|
|
||||||
"Implementation: [key technical details, code patterns]",
|
|
||||||
"Dependencies: [what it requires or builds upon]",
|
|
||||||
"Usage context: [when/why it's used]",
|
|
||||||
"Performance characteristics: [speed, reliability, constraints]",
|
|
||||||
"Integration points: [how it connects to other systems]",
|
|
||||||
"Keywords: [searchable terms for this concept]",
|
|
||||||
"Decision rationale: [why this approach was chosen]",
|
|
||||||
"Next steps: [what needs to be done next with this component]",
|
|
||||||
"Files modified: [list of files changed]",
|
|
||||||
"Tools used: [development tools/commands used]"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relationship types for creating meaningful entity connections
|
|
||||||
*/
|
|
||||||
export const RELATIONSHIP_TYPES = [
|
|
||||||
"executes_via", "orchestrates_through", "validates_using",
|
|
||||||
"provides_auth_to", "manages_state_for", "processes_events_from",
|
|
||||||
"caches_data_from", "routes_requests_to", "transforms_data_for",
|
|
||||||
"extends", "enhances_performance_of", "builds_upon",
|
|
||||||
"fixes_issue_in", "replaces", "optimizes",
|
|
||||||
"triggers_tool", "receives_result_from"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CONTEXT PRIMING TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System message templates for context priming
|
|
||||||
*/
|
|
||||||
export const CONTEXT_TEMPLATES = {
|
|
||||||
PRIMARY_CONTEXT: (projectName: string) =>
|
|
||||||
`Context primed for project: ${projectName}. Access memories with chroma_query_documents(["${projectName}*"]) or chroma_get_documents(["document_id"]).`,
|
|
||||||
|
|
||||||
RECENT_SESSIONS: (sessionList: string) =>
|
|
||||||
`Recent sessions available: ${sessionList}`,
|
|
||||||
|
|
||||||
AVAILABLE_ENTITIES: (type: string, entities: string[], hasMore: boolean, moreCount: number) =>
|
|
||||||
`Available ${type} entities: ${entities.join(', ')}${hasMore ? ` (+${moreCount} more)` : ''}`,
|
|
||||||
|
|
||||||
SESSION_START_HEADER: '🧠 Active Working Context from Previous Sessions:',
|
|
||||||
SESSION_START_SEPARATOR: '═'.repeat(70),
|
|
||||||
|
|
||||||
RESUME_INSTRUCTIONS: `💡 TO RESUME: Load active components with chroma_get_documents(["<exact_document_ids>"])
|
|
||||||
📊 TO EXPLORE: Search related work with chroma_query_documents(["<keywords>"])`
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// SESSION START OUTPUT TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session start formatting templates
|
|
||||||
*/
|
|
||||||
export const SESSION_START_TEMPLATES = {
|
|
||||||
FOCUS_LINE: (focus: string) => `📌 CURRENT FOCUS: ${focus}`,
|
|
||||||
LAST_WORKED: (timeAgo: string, projectName: string) => `Last worked: ${timeAgo} | Project: ${projectName}`,
|
|
||||||
|
|
||||||
SECTIONS: {
|
|
||||||
COMPONENTS: '🎯 ACTIVE COMPONENTS (load these for context):',
|
|
||||||
DECISIONS: '🔄 RECENT DECISIONS & PATTERNS:',
|
|
||||||
TOOLS: '🛠️ TOOLS & INFRASTRUCTURE:',
|
|
||||||
FIXES: '🐛 RECENT FIXES:',
|
|
||||||
ACTIONS: '⚡ NEXT ACTIONS:'
|
|
||||||
},
|
|
||||||
|
|
||||||
ACTION_PREFIX: '□ ',
|
|
||||||
ENTITY_BULLET: '• '
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Time formatting for "time ago" displays
|
|
||||||
*/
|
|
||||||
export const TIME_FORMATS = {
|
|
||||||
JUST_NOW: 'just now',
|
|
||||||
HOURS_AGO: (hours: number) => `${hours} hour${hours > 1 ? 's' : ''} ago`,
|
|
||||||
DAYS_AGO: (days: number) => `${days} day${days > 1 ? 's' : ''} ago`,
|
|
||||||
RECENTLY: 'recently'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HOOK RESPONSE TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard hook response structures for Claude Code integration
|
|
||||||
*/
|
|
||||||
export const HOOK_RESPONSES = {
|
|
||||||
SUCCESS: (hookEventName: string, message: string) => ({
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName,
|
|
||||||
status: "success",
|
|
||||||
message
|
|
||||||
},
|
|
||||||
suppressOutput: true
|
|
||||||
}),
|
|
||||||
|
|
||||||
SKIPPED: (hookEventName: string, message: string) => ({
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName,
|
|
||||||
status: "skipped",
|
|
||||||
message
|
|
||||||
},
|
|
||||||
suppressOutput: true
|
|
||||||
}),
|
|
||||||
|
|
||||||
BLOCKED: (reason: string) => ({
|
|
||||||
decision: "block",
|
|
||||||
reason
|
|
||||||
}),
|
|
||||||
|
|
||||||
CONTINUE: (hookEventName: string, additionalContext?: string) => ({
|
|
||||||
continue: true,
|
|
||||||
...(additionalContext && {
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName,
|
|
||||||
additionalContext
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
|
|
||||||
ERROR: (reason: string) => ({
|
|
||||||
decision: "block",
|
|
||||||
reason
|
|
||||||
})
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-defined hook messages
|
|
||||||
*/
|
|
||||||
export const HOOK_MESSAGES = {
|
|
||||||
COMPRESSION_SUCCESS: "Memory compression completed successfully",
|
|
||||||
COMPRESSION_FAILED: (stderr: string) => `Compression failed: ${stderr}`,
|
|
||||||
CONTEXT_LOADED: "Project context loaded successfully",
|
|
||||||
CONTEXT_SKIPPED: "Continuing session - context loading skipped",
|
|
||||||
NO_TRANSCRIPT: "No transcript path provided",
|
|
||||||
HOOK_ERROR: (error: string) => `Hook error: ${error}`
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export hook templates for direct usage
|
|
||||||
*/
|
|
||||||
export { HookTemplates };
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Hook Prompt Renderer
|
||||||
|
*
|
||||||
|
* Simple template rendering for hook prompts.
|
||||||
|
* Handles variable substitution and auto-truncation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
PROMPTS,
|
||||||
|
HOOK_CONFIG,
|
||||||
|
type SystemPromptVariables,
|
||||||
|
type ToolMessageVariables,
|
||||||
|
type EndMessageVariables,
|
||||||
|
} from './hook-prompts.config.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TEMPLATE RENDERING
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple template variable substitution
|
||||||
|
* Replaces {{variableName}} with actual values
|
||||||
|
*/
|
||||||
|
function substituteVariables(
|
||||||
|
template: string,
|
||||||
|
variables: Record<string, string>
|
||||||
|
): string {
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
const placeholder = `{{${key}}}`;
|
||||||
|
// Replace all occurrences of this placeholder
|
||||||
|
result = result.split(placeholder).join(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text with ellipsis if it exceeds maxLength
|
||||||
|
*/
|
||||||
|
function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength) + (text.length > maxLength ? '...' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp for tool message header
|
||||||
|
* Extracts HH:MM:SS from ISO timestamp
|
||||||
|
*/
|
||||||
|
function formatTime(timestamp: string): string {
|
||||||
|
const timePart = timestamp.split('T')[1];
|
||||||
|
if (!timePart) return '';
|
||||||
|
return timePart.slice(0, 8); // HH:MM:SS
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PUBLIC RENDERING FUNCTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render system prompt for SDK session initialization
|
||||||
|
*/
|
||||||
|
export function renderSystemPrompt(
|
||||||
|
variables: SystemPromptVariables
|
||||||
|
): string {
|
||||||
|
// Auto-truncate userPrompt
|
||||||
|
const userPromptTruncated = truncate(
|
||||||
|
variables.userPrompt,
|
||||||
|
HOOK_CONFIG.maxUserPromptLength
|
||||||
|
);
|
||||||
|
|
||||||
|
return substituteVariables(PROMPTS.system, {
|
||||||
|
project: variables.project,
|
||||||
|
sessionId: variables.sessionId,
|
||||||
|
date: variables.date,
|
||||||
|
userPrompt: userPromptTruncated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tool message for SDK processing
|
||||||
|
*/
|
||||||
|
export function renderToolMessage(
|
||||||
|
variables: ToolMessageVariables
|
||||||
|
): string {
|
||||||
|
// Auto-truncate userPrompt and toolResponse
|
||||||
|
const userPromptTruncated = truncate(
|
||||||
|
variables.userPrompt,
|
||||||
|
HOOK_CONFIG.maxUserPromptLength
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolResponseTruncated = truncate(
|
||||||
|
variables.toolResponse,
|
||||||
|
HOOK_CONFIG.maxToolResponseLength
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const timeFormatted = formatTime(variables.timestamp);
|
||||||
|
|
||||||
|
return substituteVariables(PROMPTS.tool, {
|
||||||
|
toolName: variables.toolName,
|
||||||
|
toolResponse: toolResponseTruncated,
|
||||||
|
userPrompt: userPromptTruncated,
|
||||||
|
timestamp: variables.timestamp,
|
||||||
|
timeFormatted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render end message for session completion
|
||||||
|
*/
|
||||||
|
export function renderEndMessage(
|
||||||
|
variables: EndMessageVariables
|
||||||
|
): string {
|
||||||
|
return substituteVariables(PROMPTS.end, {
|
||||||
|
project: variables.project,
|
||||||
|
sessionId: variables.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GENERIC RENDERER (for convenience)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type PromptType = 'system' | 'tool' | 'end';
|
||||||
|
|
||||||
|
export type PromptVariables<T extends PromptType> = T extends 'system'
|
||||||
|
? SystemPromptVariables
|
||||||
|
: T extends 'tool'
|
||||||
|
? ToolMessageVariables
|
||||||
|
: T extends 'end'
|
||||||
|
? EndMessageVariables
|
||||||
|
: never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic prompt renderer - dispatches to specific renderer based on type
|
||||||
|
*/
|
||||||
|
export function renderPrompt<T extends PromptType>(
|
||||||
|
type: T,
|
||||||
|
variables: PromptVariables<T>
|
||||||
|
): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'system':
|
||||||
|
return renderSystemPrompt(variables as SystemPromptVariables);
|
||||||
|
case 'tool':
|
||||||
|
return renderToolMessage(variables as ToolMessageVariables);
|
||||||
|
case 'end':
|
||||||
|
return renderEndMessage(variables as EndMessageVariables);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown prompt type: ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export { HOOK_CONFIG, PROMPTS };
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Hook Prompts Configuration
|
||||||
|
*
|
||||||
|
* Centralized configuration for all streaming hook prompts.
|
||||||
|
* This is the SINGLE SOURCE OF TRUTH for hook prompt content.
|
||||||
|
*
|
||||||
|
* EDITING GUIDE:
|
||||||
|
* - Use {{variableName}} for template variables
|
||||||
|
* - Available variables are listed in each prompt's interface
|
||||||
|
* - All prompts are processed through renderPrompt() function
|
||||||
|
* - Changes here apply to all hooks automatically after rebuild
|
||||||
|
*
|
||||||
|
* LIFECYCLE FLOW:
|
||||||
|
* 1. user-prompt-submit: Initializes SDK session with systemPrompt
|
||||||
|
* 2. post-tool-use: Feeds tool responses using toolMessage (repeats N times)
|
||||||
|
* 3. stop: Ends session and requests overview using endMessage
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPE DEFINITIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface SystemPromptVariables {
|
||||||
|
project: string;
|
||||||
|
sessionId: string;
|
||||||
|
date: string;
|
||||||
|
userPrompt: string; // Auto-truncated to maxUserPromptLength
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolMessageVariables {
|
||||||
|
toolName: string;
|
||||||
|
toolResponse: string; // Auto-truncated to maxToolResponseLength
|
||||||
|
userPrompt: string; // Auto-truncated to maxUserPromptLength
|
||||||
|
timestamp: string; // Full ISO timestamp
|
||||||
|
timeFormatted: string; // HH:MM:SS format
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndMessageVariables {
|
||||||
|
project: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SHARED CONFIGURATION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const HOOK_CONFIG = {
|
||||||
|
// Truncation limits for template variables
|
||||||
|
maxUserPromptLength: 200,
|
||||||
|
maxToolResponseLength: 20000,
|
||||||
|
|
||||||
|
// SDK configuration (used by hooks)
|
||||||
|
sdk: {
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
allowedTools: ['Bash'],
|
||||||
|
maxTokensSystem: 8192,
|
||||||
|
maxTokensTool: 8192,
|
||||||
|
maxTokensEnd: 2048,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PHASE 1: SYSTEM PROMPT (user-prompt-submit-streaming.js)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompt that initializes the SDK session.
|
||||||
|
* Instructs the SDK on how to process tool responses and store memories.
|
||||||
|
*
|
||||||
|
* Variables:
|
||||||
|
* - {{project}}: Project name (from cwd basename)
|
||||||
|
* - {{sessionId}}: Claude Code session ID
|
||||||
|
* - {{date}}: Current date (YYYY-MM-DD)
|
||||||
|
* - {{userPrompt}}: User's initial prompt (truncated to 200 chars)
|
||||||
|
*/
|
||||||
|
export const SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
|
||||||
|
|
||||||
|
# SESSION CONTEXT
|
||||||
|
- Project: {{project}}
|
||||||
|
- Session: {{sessionId}}
|
||||||
|
- Date: {{date}}
|
||||||
|
- User Request: "{{userPrompt}}"
|
||||||
|
|
||||||
|
# YOUR JOB
|
||||||
|
|
||||||
|
## FIRST: Generate Session Title
|
||||||
|
|
||||||
|
IMMEDIATELY generate a title and subtitle for this session based on the user request.
|
||||||
|
|
||||||
|
Use this bash command:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem update-session-metadata \\
|
||||||
|
--project "{{project}}" \\
|
||||||
|
--session "{{sessionId}}" \\
|
||||||
|
--title "Short title (3-6 words)" \\
|
||||||
|
--subtitle "One sentence description (max 20 words)"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Example for "Help me add dark mode to my app":
|
||||||
|
- Title: "Dark Mode Implementation"
|
||||||
|
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
|
||||||
|
|
||||||
|
## THEN: Process Tool Responses
|
||||||
|
|
||||||
|
You will receive a stream of tool responses. For each one:
|
||||||
|
|
||||||
|
1. ANALYZE: Does this contain information worth remembering?
|
||||||
|
2. DECIDE: Should I store this or skip it?
|
||||||
|
3. EXTRACT: What are the key semantic concepts?
|
||||||
|
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
|
||||||
|
5. STORE: Use bash to save the hierarchical memory
|
||||||
|
6. TRACK: Keep count of stored memories (001, 002, 003...)
|
||||||
|
|
||||||
|
# WHAT TO STORE
|
||||||
|
|
||||||
|
Store these:
|
||||||
|
- File contents with logic, algorithms, or patterns
|
||||||
|
- Search results revealing project structure
|
||||||
|
- Build errors or test failures with context
|
||||||
|
- Code revealing architecture or design decisions
|
||||||
|
- Git diffs with significant changes
|
||||||
|
- Command outputs showing system state
|
||||||
|
|
||||||
|
Skip these:
|
||||||
|
- Simple status checks (git status with no changes)
|
||||||
|
- Trivial edits (one-line config changes)
|
||||||
|
- Repeated operations
|
||||||
|
- Binary data or noise
|
||||||
|
- Anything without semantic value
|
||||||
|
|
||||||
|
# HIERARCHICAL MEMORY FORMAT
|
||||||
|
|
||||||
|
Each memory has FOUR components:
|
||||||
|
|
||||||
|
## 1. TITLE (3-8 words)
|
||||||
|
A scannable headline that captures the core action or topic.
|
||||||
|
Examples:
|
||||||
|
- "SDK Transcript Cleanup Implementation"
|
||||||
|
- "Hook System Architecture Analysis"
|
||||||
|
- "ChromaDB Migration Planning"
|
||||||
|
|
||||||
|
## 2. SUBTITLE (max 24 words)
|
||||||
|
A concise, memorable summary that captures the essence of the change.
|
||||||
|
Examples:
|
||||||
|
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
|
||||||
|
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
|
||||||
|
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Clear and descriptive
|
||||||
|
- Focus on the outcome or benefit
|
||||||
|
- Use active voice when possible
|
||||||
|
- Keep it professional and informative
|
||||||
|
|
||||||
|
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
|
||||||
|
Individual, searchable statements that can be vector-embedded separately.
|
||||||
|
Each fact is ONE specific piece of information.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
|
||||||
|
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
|
||||||
|
- "Uses fs.unlink with graceful error handling for missing files"
|
||||||
|
- "Checks two transcript path formats for backward compatibility"
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Start with filename or component when relevant
|
||||||
|
- Be specific: include paths, function names, actual values
|
||||||
|
- Each fact stands alone (no pronouns like "it" or "this")
|
||||||
|
- 50-150 characters target
|
||||||
|
- Focus on searchable technical details
|
||||||
|
|
||||||
|
## 4. NARRATIVE (512-1024 tokens, same as current format)
|
||||||
|
The full contextual story for deep dives:
|
||||||
|
|
||||||
|
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
|
||||||
|
|
||||||
|
This is the detailed explanation for when someone needs full context.
|
||||||
|
|
||||||
|
# STORAGE COMMAND FORMAT
|
||||||
|
|
||||||
|
Store using this EXACT bash command structure:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-memory \\
|
||||||
|
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
|
||||||
|
--title "Your Title Here" \\
|
||||||
|
--subtitle "Your concise subtitle here" \\
|
||||||
|
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
|
||||||
|
--concepts '["concept1", "concept2", "concept3"]' \\
|
||||||
|
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
|
||||||
|
--project "{{project}}" \\
|
||||||
|
--session "{{sessionId}}" \\
|
||||||
|
--date "{{date}}"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
CRITICAL FORMATTING RULES:
|
||||||
|
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
|
||||||
|
- Use double quotes inside the JSON arrays: "item"
|
||||||
|
- Use double quotes around simple string values: --title "Title"
|
||||||
|
- Escape any quotes in the content properly
|
||||||
|
- Sequential numbering: 001, 002, 003, etc.
|
||||||
|
|
||||||
|
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
|
||||||
|
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
|
||||||
|
|
||||||
|
# EXAMPLE MEMORY
|
||||||
|
|
||||||
|
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
|
||||||
|
|
||||||
|
Your storage command:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-memory \\
|
||||||
|
--id "claude-mem_abc123_2025-10-01_001" \\
|
||||||
|
--title "SDK Transcript Auto-Cleanup" \\
|
||||||
|
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
|
||||||
|
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
|
||||||
|
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
|
||||||
|
--files '["hooks/stop-streaming.js"]' \\
|
||||||
|
--project "claude-mem" \\
|
||||||
|
--session "abc123" \\
|
||||||
|
--date "2025-10-01"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# STATE TRACKING
|
||||||
|
|
||||||
|
CRITICAL: Keep track of your memory counter across all tool messages.
|
||||||
|
- Start at 001
|
||||||
|
- Increment for each stored memory
|
||||||
|
- Never repeat numbers
|
||||||
|
- Each session has separate numbering
|
||||||
|
|
||||||
|
# SESSION END
|
||||||
|
|
||||||
|
At the end (when I send "SESSION ENDING"), generate an overview using:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# IMPORTANT REMINDERS
|
||||||
|
|
||||||
|
- You're processing a DIFFERENT Claude Code session (not your own)
|
||||||
|
- Use Bash tool to call claude-mem commands
|
||||||
|
- Keep subtitles clear and informative (max 24 words)
|
||||||
|
- Each fact is ONE specific thing (not multiple ideas)
|
||||||
|
- Be selective - quality over quantity
|
||||||
|
- Always increment memory numbers
|
||||||
|
- Facts should be searchable (specific file names, paths, functions)
|
||||||
|
|
||||||
|
Ready for tool responses.`;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PHASE 2: TOOL MESSAGE (post-tool-use-streaming.js)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message format for each tool response sent to the SDK.
|
||||||
|
* The SDK analyzes this and decides whether to store a memory.
|
||||||
|
*
|
||||||
|
* Variables:
|
||||||
|
* - {{timeFormatted}}: Time portion of timestamp (HH:MM:SS)
|
||||||
|
* - {{toolName}}: Name of the tool that was used
|
||||||
|
* - {{userPrompt}}: User's original prompt (truncated to 200 chars)
|
||||||
|
* - {{toolResponse}}: Full tool response (truncated to 20000 chars)
|
||||||
|
*/
|
||||||
|
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
|
||||||
|
|
||||||
|
Tool: {{toolName}}
|
||||||
|
User Context: "{{userPrompt}}"
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
{{toolResponse}}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Analyze and store if meaningful.`;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PHASE 3: END MESSAGE (stop-streaming.js)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message sent to SDK when session ends.
|
||||||
|
* Requests the SDK to generate and store a session overview.
|
||||||
|
*
|
||||||
|
* Variables:
|
||||||
|
* - {{project}}: Project name
|
||||||
|
* - {{sessionId}}: Claude Code session ID
|
||||||
|
*/
|
||||||
|
export const END_MESSAGE = `# SESSION ENDING
|
||||||
|
|
||||||
|
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
|
||||||
|
|
||||||
|
Store it using Bash:
|
||||||
|
\`\`\`bash
|
||||||
|
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Focus on: what was done, current state, key decisions, outcomes.`;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const PROMPTS = {
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
tool: TOOL_MESSAGE,
|
||||||
|
end: END_MESSAGE,
|
||||||
|
} as const;
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prompts Module - Single source of truth for all prompt generation
|
|
||||||
*
|
|
||||||
* This module provides a centralized system for generating prompts across
|
|
||||||
* the claude-mem system. It includes the core PromptOrchestrator class
|
|
||||||
* and all related TypeScript interfaces.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Export all interfaces
|
|
||||||
export type {
|
|
||||||
AnalysisContext,
|
|
||||||
SessionContext,
|
|
||||||
HookContext,
|
|
||||||
AnalysisPrompt,
|
|
||||||
SessionPrompt,
|
|
||||||
HookResponse,
|
|
||||||
} from '../core/orchestration/PromptOrchestrator.js';
|
|
||||||
|
|
||||||
// Export the main class
|
|
||||||
export {
|
|
||||||
PromptOrchestrator,
|
|
||||||
} from '../core/orchestration/PromptOrchestrator.js';
|
|
||||||
|
|
||||||
// Export factory functions
|
|
||||||
export {
|
|
||||||
createPromptOrchestrator,
|
|
||||||
createAnalysisContext,
|
|
||||||
createSessionContext,
|
|
||||||
createHookContext,
|
|
||||||
} from '../core/orchestration/PromptOrchestrator.js';
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# Claude Memory Templates
|
|
||||||
|
|
||||||
This directory contains modular templates for the Claude Memory System, including LLM analysis prompts and system integration responses.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### AnalysisTemplates.ts
|
|
||||||
The main template system for LLM analysis prompts. Contains clean, separated template functions for:
|
|
||||||
|
|
||||||
- **Entity extraction instructions** - Guidelines for identifying and categorizing technical entities
|
|
||||||
- **Relationship mapping instructions** - Rules for creating meaningful connections between entities
|
|
||||||
- **Output format specifications** - Exact format requirements for pipe-separated summaries
|
|
||||||
- **Example outputs** - Sample outputs to guide the LLM
|
|
||||||
- **MCP tool usage instructions** - Step-by-step MCP tool usage workflow
|
|
||||||
- **Dynamic content injection helpers** - Functions for injecting project/session context
|
|
||||||
|
|
||||||
### HookTemplates.ts
|
|
||||||
System integration templates for Claude Code hook responses. Provides standardized templates for:
|
|
||||||
|
|
||||||
- **Pre-compact hook responses** - Approve/block compression operations with proper formatting
|
|
||||||
- **Session-start hook responses** - Load and format context with rich memory information
|
|
||||||
- **Pre-tool use hook responses** - Security policies and permission controls
|
|
||||||
- **Error handling templates** - User-friendly error messages with troubleshooting guidance
|
|
||||||
- **Progress indicators** - Status updates for long-running operations
|
|
||||||
- **Response validation** - Ensures compliance with Claude Code hook specifications
|
|
||||||
|
|
||||||
### ContextTemplates.ts
|
|
||||||
Human-readable formatting templates for user-facing messages during memory operations.
|
|
||||||
|
|
||||||
### Legacy Templates
|
|
||||||
- `analysis-template.txt` - Legacy mustache-style template (deprecated)
|
|
||||||
- `session-start-template.txt` - Legacy mustache-style template (deprecated)
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The new template system follows these principles:
|
|
||||||
|
|
||||||
1. **Pure Functions** - Each template function takes context and returns formatted strings
|
|
||||||
2. **Modular Design** - Complex prompts are broken into focused, reusable components
|
|
||||||
3. **Type Safety** - Full TypeScript support with proper interfaces
|
|
||||||
4. **Context Injection** - Dynamic content injection through helper functions
|
|
||||||
5. **Composable Templates** - Build complex prompts by combining template sections
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Hook Templates Usage
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
createPreCompactSuccessResponse,
|
|
||||||
createSessionStartMemoryResponse,
|
|
||||||
createPreToolUseAllowResponse,
|
|
||||||
validateHookResponse
|
|
||||||
} from './HookTemplates.js';
|
|
||||||
|
|
||||||
// Pre-compact hook: approve compression
|
|
||||||
const preCompactResponse = createPreCompactSuccessResponse();
|
|
||||||
console.log(JSON.stringify(preCompactResponse));
|
|
||||||
// Output: {"continue": true, "suppressOutput": true}
|
|
||||||
|
|
||||||
// Session start hook: load context with memories
|
|
||||||
const sessionResponse = createSessionStartMemoryResponse({
|
|
||||||
projectName: 'claude-mem',
|
|
||||||
memoryCount: 15,
|
|
||||||
lastSessionTime: '2 hours ago',
|
|
||||||
recentComponents: ['HookTemplates', 'PromptOrchestrator'],
|
|
||||||
recentDecisions: ['Use TypeScript for type safety']
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify(sessionResponse));
|
|
||||||
|
|
||||||
// Pre-tool use: allow memory tools
|
|
||||||
const toolResponse = createPreToolUseAllowResponse('Memory operations are always permitted');
|
|
||||||
console.log(JSON.stringify(toolResponse));
|
|
||||||
|
|
||||||
// Validate responses before sending
|
|
||||||
const validation = validateHookResponse(preCompactResponse, 'PreCompact');
|
|
||||||
if (!validation.isValid) {
|
|
||||||
console.error('Invalid response:', validation.errors);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Analysis Templates Usage
|
|
||||||
```typescript
|
|
||||||
import { buildCompleteAnalysisPrompt } from './AnalysisTemplates.js';
|
|
||||||
|
|
||||||
const prompt = buildCompleteAnalysisPrompt(
|
|
||||||
'myproject', // projectPrefix
|
|
||||||
'session123', // sessionId
|
|
||||||
[], // toolUseChains
|
|
||||||
'2024-01-01', // timestamp (optional)
|
|
||||||
'archive.jsonl' // archiveFilename (optional)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Individual Template Components
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
createEntityExtractionInstructions,
|
|
||||||
createOutputFormatSpecification,
|
|
||||||
createExampleOutput
|
|
||||||
} from './AnalysisTemplates.js';
|
|
||||||
|
|
||||||
// Get just the entity extraction guidelines
|
|
||||||
const entityInstructions = createEntityExtractionInstructions('myproject');
|
|
||||||
|
|
||||||
// Get output format specification
|
|
||||||
const outputFormat = createOutputFormatSpecification('2024-01-01', 'archive.jsonl');
|
|
||||||
|
|
||||||
// Get example output
|
|
||||||
const examples = createExampleOutput('myproject', 'session123');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Injection
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
injectProjectContext,
|
|
||||||
injectSessionContext,
|
|
||||||
validateTemplateContext
|
|
||||||
} from './AnalysisTemplates.js';
|
|
||||||
|
|
||||||
// Validate context before using templates
|
|
||||||
const context = { projectPrefix: 'myproject', sessionId: 'session123' };
|
|
||||||
const errors = validateTemplateContext(context);
|
|
||||||
if (errors.length > 0) {
|
|
||||||
console.error('Invalid context:', errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject dynamic content into template strings
|
|
||||||
let template = "Working on {{projectPrefix}} session {{sessionId}}";
|
|
||||||
template = injectProjectContext(template, 'myproject');
|
|
||||||
template = injectSessionContext(template, 'session123');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Template Sections
|
|
||||||
|
|
||||||
### Entity Extraction Instructions
|
|
||||||
- Categories of entities to extract (components, patterns, decisions, etc.)
|
|
||||||
- Naming conventions with project prefixes
|
|
||||||
- Entity type classifications
|
|
||||||
- Observation field templates
|
|
||||||
|
|
||||||
### Relationship Mapping
|
|
||||||
- Available relationship types
|
|
||||||
- Active-voice relationship guidelines
|
|
||||||
- Graph connection strategies
|
|
||||||
|
|
||||||
### Output Format
|
|
||||||
- Pipe-separated format specification
|
|
||||||
- Required fields and exact values
|
|
||||||
- Summary writing guidelines
|
|
||||||
|
|
||||||
### MCP Tool Usage
|
|
||||||
- Step-by-step MCP tool workflow
|
|
||||||
- Entity creation instructions
|
|
||||||
- Relationship creation guidelines
|
|
||||||
|
|
||||||
### Critical Requirements
|
|
||||||
- Entity count requirements (3-15 entities)
|
|
||||||
- Relationship count requirements (5-20 relationships)
|
|
||||||
- Output line requirements (3-10 summaries)
|
|
||||||
- Format validation rules
|
|
||||||
|
|
||||||
## Benefits Over Legacy System
|
|
||||||
|
|
||||||
1. **Maintainability** - Separated concerns make individual sections easy to update
|
|
||||||
2. **Testability** - Pure functions can be unit tested independently
|
|
||||||
3. **Reusability** - Template components can be reused across different contexts
|
|
||||||
4. **Debugging** - Easy to isolate issues to specific template sections
|
|
||||||
5. **Type Safety** - Full TypeScript support prevents runtime template errors
|
|
||||||
6. **Performance** - No string parsing overhead, direct function composition
|
|
||||||
|
|
||||||
## Migration from constants.ts
|
|
||||||
|
|
||||||
The massive `createAnalysisPrompt` function in `constants.ts` has been refactored into this modular system:
|
|
||||||
|
|
||||||
**Before** (130+ lines in single function):
|
|
||||||
```typescript
|
|
||||||
export function createAnalysisPrompt(...) {
|
|
||||||
// Massive template string with embedded logic
|
|
||||||
return `You are analyzing...${incrementalSection}${toolChains}...`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After** (clean delegation):
|
|
||||||
```typescript
|
|
||||||
export function createAnalysisPrompt(...) {
|
|
||||||
return buildCompleteAnalysisPrompt(...);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This maintains backward compatibility while providing a much cleaner, more maintainable internal structure.
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* Analysis Templates for LLM Instructions
|
|
||||||
*
|
|
||||||
* Generates prompts for extracting memories from conversations and storing in Chroma
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Handlebars from 'handlebars';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// MAIN ANALYSIS PROMPT TEMPLATE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const ANALYSIS_PROMPT = `You are analyzing a Claude Code conversation transcript to create memories using the Chroma MCP memory system.
|
|
||||||
|
|
||||||
YOUR TASK:
|
|
||||||
1. Extract key learnings and accomplishments as natural language memories
|
|
||||||
2. Store memories using mcp__claude-mem__chroma_add_documents
|
|
||||||
3. Return a structured JSON response with the extracted summaries
|
|
||||||
|
|
||||||
WHAT TO EXTRACT:
|
|
||||||
- Technical implementations (functions, classes, APIs, databases)
|
|
||||||
- Design patterns and architectural decisions
|
|
||||||
- Bug fixes and problem solutions
|
|
||||||
- Workflows, processes, and integrations
|
|
||||||
- Performance optimizations and improvements
|
|
||||||
|
|
||||||
STORAGE INSTRUCTIONS:
|
|
||||||
Call mcp__claude-mem__chroma_add_documents with:
|
|
||||||
- collection_name: "claude_memories"
|
|
||||||
- documents: Array of natural language descriptions
|
|
||||||
- ids: ["{{projectPrefix}}_{{sessionId}}_1", "{{projectPrefix}}_{{sessionId}}_2", ...]
|
|
||||||
- metadatas: Array with fields:
|
|
||||||
* type: component/pattern/workflow/integration/concept/decision/tool/fix
|
|
||||||
* keywords: Comma-separated search terms
|
|
||||||
* context: Brief situation description
|
|
||||||
* timestamp: "{{timestamp}}"
|
|
||||||
* session_id: "{{sessionId}}"
|
|
||||||
|
|
||||||
ERROR HANDLING:
|
|
||||||
If you get "IDs already exist" errors, use mcp__claude-mem__chroma_update_documents instead.
|
|
||||||
If any tool calls fail, continue and return the JSON response anyway.
|
|
||||||
|
|
||||||
Project: {{projectPrefix}}
|
|
||||||
Session ID: {{sessionId}}
|
|
||||||
|
|
||||||
Conversation to compress:`;
|
|
||||||
|
|
||||||
// Compile template once
|
|
||||||
const compiledAnalysisPrompt = Handlebars.compile(ANALYSIS_PROMPT, { noEscape: true });
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// MAIN API FUNCTIONS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the comprehensive analysis prompt for memory extraction
|
|
||||||
*/
|
|
||||||
export function buildComprehensiveAnalysisPrompt(
|
|
||||||
projectPrefix: string,
|
|
||||||
sessionId: string,
|
|
||||||
timestamp?: string,
|
|
||||||
archiveFilename?: string
|
|
||||||
): string {
|
|
||||||
const context = {
|
|
||||||
projectPrefix,
|
|
||||||
sessionId,
|
|
||||||
timestamp: timestamp || new Date().toISOString(),
|
|
||||||
archiveFilename: archiveFilename || `${sessionId}.jsonl.archive`
|
|
||||||
};
|
|
||||||
|
|
||||||
return compiledAnalysisPrompt(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the analysis prompt
|
|
||||||
*/
|
|
||||||
export function createAnalysisPrompt(
|
|
||||||
transcript: string,
|
|
||||||
sessionId: string,
|
|
||||||
projectPrefix: string,
|
|
||||||
timestamp?: string
|
|
||||||
): string {
|
|
||||||
const prompt = buildComprehensiveAnalysisPrompt(
|
|
||||||
projectPrefix,
|
|
||||||
sessionId,
|
|
||||||
timestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
const responseFormat = `
|
|
||||||
|
|
||||||
RESPONSE FORMAT:
|
|
||||||
After storing memories in Chroma, return EXACTLY this JSON structure wrapped in tags:
|
|
||||||
|
|
||||||
<JSONResponse>
|
|
||||||
{
|
|
||||||
"overview": "2-3 sentence summary of session themes and accomplishments. Write for any developer to understand by organically defining jargon.",
|
|
||||||
"summaries": [
|
|
||||||
{
|
|
||||||
"text": "What was accomplished (start with action verb)",
|
|
||||||
"document_id": "${projectPrefix}_${sessionId}_1",
|
|
||||||
"keywords": "comma, separated, terms",
|
|
||||||
"timestamp": "${timestamp || new Date().toISOString()}",
|
|
||||||
"archive": "${sessionId}.jsonl.archive"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</JSONResponse>
|
|
||||||
|
|
||||||
IMPORTANT:
|
|
||||||
- Return 3-10 summaries based on conversation complexity
|
|
||||||
- Each summary should correspond to a memory you attempted to store
|
|
||||||
- If tool calls fail, still return the JSON response with summaries
|
|
||||||
- The JSON must be valid and complete
|
|
||||||
- Place NOTHING outside the <JSONResponse> tags
|
|
||||||
- Do not include any explanatory text before or after the JSON`;
|
|
||||||
|
|
||||||
return prompt + '\n\n' + transcript + responseFormat;
|
|
||||||
}
|
|
||||||
@@ -103,61 +103,6 @@ function makeLine(char: string = '─', width: number = getWrapWidth()): string
|
|||||||
// SESSION START MESSAGES
|
// SESSION START MESSAGES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a welcoming session start message explaining what memories were loaded
|
|
||||||
*/
|
|
||||||
export function createSessionStartMessage(
|
|
||||||
projectName: string,
|
|
||||||
memoryCount: number,
|
|
||||||
lastSessionTime?: string
|
|
||||||
): string {
|
|
||||||
const width = getWrapWidth();
|
|
||||||
const timeInfo = lastSessionTime ? ` (last worked: ${lastSessionTime})` : '';
|
|
||||||
|
|
||||||
if (memoryCount === 0) {
|
|
||||||
return wrapText(
|
|
||||||
`🧠 Loading memories from previous sessions for ${projectName}${timeInfo}
|
|
||||||
|
|
||||||
No relevant memories found - this appears to be your first session or a new project area.
|
|
||||||
|
|
||||||
💡 Getting Started:
|
|
||||||
• Start working and memories will be automatically created
|
|
||||||
• At the end of your session, ask to compress and store the conversation
|
|
||||||
• Next time you return, relevant context will be loaded automatically`,
|
|
||||||
width
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const memoryText =
|
|
||||||
memoryCount === 1 ? 'relevant memory' : 'relevant memories';
|
|
||||||
return wrapText(
|
|
||||||
`🧠 Loading memories from previous sessions for ${projectName}${timeInfo}
|
|
||||||
|
|
||||||
Found ${memoryCount} ${memoryText} to help continue your work.`,
|
|
||||||
width
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OPERATION MESSAGES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a loading message during context retrieval
|
|
||||||
*/
|
|
||||||
export function createLoadingMessage(operation: string): string {
|
|
||||||
const operations: Record<string, string> = {
|
|
||||||
searching: '🔍 Searching previous memories...',
|
|
||||||
loading: '📚 Loading relevant context...',
|
|
||||||
formatting: '✨ Organizing memories for display...',
|
|
||||||
compressing: '🗜️ Compressing session transcript...',
|
|
||||||
archiving: '📦 Archiving conversation...',
|
|
||||||
};
|
|
||||||
|
|
||||||
const width = getWrapWidth();
|
|
||||||
return wrapText(operations[operation] || `⏳ ${operation}...`, width);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a completion message after context operations
|
* Creates a completion message after context operations
|
||||||
*/
|
*/
|
||||||
@@ -263,28 +208,6 @@ export function formatTimeAgo(timestamp: string | Date): string {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates summary text for memory operations
|
|
||||||
*/
|
|
||||||
export function createOperationSummary(
|
|
||||||
operation: 'compress' | 'load' | 'search' | 'archive',
|
|
||||||
results: { count: number; duration?: number; details?: string }
|
|
||||||
): string {
|
|
||||||
const { count, duration, details } = results;
|
|
||||||
const durationText = duration ? ` in ${duration}ms` : '';
|
|
||||||
const detailsText = details ? ` - ${details}` : '';
|
|
||||||
|
|
||||||
const templates = {
|
|
||||||
compress: `Compressed ${count} conversation turns${durationText}${detailsText}`,
|
|
||||||
load: `Loaded ${count} relevant memories${durationText}${detailsText}`,
|
|
||||||
search: `Found ${count} matching memories${durationText}${detailsText}`,
|
|
||||||
archive: `Archived ${count} conversation segments${durationText}${detailsText}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const width = getWrapWidth();
|
|
||||||
return wrapText(`📊 ${templates[operation]}`, width);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SESSION START TEMPLATE SYSTEM (data processing only)
|
// SESSION START TEMPLATE SYSTEM (data processing only)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -308,55 +231,6 @@ interface SessionGroup {
|
|||||||
memories: MemoryEntry[];
|
memories: MemoryEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats current date and time for session start
|
|
||||||
*/
|
|
||||||
export function formatCurrentDateTime(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const currentDateTime = now.toLocaleString('en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
timeZoneName: 'short',
|
|
||||||
});
|
|
||||||
|
|
||||||
return `Current Date / Time: ${currentDateTime}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts overview section from JSON objects
|
|
||||||
* Looks for objects with type "overview" and matching project
|
|
||||||
*/
|
|
||||||
export function extractOverview(
|
|
||||||
recentObjects: any[],
|
|
||||||
projectName?: string
|
|
||||||
): string | null {
|
|
||||||
// Find overview objects
|
|
||||||
const overviewObjects = recentObjects.filter(
|
|
||||||
(obj) => obj.type === 'overview'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (overviewObjects.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If project is specified, find overview for that project
|
|
||||||
if (projectName) {
|
|
||||||
const projectOverview = overviewObjects.find(
|
|
||||||
(obj) => obj.project === projectName
|
|
||||||
);
|
|
||||||
if (projectOverview) {
|
|
||||||
return projectOverview.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the most recent overview if no project match
|
|
||||||
return overviewObjects[overviewObjects.length - 1]?.content || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for overview with timestamp
|
* Interface for overview with timestamp
|
||||||
*/
|
*/
|
||||||
@@ -377,66 +251,11 @@ interface SessionOverviewGroup {
|
|||||||
timeAgo?: string;
|
timeAgo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts multiple overviews with timestamps
|
|
||||||
* Returns up to 'count' most recent overviews
|
|
||||||
*/
|
|
||||||
export function extractOverviews(
|
|
||||||
recentObjects: any[],
|
|
||||||
count: number = 3,
|
|
||||||
projectName?: string
|
|
||||||
): OverviewEntry[] {
|
|
||||||
// Find overview objects
|
|
||||||
const overviewObjects = recentObjects.filter(
|
|
||||||
(obj) => obj.type === 'overview'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (overviewObjects.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by project if specified
|
|
||||||
let filteredOverviews = overviewObjects;
|
|
||||||
if (projectName) {
|
|
||||||
filteredOverviews = overviewObjects.filter(
|
|
||||||
(obj) => obj.project === projectName
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fall back to all overviews if no project match
|
|
||||||
if (filteredOverviews.length === 0) {
|
|
||||||
filteredOverviews = overviewObjects;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take the last 'count' overviews
|
|
||||||
const recentOverviews = filteredOverviews.slice(-count);
|
|
||||||
|
|
||||||
// Process each overview with timestamp and session ID
|
|
||||||
return recentOverviews.map((obj) => {
|
|
||||||
const entry: OverviewEntry = {
|
|
||||||
content: obj.content || '',
|
|
||||||
sessionId: obj.sessionId || obj.session_id || 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to parse timestamp
|
|
||||||
const timestamp = parseTimestamp(obj);
|
|
||||||
if (timestamp) {
|
|
||||||
entry.timestamp = timestamp;
|
|
||||||
entry.timeAgo = formatRelativeTime(timestamp);
|
|
||||||
} else {
|
|
||||||
// Fallback if no timestamp
|
|
||||||
entry.timeAgo = 'Recently';
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}); // Show in original order (oldest to newest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure data processing function - converts JSON objects into structured memory entries
|
* Pure data processing function - converts JSON objects into structured memory entries
|
||||||
* No formatting is done here, only data parsing and cleaning
|
* No formatting is done here, only data parsing and cleaning
|
||||||
*/
|
*/
|
||||||
export function processMemoryEntries(recentObjects: any[]): MemoryEntry[] {
|
function processMemoryEntries(recentObjects: any[]): MemoryEntry[] {
|
||||||
if (recentObjects.length === 0) {
|
if (recentObjects.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook Templates Test
|
|
||||||
*
|
|
||||||
* Basic validation tests for hook response templates to ensure they
|
|
||||||
* generate valid responses that conform to Claude Code's hook system.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
createPreCompactSuccessResponse,
|
|
||||||
createPreCompactBlockedResponse,
|
|
||||||
createPreCompactApprovalResponse,
|
|
||||||
createSessionStartSuccessResponse,
|
|
||||||
createSessionStartEmptyResponse,
|
|
||||||
createSessionStartErrorResponse,
|
|
||||||
createSessionStartMemoryResponse,
|
|
||||||
createPreToolUseAllowResponse,
|
|
||||||
createPreToolUseDenyResponse,
|
|
||||||
createPreToolUseAskResponse,
|
|
||||||
createHookSuccessResponse,
|
|
||||||
createHookErrorResponse,
|
|
||||||
validateHookResponse,
|
|
||||||
createContextualHookResponse,
|
|
||||||
formatDuration,
|
|
||||||
createOperationSummary,
|
|
||||||
OPERATION_STATUS_TEMPLATES,
|
|
||||||
ERROR_RESPONSE_TEMPLATES
|
|
||||||
} from './HookTemplates.js';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PRE-COMPACT HOOK TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('Testing Pre-Compact Hook Templates...');
|
|
||||||
|
|
||||||
// Test successful pre-compact response
|
|
||||||
const preCompactSuccess = createPreCompactSuccessResponse();
|
|
||||||
console.log('✓ Pre-compact success:', JSON.stringify(preCompactSuccess, null, 2));
|
|
||||||
|
|
||||||
// Test blocked pre-compact response
|
|
||||||
const preCompactBlocked = createPreCompactBlockedResponse('User requested to skip compression');
|
|
||||||
console.log('✓ Pre-compact blocked:', JSON.stringify(preCompactBlocked, null, 2));
|
|
||||||
|
|
||||||
// Test approval response
|
|
||||||
const preCompactApproval = createPreCompactApprovalResponse('approve', 'Compression approved by policy');
|
|
||||||
console.log('✓ Pre-compact approval:', JSON.stringify(preCompactApproval, null, 2));
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// SESSION START HOOK TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('\nTesting Session Start Hook Templates...');
|
|
||||||
|
|
||||||
// Test successful session start with context
|
|
||||||
const sessionStartSuccess = createSessionStartSuccessResponse('Loaded 5 memories from previous sessions');
|
|
||||||
console.log('✓ Session start success:', JSON.stringify(sessionStartSuccess, null, 2));
|
|
||||||
|
|
||||||
// Test empty session start
|
|
||||||
const sessionStartEmpty = createSessionStartEmptyResponse();
|
|
||||||
console.log('✓ Session start empty:', JSON.stringify(sessionStartEmpty, null, 2));
|
|
||||||
|
|
||||||
// Test error session start
|
|
||||||
const sessionStartError = createSessionStartErrorResponse('Memory index corrupted');
|
|
||||||
console.log('✓ Session start error:', JSON.stringify(sessionStartError, null, 2));
|
|
||||||
|
|
||||||
// Test rich memory response
|
|
||||||
const sessionStartMemory = createSessionStartMemoryResponse({
|
|
||||||
projectName: 'claude-mem',
|
|
||||||
memoryCount: 12,
|
|
||||||
lastSessionTime: '2 hours ago',
|
|
||||||
recentComponents: ['PromptOrchestrator', 'HookTemplates', 'MCPClient'],
|
|
||||||
recentDecisions: ['Use TypeScript for type safety', 'Implement embedded Weaviate']
|
|
||||||
});
|
|
||||||
console.log('✓ Session start memory:', JSON.stringify(sessionStartMemory, null, 2));
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PRE-TOOL USE HOOK TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('\nTesting Pre-Tool Use Hook Templates...');
|
|
||||||
|
|
||||||
// Test allow response
|
|
||||||
const preToolAllow = createPreToolUseAllowResponse('Tool execution approved by security policy');
|
|
||||||
console.log('✓ Pre-tool allow:', JSON.stringify(preToolAllow, null, 2));
|
|
||||||
|
|
||||||
// Test deny response
|
|
||||||
const preToolDeny = createPreToolUseDenyResponse('Bash commands disabled in restricted mode');
|
|
||||||
console.log('✓ Pre-tool deny:', JSON.stringify(preToolDeny, null, 2));
|
|
||||||
|
|
||||||
// Test ask response
|
|
||||||
const preToolAsk = createPreToolUseAskResponse('File operation requires user confirmation');
|
|
||||||
console.log('✓ Pre-tool ask:', JSON.stringify(preToolAsk, null, 2));
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// GENERIC HOOK TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('\nTesting Generic Hook Templates...');
|
|
||||||
|
|
||||||
// Test basic success
|
|
||||||
const genericSuccess = createHookSuccessResponse(false);
|
|
||||||
console.log('✓ Generic success:', JSON.stringify(genericSuccess, null, 2));
|
|
||||||
|
|
||||||
// Test basic error
|
|
||||||
const genericError = createHookErrorResponse('Operation failed due to network timeout', true);
|
|
||||||
console.log('✓ Generic error:', JSON.stringify(genericError, null, 2));
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// VALIDATION TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('\nTesting Hook Response Validation...');
|
|
||||||
|
|
||||||
// Test valid PreCompact response
|
|
||||||
const preCompactValidation = validateHookResponse(preCompactSuccess, 'PreCompact');
|
|
||||||
console.log('✓ PreCompact validation:', preCompactValidation);
|
|
||||||
|
|
||||||
// Test invalid PreCompact response (with hookSpecificOutput)
|
|
||||||
const invalidPreCompact = {
|
|
||||||
continue: true,
|
|
||||||
hookSpecificOutput: { hookEventName: 'PreCompact' }
|
|
||||||
};
|
|
||||||
const preCompactInvalidValidation = validateHookResponse(invalidPreCompact, 'PreCompact');
|
|
||||||
console.log('✓ PreCompact invalid validation:', preCompactInvalidValidation);
|
|
||||||
|
|
||||||
// Test valid SessionStart response
|
|
||||||
const sessionStartValidation = validateHookResponse(sessionStartSuccess, 'SessionStart');
|
|
||||||
console.log('✓ SessionStart validation:', sessionStartValidation);
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// CONTEXTUAL HOOK RESPONSE TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('\nTesting Contextual Hook Responses...');
|
|
||||||
|
|
||||||
// Test successful session start context
|
|
||||||
const contextualSessionStart = createContextualHookResponse({
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
sessionId: 'test-123',
|
|
||||||
success: true,
|
|
||||||
message: 'Successfully loaded 8 memories from previous claude-mem sessions'
|
|
||||||
});
|
|
||||||
console.log('✓ Contextual SessionStart:', JSON.stringify(contextualSessionStart, null, 2));
|
|
||||||
|
|
||||||
// Test failed PreCompact context
|
|
||||||
const contextualPreCompactFail = createContextualHookResponse({
|
|
||||||
hookEventName: 'PreCompact',
|
|
||||||
sessionId: 'test-123',
|
|
||||||
success: false,
|
|
||||||
message: 'Compression blocked: insufficient disk space'
|
|
||||||
});
|
|
||||||
console.log('✓ Contextual PreCompact fail:', JSON.stringify(contextualPreCompactFail, null, 2));
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// UTILITY FUNCTION TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('\nTesting Utility Functions...');
|
|
||||||
|
|
||||||
// Test duration formatting
|
|
||||||
console.log('✓ Duration 500ms:', formatDuration(500));
|
|
||||||
console.log('✓ Duration 5s:', formatDuration(5000));
|
|
||||||
console.log('✓ Duration 90s:', formatDuration(90000));
|
|
||||||
console.log('✓ Duration 2m30s:', formatDuration(150000));
|
|
||||||
|
|
||||||
// Test operation summary
|
|
||||||
console.log('✓ Operation summary success:', createOperationSummary('Memory compression', true, 5000, 15, 'entities extracted'));
|
|
||||||
console.log('✓ Operation summary failure:', createOperationSummary('Context loading', false, 2000, 0, 'connection timeout'));
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// TEMPLATE CONSTANT TESTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
console.log('\nTesting Template Constants...');
|
|
||||||
|
|
||||||
// Test operation status templates
|
|
||||||
console.log('✓ Compression complete:', OPERATION_STATUS_TEMPLATES.COMPRESSION_COMPLETE(25, 5000));
|
|
||||||
console.log('✓ Context loaded:', OPERATION_STATUS_TEMPLATES.CONTEXT_LOADED(8));
|
|
||||||
console.log('✓ Tool allowed:', OPERATION_STATUS_TEMPLATES.TOOL_ALLOWED('Bash'));
|
|
||||||
|
|
||||||
// Test error response templates
|
|
||||||
console.log('✓ File not found:', ERROR_RESPONSE_TEMPLATES.FILE_NOT_FOUND('/path/to/transcript.txt'));
|
|
||||||
console.log('✓ Connection failed:', ERROR_RESPONSE_TEMPLATES.CONNECTION_FAILED('MCP memory server'));
|
|
||||||
console.log('✓ Operation timeout:', ERROR_RESPONSE_TEMPLATES.OPERATION_TIMEOUT('compression', 30000));
|
|
||||||
|
|
||||||
console.log('\n✅ All hook template tests completed successfully!');
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook Templates for System Integration
|
|
||||||
*
|
|
||||||
* This module provides standardized templates for hook responses that integrate
|
|
||||||
* with Claude Code's hook system. These templates ensure consistent formatting
|
|
||||||
* and proper JSON structure for different hook events.
|
|
||||||
*
|
|
||||||
* Based on Claude Code Hook Documentation v2025
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
BaseHookResponse,
|
|
||||||
PreCompactResponse,
|
|
||||||
SessionStartResponse,
|
|
||||||
PreToolUseResponse,
|
|
||||||
HookPayload,
|
|
||||||
PreCompactPayload,
|
|
||||||
SessionStartPayload
|
|
||||||
} from '../../../shared/types.js';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HOOK RESPONSE INTERFACES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context data for generating hook responses
|
|
||||||
*/
|
|
||||||
export interface HookResponseContext {
|
|
||||||
/** The hook event name */
|
|
||||||
hookEventName: string;
|
|
||||||
/** Session identifier */
|
|
||||||
sessionId: string;
|
|
||||||
/** Whether the operation was successful */
|
|
||||||
success: boolean;
|
|
||||||
/** Optional message for the response */
|
|
||||||
message?: string;
|
|
||||||
/** Additional data specific to the hook type */
|
|
||||||
additionalData?: Record<string, unknown>;
|
|
||||||
/** Duration of the operation in milliseconds */
|
|
||||||
duration?: number;
|
|
||||||
/** Number of items processed */
|
|
||||||
itemCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Progress information for long-running operations
|
|
||||||
*/
|
|
||||||
export interface OperationProgress {
|
|
||||||
/** Current step number */
|
|
||||||
current: number;
|
|
||||||
/** Total number of steps */
|
|
||||||
total: number;
|
|
||||||
/** Description of current step */
|
|
||||||
currentStep?: string;
|
|
||||||
/** Estimated time remaining in milliseconds */
|
|
||||||
estimatedRemaining?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PRE-COMPACT HOOK TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a successful pre-compact response that allows compression to proceed
|
|
||||||
* PreCompact hooks do NOT support hookSpecificOutput according to documentation
|
|
||||||
*/
|
|
||||||
export function createPreCompactSuccessResponse(): PreCompactResponse {
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
suppressOutput: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a blocked pre-compact response that prevents compression
|
|
||||||
*/
|
|
||||||
export function createPreCompactBlockedResponse(reason: string): PreCompactResponse {
|
|
||||||
return {
|
|
||||||
continue: false,
|
|
||||||
stopReason: reason,
|
|
||||||
suppressOutput: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a pre-compact response with approval decision
|
|
||||||
*/
|
|
||||||
export function createPreCompactApprovalResponse(
|
|
||||||
decision: 'approve' | 'block',
|
|
||||||
reason?: string
|
|
||||||
): PreCompactResponse {
|
|
||||||
return {
|
|
||||||
decision,
|
|
||||||
reason,
|
|
||||||
continue: decision === 'approve',
|
|
||||||
suppressOutput: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// SESSION START HOOK TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a successful session start response with loaded context
|
|
||||||
* SessionStart hooks DO support hookSpecificOutput
|
|
||||||
*/
|
|
||||||
export function createSessionStartSuccessResponse(
|
|
||||||
additionalContext?: string
|
|
||||||
): SessionStartResponse {
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
suppressOutput: true,
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
additionalContext
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a session start response when no context is available
|
|
||||||
*/
|
|
||||||
export function createSessionStartEmptyResponse(): SessionStartResponse {
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
suppressOutput: true,
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
additionalContext: 'Starting fresh session - no previous context available'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a session start response with error information
|
|
||||||
*/
|
|
||||||
export function createSessionStartErrorResponse(error: string): SessionStartResponse {
|
|
||||||
return {
|
|
||||||
continue: true, // Continue even if context loading fails
|
|
||||||
suppressOutput: true,
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
additionalContext: `Context loading encountered an issue: ${error}. Starting without previous context.`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a rich session start response with memory summary
|
|
||||||
*/
|
|
||||||
export function createSessionStartMemoryResponse(memoryData: {
|
|
||||||
projectName: string;
|
|
||||||
memoryCount: number;
|
|
||||||
lastSessionTime?: string;
|
|
||||||
recentComponents?: string[];
|
|
||||||
recentDecisions?: string[];
|
|
||||||
}): SessionStartResponse {
|
|
||||||
const { projectName, memoryCount, lastSessionTime, recentComponents = [], recentDecisions = [] } = memoryData;
|
|
||||||
|
|
||||||
const timeInfo = lastSessionTime ? ` (last worked: ${lastSessionTime})` : '';
|
|
||||||
const contextParts: string[] = [];
|
|
||||||
|
|
||||||
contextParts.push(`🧠 Loaded ${memoryCount} memories from previous sessions for ${projectName}${timeInfo}`);
|
|
||||||
|
|
||||||
if (recentComponents.length > 0) {
|
|
||||||
contextParts.push(`\n🎯 Recent components: ${recentComponents.slice(0, 3).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentDecisions.length > 0) {
|
|
||||||
contextParts.push(`\n🔄 Recent decisions: ${recentDecisions.slice(0, 2).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
contextParts.push('\n💡 Use chroma_query_documents(["keywords"]) to find related work or chroma_get_documents(["document_id"]) to load specific content');
|
|
||||||
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
suppressOutput: true,
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
additionalContext: contextParts.join('')
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PRE-TOOL USE HOOK TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a pre-tool use response that allows the tool to execute
|
|
||||||
*/
|
|
||||||
export function createPreToolUseAllowResponse(reason?: string): PreToolUseResponse {
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
suppressOutput: true,
|
|
||||||
permissionDecision: 'allow',
|
|
||||||
permissionDecisionReason: reason
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a pre-tool use response that blocks the tool execution
|
|
||||||
*/
|
|
||||||
export function createPreToolUseDenyResponse(reason: string): PreToolUseResponse {
|
|
||||||
return {
|
|
||||||
continue: false,
|
|
||||||
stopReason: reason,
|
|
||||||
suppressOutput: true,
|
|
||||||
permissionDecision: 'deny',
|
|
||||||
permissionDecisionReason: reason
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a pre-tool use response that asks for user confirmation
|
|
||||||
*/
|
|
||||||
export function createPreToolUseAskResponse(reason: string): PreToolUseResponse {
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
suppressOutput: false, // Show output so user can see the question
|
|
||||||
permissionDecision: 'ask',
|
|
||||||
permissionDecisionReason: reason
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// GENERIC HOOK RESPONSE TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a basic success response for any hook type
|
|
||||||
*/
|
|
||||||
export function createHookSuccessResponse(suppressOutput = true): BaseHookResponse {
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
suppressOutput
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a basic error response for any hook type
|
|
||||||
*/
|
|
||||||
export function createHookErrorResponse(
|
|
||||||
reason: string,
|
|
||||||
suppressOutput = true
|
|
||||||
): BaseHookResponse {
|
|
||||||
return {
|
|
||||||
continue: false,
|
|
||||||
stopReason: reason,
|
|
||||||
suppressOutput
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a response with system message (warning/info for user)
|
|
||||||
*/
|
|
||||||
export function createHookSystemMessageResponse(
|
|
||||||
message: string,
|
|
||||||
continueProcessing = true
|
|
||||||
): BaseHookResponse & { systemMessage: string } {
|
|
||||||
return {
|
|
||||||
continue: continueProcessing,
|
|
||||||
suppressOutput: true,
|
|
||||||
systemMessage: message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OPERATION STATUS TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Templates for different types of operation status messages
|
|
||||||
*/
|
|
||||||
export const OPERATION_STATUS_TEMPLATES = {
|
|
||||||
// Compression operations
|
|
||||||
COMPRESSION_STARTED: 'Starting memory compression...',
|
|
||||||
COMPRESSION_ANALYZING: 'Analyzing transcript content...',
|
|
||||||
COMPRESSION_EXTRACTING: 'Extracting memories and connections...',
|
|
||||||
COMPRESSION_SAVING: 'Saving compressed memories...',
|
|
||||||
COMPRESSION_COMPLETE: (count: number, duration?: number) =>
|
|
||||||
`Memory compression complete. Extracted ${count} memories${duration ? ` in ${Math.round(duration/1000)}s` : ''}`,
|
|
||||||
|
|
||||||
// Context loading operations
|
|
||||||
CONTEXT_LOADING: 'Loading previous session context...',
|
|
||||||
CONTEXT_SEARCHING: 'Searching for relevant memories...',
|
|
||||||
CONTEXT_FORMATTING: 'Organizing context for display...',
|
|
||||||
CONTEXT_LOADED: (count: number) => `Context loaded successfully. Found ${count} relevant memories`,
|
|
||||||
CONTEXT_EMPTY: 'No previous context found. Starting fresh session',
|
|
||||||
|
|
||||||
// Tool operations
|
|
||||||
TOOL_CHECKING: (toolName: string) => `Checking permissions for ${toolName}...`,
|
|
||||||
TOOL_ALLOWED: (toolName: string) => `✅ ${toolName} execution approved`,
|
|
||||||
TOOL_BLOCKED: (toolName: string, reason: string) => `❌ ${toolName} blocked: ${reason}`,
|
|
||||||
|
|
||||||
// General operations
|
|
||||||
OPERATION_STARTING: (operation: string) => `Starting ${operation}...`,
|
|
||||||
OPERATION_PROGRESS: (operation: string, current: number, total: number) =>
|
|
||||||
`${operation}: ${current}/${total} (${Math.round((current/total)*100)}%)`,
|
|
||||||
OPERATION_COMPLETE: (operation: string) => `✅ ${operation} completed successfully`,
|
|
||||||
OPERATION_FAILED: (operation: string, error: string) => `❌ ${operation} failed: ${error}`
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a progress message for long-running operations
|
|
||||||
*/
|
|
||||||
export function createProgressMessage(
|
|
||||||
operation: string,
|
|
||||||
progress: OperationProgress
|
|
||||||
): string {
|
|
||||||
const { current, total, currentStep, estimatedRemaining } = progress;
|
|
||||||
const percentage = Math.round((current / total) * 100);
|
|
||||||
|
|
||||||
let message = `${operation}: ${current}/${total} (${percentage}%)`;
|
|
||||||
|
|
||||||
if (currentStep) {
|
|
||||||
message += ` - ${currentStep}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estimatedRemaining && estimatedRemaining > 1000) {
|
|
||||||
const seconds = Math.round(estimatedRemaining / 1000);
|
|
||||||
message += ` (${seconds}s remaining)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ERROR RESPONSE TEMPLATES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard error messages for different failure scenarios
|
|
||||||
*/
|
|
||||||
export const ERROR_RESPONSE_TEMPLATES = {
|
|
||||||
// File system errors
|
|
||||||
FILE_NOT_FOUND: (path: string) => `File not found: ${path}`,
|
|
||||||
FILE_READ_ERROR: (path: string, error: string) => `Failed to read ${path}: ${error}`,
|
|
||||||
FILE_WRITE_ERROR: (path: string, error: string) => `Failed to write ${path}: ${error}`,
|
|
||||||
|
|
||||||
// Network/connection errors
|
|
||||||
CONNECTION_FAILED: (service: string) => `Failed to connect to ${service}`,
|
|
||||||
CONNECTION_TIMEOUT: (service: string) => `Connection to ${service} timed out`,
|
|
||||||
|
|
||||||
// Validation errors
|
|
||||||
INVALID_PAYLOAD: (field: string) => `Invalid or missing field: ${field}`,
|
|
||||||
INVALID_FORMAT: (expected: string, received: string) => `Expected ${expected}, received ${received}`,
|
|
||||||
|
|
||||||
// Operation errors
|
|
||||||
OPERATION_TIMEOUT: (operation: string, timeout: number) =>
|
|
||||||
`${operation} timed out after ${timeout}ms`,
|
|
||||||
OPERATION_CANCELLED: (operation: string) => `${operation} was cancelled`,
|
|
||||||
INSUFFICIENT_PERMISSIONS: (operation: string) =>
|
|
||||||
`Insufficient permissions for ${operation}`,
|
|
||||||
|
|
||||||
// Memory system errors
|
|
||||||
MEMORY_SYSTEM_UNAVAILABLE: 'Memory system is not available',
|
|
||||||
MEMORY_CORRUPTION: 'Memory index appears to be corrupted',
|
|
||||||
MEMORY_SEARCH_FAILED: (query: string) => `Memory search failed for query: "${query}"`,
|
|
||||||
|
|
||||||
// Compression errors
|
|
||||||
COMPRESSION_FAILED: (stage: string) => `Compression failed during ${stage}`,
|
|
||||||
INVALID_TRANSCRIPT: 'Transcript file is invalid or corrupted',
|
|
||||||
|
|
||||||
// General errors
|
|
||||||
UNKNOWN_ERROR: (context: string) => `An unexpected error occurred during ${context}`,
|
|
||||||
SYSTEM_ERROR: (error: string) => `System error: ${error}`
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a standardized error response with troubleshooting guidance
|
|
||||||
*/
|
|
||||||
export function createDetailedErrorResponse(
|
|
||||||
operation: string,
|
|
||||||
error: string,
|
|
||||||
troubleshootingSteps: string[] = []
|
|
||||||
): BaseHookResponse {
|
|
||||||
const baseMessage = `${operation} failed: ${error}`;
|
|
||||||
|
|
||||||
const fullMessage = troubleshootingSteps.length > 0
|
|
||||||
? `${baseMessage}\n\nTroubleshooting steps:\n${troubleshootingSteps.map(step => `• ${step}`).join('\n')}`
|
|
||||||
: baseMessage;
|
|
||||||
|
|
||||||
return {
|
|
||||||
continue: false,
|
|
||||||
stopReason: fullMessage,
|
|
||||||
suppressOutput: false // Show error details to user
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HOOK RESPONSE VALIDATION
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a hook response conforms to Claude Code expectations
|
|
||||||
*/
|
|
||||||
export function validateHookResponse(
|
|
||||||
response: any,
|
|
||||||
hookType: string
|
|
||||||
): { isValid: boolean; errors: string[] } {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
// Check required fields
|
|
||||||
if (typeof response !== 'object' || response === null) {
|
|
||||||
errors.push('Response must be a valid JSON object');
|
|
||||||
return { isValid: false, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate continue field
|
|
||||||
if (response.continue !== undefined && typeof response.continue !== 'boolean') {
|
|
||||||
errors.push('continue field must be a boolean');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate suppressOutput field
|
|
||||||
if (response.suppressOutput !== undefined && typeof response.suppressOutput !== 'boolean') {
|
|
||||||
errors.push('suppressOutput field must be a boolean');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate stopReason field
|
|
||||||
if (response.stopReason !== undefined && typeof response.stopReason !== 'string') {
|
|
||||||
errors.push('stopReason field must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook-specific validations
|
|
||||||
if (hookType === 'PreCompact') {
|
|
||||||
// PreCompact should not have hookSpecificOutput
|
|
||||||
if (response.hookSpecificOutput !== undefined) {
|
|
||||||
errors.push('PreCompact hooks do not support hookSpecificOutput');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate decision field if present
|
|
||||||
if (response.decision !== undefined && !['approve', 'block'].includes(response.decision)) {
|
|
||||||
errors.push('decision field must be "approve" or "block"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hookType === 'SessionStart') {
|
|
||||||
// Validate hookSpecificOutput structure
|
|
||||||
if (response.hookSpecificOutput) {
|
|
||||||
const hso = response.hookSpecificOutput;
|
|
||||||
if (hso.hookEventName !== 'SessionStart') {
|
|
||||||
errors.push('hookSpecificOutput.hookEventName must be "SessionStart"');
|
|
||||||
}
|
|
||||||
if (hso.additionalContext !== undefined && typeof hso.additionalContext !== 'string') {
|
|
||||||
errors.push('hookSpecificOutput.additionalContext must be a string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hookType === 'PreToolUse') {
|
|
||||||
// Validate permissionDecision field
|
|
||||||
if (response.permissionDecision !== undefined) {
|
|
||||||
if (!['allow', 'deny', 'ask'].includes(response.permissionDecision)) {
|
|
||||||
errors.push('permissionDecision must be "allow", "deny", or "ask"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: errors.length === 0,
|
|
||||||
errors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// UTILITY FUNCTIONS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a hook response based on context and automatically handles hook-specific formatting
|
|
||||||
*/
|
|
||||||
export function createContextualHookResponse(context: HookResponseContext): BaseHookResponse {
|
|
||||||
const { hookEventName, success, message, additionalData, duration, itemCount } = context;
|
|
||||||
|
|
||||||
// Base response
|
|
||||||
const response: BaseHookResponse = {
|
|
||||||
continue: success,
|
|
||||||
suppressOutput: true
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add failure reason if not successful
|
|
||||||
if (!success && message) {
|
|
||||||
response.stopReason = message;
|
|
||||||
response.suppressOutput = false; // Show error to user
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle hook-specific output
|
|
||||||
if (success && hookEventName === 'SessionStart' && message) {
|
|
||||||
return {
|
|
||||||
...response,
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'SessionStart',
|
|
||||||
additionalContext: message
|
|
||||||
}
|
|
||||||
} as SessionStartResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle PreCompact approval
|
|
||||||
if (hookEventName === 'PreCompact') {
|
|
||||||
return {
|
|
||||||
...response,
|
|
||||||
decision: success ? 'approve' : 'block',
|
|
||||||
reason: message
|
|
||||||
} as PreCompactResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats duration in milliseconds to human-readable format
|
|
||||||
*/
|
|
||||||
export function formatDuration(milliseconds: number): string {
|
|
||||||
if (milliseconds < 1000) {
|
|
||||||
return `${milliseconds}ms`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seconds = Math.round(milliseconds / 1000);
|
|
||||||
if (seconds < 60) {
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = seconds % 60;
|
|
||||||
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a summary line for operation completion
|
|
||||||
*/
|
|
||||||
export function createOperationSummary(
|
|
||||||
operation: string,
|
|
||||||
success: boolean,
|
|
||||||
duration?: number,
|
|
||||||
itemCount?: number,
|
|
||||||
details?: string
|
|
||||||
): string {
|
|
||||||
const status = success ? '✅' : '❌';
|
|
||||||
const durationText = duration ? ` in ${formatDuration(duration)}` : '';
|
|
||||||
const itemText = itemCount ? ` (${itemCount} items)` : '';
|
|
||||||
const detailText = details ? ` - ${details}` : '';
|
|
||||||
|
|
||||||
return `${status} ${operation}${itemText}${durationText}${detailText}`;
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import { TranscriptParser } from './transcript-parser.js';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conversation item for selection UI
|
|
||||||
*/
|
|
||||||
export interface ConversationItem {
|
|
||||||
filePath: string;
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: string;
|
|
||||||
messageCount: number;
|
|
||||||
gitBranch?: string;
|
|
||||||
cwd: string;
|
|
||||||
fileSize: number;
|
|
||||||
displayName: string;
|
|
||||||
projectName: string;
|
|
||||||
parsedDate: Date;
|
|
||||||
relativeDate: string;
|
|
||||||
dateGroup: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selection result
|
|
||||||
*/
|
|
||||||
export interface SelectionResult {
|
|
||||||
selectedFiles: string[];
|
|
||||||
cancelled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interactive conversation selector service
|
|
||||||
*/
|
|
||||||
export class ConversationSelector {
|
|
||||||
private parser: TranscriptParser;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.parser = new TranscriptParser();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show interactive selection UI for conversations with improved flow
|
|
||||||
*/
|
|
||||||
async selectConversations(): Promise<SelectionResult> {
|
|
||||||
p.intro('🧠 Claude History Import');
|
|
||||||
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Scanning for conversation files...');
|
|
||||||
|
|
||||||
const conversationFiles = await this.parser.scanConversationFiles();
|
|
||||||
|
|
||||||
if (conversationFiles.length === 0) {
|
|
||||||
s.stop('❌ No conversation files found');
|
|
||||||
p.outro('No conversation files found in Claude projects directory');
|
|
||||||
return { selectedFiles: [], cancelled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get metadata for each file
|
|
||||||
const conversations: ConversationItem[] = [];
|
|
||||||
for (const filePath of conversationFiles) {
|
|
||||||
try {
|
|
||||||
const metadata = await this.parser.getConversationMetadata(filePath);
|
|
||||||
const projectName = this.extractProjectName(filePath);
|
|
||||||
const parsedDate = this.parseTimestamp(metadata.timestamp, filePath);
|
|
||||||
const relativeDate = this.formatRelativeDate(parsedDate);
|
|
||||||
const dateGroup = this.getDateGroup(parsedDate);
|
|
||||||
|
|
||||||
conversations.push({
|
|
||||||
filePath,
|
|
||||||
...metadata,
|
|
||||||
projectName,
|
|
||||||
parsedDate,
|
|
||||||
relativeDate,
|
|
||||||
dateGroup,
|
|
||||||
displayName: this.createDisplayName(filePath, metadata)
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Skip invalid files silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conversations.length === 0) {
|
|
||||||
s.stop('❌ No valid conversation files found');
|
|
||||||
p.outro('No valid conversation files found');
|
|
||||||
return { selectedFiles: [], cancelled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
s.stop(`Found ${conversations.length} conversation files`);
|
|
||||||
|
|
||||||
// Sort by timestamp (newest first)
|
|
||||||
conversations.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
|
|
||||||
|
|
||||||
// If there are too many conversations, offer filtering options first
|
|
||||||
let filteredConversations = conversations;
|
|
||||||
if (conversations.length > 100) {
|
|
||||||
const filterChoice = await p.select({
|
|
||||||
message: `Found ${conversations.length} conversations. How would you like to proceed?`,
|
|
||||||
options: [
|
|
||||||
{ value: 'recent', label: 'Show recent (last 50)', hint: 'Most recent conversations' },
|
|
||||||
{ value: 'project', label: 'Filter by project', hint: 'Select specific project first' },
|
|
||||||
{ value: 'all', label: 'Show all', hint: `Display all ${conversations.length} conversations` }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(filterChoice)) {
|
|
||||||
p.cancel('Selection cancelled');
|
|
||||||
return { selectedFiles: [], cancelled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterChoice === 'recent') {
|
|
||||||
filteredConversations = conversations.slice(0, 50);
|
|
||||||
} else if (filterChoice === 'project') {
|
|
||||||
const projectNames = [...new Set(conversations.map(c => c.projectName))].sort();
|
|
||||||
const selectedProject = await p.select({
|
|
||||||
message: 'Select project:',
|
|
||||||
options: projectNames.map(project => {
|
|
||||||
const count = conversations.filter(c => c.projectName === project).length;
|
|
||||||
return {
|
|
||||||
value: project,
|
|
||||||
label: project,
|
|
||||||
hint: `${count} conversation${count === 1 ? '' : 's'}`
|
|
||||||
};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selectedProject)) {
|
|
||||||
p.cancel('Selection cancelled');
|
|
||||||
return { selectedFiles: [], cancelled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredConversations = conversations.filter(c => c.projectName === selectedProject);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conversation selection
|
|
||||||
const selectedConversations = await this.selectConversationsFromList(filteredConversations);
|
|
||||||
if (!selectedConversations || selectedConversations.length === 0) {
|
|
||||||
p.cancel('No conversations selected');
|
|
||||||
return { selectedFiles: [], cancelled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirmation
|
|
||||||
const confirmed = await this.confirmSelection(selectedConversations);
|
|
||||||
if (!confirmed) {
|
|
||||||
p.cancel('Import cancelled');
|
|
||||||
return { selectedFiles: [], cancelled: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
p.outro(`Ready to import ${selectedConversations.length} conversations`);
|
|
||||||
return { selectedFiles: selectedConversations.map(c => c.filePath), cancelled: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract project name from file path
|
|
||||||
*/
|
|
||||||
private extractProjectName(filePath: string): string {
|
|
||||||
return path.basename(path.dirname(filePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely parse timestamp with fallback to file modification time
|
|
||||||
*/
|
|
||||||
private parseTimestamp(timestamp: string | undefined, filePath: string): Date {
|
|
||||||
// Try parsing the provided timestamp
|
|
||||||
if (timestamp) {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to file modification time
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
return stats.mtime;
|
|
||||||
} catch (e) {
|
|
||||||
// Last resort: current time
|
|
||||||
return new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date as relative time (e.g., "2 days ago", "3 weeks ago")
|
|
||||||
*/
|
|
||||||
private formatRelativeDate(date: Date): string {
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
const diffWeeks = Math.floor(diffDays / 7);
|
|
||||||
const diffMonths = Math.floor(diffDays / 30);
|
|
||||||
|
|
||||||
if (diffMinutes < 1) return 'just now';
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
|
||||||
if (diffWeeks < 4) return `${diffWeeks}w ago`;
|
|
||||||
if (diffMonths < 12) return `${diffMonths}mo ago`;
|
|
||||||
|
|
||||||
const diffYears = Math.floor(diffMonths / 12);
|
|
||||||
return `${diffYears}y ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get date group for grouping conversations
|
|
||||||
*/
|
|
||||||
private getDateGroup(date: Date): string {
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
|
||||||
const thisWeekStart = new Date(today.getTime() - today.getDay() * 24 * 60 * 60 * 1000);
|
|
||||||
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
|
|
||||||
const conversationDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
||||||
|
|
||||||
if (conversationDate.getTime() >= today.getTime()) {
|
|
||||||
return 'Today';
|
|
||||||
} else if (conversationDate.getTime() >= yesterday.getTime()) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (conversationDate.getTime() >= thisWeekStart.getTime()) {
|
|
||||||
return 'This Week';
|
|
||||||
} else if (conversationDate.getTime() >= lastWeekStart.getTime()) {
|
|
||||||
return 'Last Week';
|
|
||||||
} else if (conversationDate.getTime() >= thisMonthStart.getTime()) {
|
|
||||||
return 'This Month';
|
|
||||||
} else {
|
|
||||||
return 'Older';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create display name for conversation
|
|
||||||
*/
|
|
||||||
private createDisplayName(filePath: string, metadata: any): string {
|
|
||||||
const parsedDate = this.parseTimestamp(metadata.timestamp, filePath);
|
|
||||||
const relativeDate = this.formatRelativeDate(parsedDate);
|
|
||||||
const sizeKB = Math.round(metadata.fileSize / 1024);
|
|
||||||
const branchInfo = metadata.gitBranch ? `${metadata.gitBranch}` : '';
|
|
||||||
|
|
||||||
return `${relativeDate} • ${metadata.messageCount} msgs • ${sizeKB}KB${branchInfo ? ` • ${branchInfo}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select specific conversations from list
|
|
||||||
*/
|
|
||||||
private async selectConversationsFromList(
|
|
||||||
conversations: ConversationItem[]
|
|
||||||
): Promise<ConversationItem[] | null> {
|
|
||||||
// Group conversations by date for better organization
|
|
||||||
const groupedConversations = this.groupConversationsByDate(conversations);
|
|
||||||
const options = this.createGroupedOptions(groupedConversations, conversations);
|
|
||||||
|
|
||||||
// Multi-select with select all/none shortcuts
|
|
||||||
const selectedIndices = await p.multiselect({
|
|
||||||
message: `Select conversations to import (${conversations.length} available, Space=toggle, Enter=confirm):`,
|
|
||||||
options,
|
|
||||||
required: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selectedIndices)) return null;
|
|
||||||
|
|
||||||
// Return selected conversations
|
|
||||||
const selected = selectedIndices as number[];
|
|
||||||
if (selected.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected.map(i => conversations[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm selection before processing
|
|
||||||
*/
|
|
||||||
private async confirmSelection(conversations: ConversationItem[]): Promise<boolean> {
|
|
||||||
const totalSize = conversations.reduce((sum, c) => sum + c.fileSize, 0);
|
|
||||||
const sizeKB = Math.round(totalSize / 1024);
|
|
||||||
const projects = [...new Set(conversations.map(c => c.projectName))];
|
|
||||||
|
|
||||||
const details = [
|
|
||||||
`${conversations.length} conversation${conversations.length === 1 ? '' : 's'}`,
|
|
||||||
`${projects.length} project${projects.length === 1 ? '' : 's'}: ${projects.join(', ')}`,
|
|
||||||
`Total size: ${sizeKB}KB`
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const confirmed = await p.confirm({
|
|
||||||
message: `Ready to import:\n\n${details}\n\nContinue?`,
|
|
||||||
initialValue: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return !p.isCancel(confirmed) && confirmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Group conversations by date sections
|
|
||||||
*/
|
|
||||||
private groupConversationsByDate(conversations: ConversationItem[]): Map<string, ConversationItem[]> {
|
|
||||||
const groups = new Map<string, ConversationItem[]>();
|
|
||||||
|
|
||||||
for (const conv of conversations) {
|
|
||||||
const group = conv.dateGroup;
|
|
||||||
if (!groups.has(group)) {
|
|
||||||
groups.set(group, []);
|
|
||||||
}
|
|
||||||
groups.get(group)!.push(conv);
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create options with date group headers
|
|
||||||
*/
|
|
||||||
private createGroupedOptions(groupedConversations: Map<string, ConversationItem[]>, allConversations: ConversationItem[]) {
|
|
||||||
const options: any[] = [];
|
|
||||||
|
|
||||||
// Add hint at top about selecting all/none
|
|
||||||
options.push({
|
|
||||||
value: 'hint',
|
|
||||||
label: '💡 Use Space to toggle, A to select all, I to invert',
|
|
||||||
disabled: true
|
|
||||||
});
|
|
||||||
options.push({ value: 'separator-hint', label: '─'.repeat(60), disabled: true });
|
|
||||||
|
|
||||||
// Define order of groups
|
|
||||||
const groupOrder = ['Today', 'Yesterday', 'This Week', 'Last Week', 'This Month', 'Older'];
|
|
||||||
|
|
||||||
for (const groupName of groupOrder) {
|
|
||||||
const conversations = groupedConversations.get(groupName);
|
|
||||||
if (!conversations || conversations.length === 0) continue;
|
|
||||||
|
|
||||||
// Add group header (disabled option for visual separation)
|
|
||||||
if (options.length > 2) { // Account for hint and separator
|
|
||||||
options.push({ value: `separator-${groupName}`, label: '─'.repeat(50), disabled: true });
|
|
||||||
}
|
|
||||||
options.push({
|
|
||||||
value: `header-${groupName}`,
|
|
||||||
label: `${groupName} (${conversations.length})`,
|
|
||||||
disabled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add conversations in this group
|
|
||||||
for (const conv of conversations) {
|
|
||||||
const index = allConversations.indexOf(conv);
|
|
||||||
const projectInfo = conv.projectName ? `[${conv.projectName}]` : '';
|
|
||||||
const workingDir = conv.cwd && conv.cwd !== 'undefined' ? path.basename(conv.cwd) : '';
|
|
||||||
const hint = `${projectInfo} ${workingDir}`.trim() || (conv.gitBranch ? `Branch: ${conv.gitBranch}` : '');
|
|
||||||
|
|
||||||
options.push({
|
|
||||||
value: index,
|
|
||||||
label: ` ${conv.displayName}`,
|
|
||||||
hint: hint
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -181,7 +181,9 @@ export class PathDiscovery {
|
|||||||
const packageJsonPath = require.resolve('claude-mem/package.json');
|
const packageJsonPath = require.resolve('claude-mem/package.json');
|
||||||
this._packageRoot = dirname(packageJsonPath);
|
this._packageRoot = dirname(packageJsonPath);
|
||||||
return this._packageRoot;
|
return this._packageRoot;
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Continue to next method
|
||||||
|
}
|
||||||
|
|
||||||
// Method 2: Walk up from current module location
|
// Method 2: Walk up from current module location
|
||||||
const currentFile = fileURLToPath(import.meta.url);
|
const currentFile = fileURLToPath(import.meta.url);
|
||||||
@@ -190,13 +192,11 @@ export class PathDiscovery {
|
|||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const packageJsonPath = join(currentDir, 'package.json');
|
const packageJsonPath = join(currentDir, 'package.json');
|
||||||
if (existsSync(packageJsonPath)) {
|
if (existsSync(packageJsonPath)) {
|
||||||
try {
|
|
||||||
const packageJson = require(packageJsonPath);
|
const packageJson = require(packageJsonPath);
|
||||||
if (packageJson.name === 'claude-mem') {
|
if (packageJson.name === 'claude-mem') {
|
||||||
this._packageRoot = currentDir;
|
this._packageRoot = currentDir;
|
||||||
return this._packageRoot;
|
return this._packageRoot;
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentDir = dirname(currentDir);
|
const parentDir = dirname(currentDir);
|
||||||
@@ -215,27 +215,37 @@ export class PathDiscovery {
|
|||||||
this._packageRoot = dirname(npmData.dependencies['claude-mem'].resolved);
|
this._packageRoot = dirname(npmData.dependencies['claude-mem'].resolved);
|
||||||
return this._packageRoot;
|
return this._packageRoot;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Continue to error
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error('Cannot locate claude-mem package root. Ensure claude-mem is properly installed.');
|
throw new Error('Cannot locate claude-mem package root. Ensure claude-mem is properly installed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find hooks directory in the installed package
|
* Find hook templates directory in the installed package
|
||||||
|
*
|
||||||
|
* This returns the SOURCE templates directory that gets copied during installation
|
||||||
|
* to the runtime hooks directory (~/.claude-mem/hooks/)
|
||||||
*/
|
*/
|
||||||
findPackageHooksDirectory(): string {
|
findPackageHookTemplatesDirectory(): string {
|
||||||
const packageRoot = this.getPackageRoot();
|
const packageRoot = this.getPackageRoot();
|
||||||
const hooksDir = join(packageRoot, 'hooks');
|
const hookTemplatesDir = join(packageRoot, 'hook-templates');
|
||||||
|
|
||||||
// Verify it contains expected hook files
|
// Verify it contains expected hook template files
|
||||||
const requiredHooks = ['pre-compact.js', 'session-start.js'];
|
const requiredHookTemplates = [
|
||||||
for (const hookFile of requiredHooks) {
|
'session-start.js',
|
||||||
if (!existsSync(join(hooksDir, hookFile))) {
|
'stop.js',
|
||||||
throw new Error(`Package hooks directory missing required file: ${hookFile}`);
|
'user-prompt-submit.js',
|
||||||
|
'post-tool-use.js'
|
||||||
|
];
|
||||||
|
for (const hookTemplateFile of requiredHookTemplates) {
|
||||||
|
if (!existsSync(join(hookTemplatesDir, hookTemplateFile))) {
|
||||||
|
throw new Error(`Package hook-templates directory missing required template file: ${hookTemplateFile}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hooksDir;
|
return hookTemplatesDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -325,10 +335,20 @@ export class PathDiscovery {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current project directory name
|
* Get current project directory name
|
||||||
|
* Uses git repository root's basename if in a git repo, otherwise falls back to cwd basename
|
||||||
*/
|
*/
|
||||||
static getCurrentProjectName(): string {
|
static getCurrentProjectName(): string {
|
||||||
|
try {
|
||||||
|
const gitRoot = execSync('git rev-parse --show-toplevel', {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'ignore']
|
||||||
|
}).trim();
|
||||||
|
return require('path').basename(gitRoot);
|
||||||
|
} catch {
|
||||||
return require('path').basename(process.cwd());
|
return require('path').basename(process.cwd());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a timestamped backup filename
|
* Create a timestamped backup filename
|
||||||
@@ -347,68 +367,7 @@ export class PathDiscovery {
|
|||||||
* Check if a path exists and is accessible
|
* Check if a path exists and is accessible
|
||||||
*/
|
*/
|
||||||
static isPathAccessible(path: string): boolean {
|
static isPathAccessible(path: string): boolean {
|
||||||
try {
|
|
||||||
return existsSync(path) && statSync(path).isDirectory();
|
return existsSync(path) && statSync(path).isDirectory();
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// STATIC CONVENIENCE METHODS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick access to singleton instance methods
|
|
||||||
*/
|
|
||||||
static getDataDirectory(): string {
|
|
||||||
return PathDiscovery.getInstance().getDataDirectory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getArchivesDirectory(): string {
|
|
||||||
return PathDiscovery.getInstance().getArchivesDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
static getHooksDirectory(): string {
|
|
||||||
return PathDiscovery.getInstance().getHooksDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
static getLogsDirectory(): string {
|
|
||||||
return PathDiscovery.getInstance().getLogsDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
static getClaudeSettingsPath(): string {
|
|
||||||
return PathDiscovery.getInstance().getClaudeSettingsPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
static getClaudeMdPath(): string {
|
|
||||||
return PathDiscovery.getInstance().getClaudeMdPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
static findPackageHooksDirectory(): string {
|
|
||||||
return PathDiscovery.getInstance().findPackageHooksDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
static findPackageCommandsDirectory(): string {
|
|
||||||
return PathDiscovery.getInstance().findPackageCommandsDirectory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance for immediate use
|
|
||||||
export const pathDiscovery = PathDiscovery.getInstance();
|
|
||||||
|
|
||||||
// Export static methods for convenience
|
|
||||||
export const {
|
|
||||||
getDataDirectory,
|
|
||||||
getArchivesDirectory,
|
|
||||||
getHooksDirectory,
|
|
||||||
getLogsDirectory,
|
|
||||||
getClaudeSettingsPath,
|
|
||||||
getClaudeMdPath,
|
|
||||||
findPackageHooksDirectory,
|
|
||||||
findPackageCommandsDirectory,
|
|
||||||
extractProjectName,
|
|
||||||
getCurrentProjectName,
|
|
||||||
createBackupFilename,
|
|
||||||
isPathAccessible
|
|
||||||
} = PathDiscovery;
|
|
||||||
@@ -21,8 +21,8 @@ export class MemoryStore {
|
|||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
INSERT INTO memories (
|
INSERT INTO memories (
|
||||||
session_id, text, document_id, keywords, created_at, created_at_epoch,
|
session_id, text, document_id, keywords, created_at, created_at_epoch,
|
||||||
project, archive_basename, origin
|
project, archive_basename, origin, title, subtitle, facts, concepts, files_touched
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const info = stmt.run(
|
const info = stmt.run(
|
||||||
@@ -34,7 +34,12 @@ export class MemoryStore {
|
|||||||
epoch,
|
epoch,
|
||||||
input.project,
|
input.project,
|
||||||
input.archive_basename || null,
|
input.archive_basename || null,
|
||||||
input.origin || 'transcript'
|
input.origin || 'transcript',
|
||||||
|
input.title || null,
|
||||||
|
input.subtitle || null,
|
||||||
|
input.facts || null,
|
||||||
|
input.concepts || null,
|
||||||
|
input.files_touched || null
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.getById(info.lastInsertRowid as number)!;
|
return this.getById(info.lastInsertRowid as number)!;
|
||||||
@@ -167,6 +172,35 @@ export class MemoryStore {
|
|||||||
return stmt.all(...params) as MemoryRow[];
|
return stmt.all(...params) as MemoryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent memories for a project filtered by origin
|
||||||
|
*/
|
||||||
|
getRecentForProjectByOrigin(project: string, origin: string, limit = 10): MemoryRow[] {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM memories
|
||||||
|
WHERE project = ? AND origin = ?
|
||||||
|
ORDER BY created_at_epoch DESC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
return stmt.all(project, origin, limit) as MemoryRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last N memories for a project, sorted oldest to newest
|
||||||
|
*/
|
||||||
|
getLastNForProject(project: string, limit = 10): MemoryRow[] {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT * FROM memories
|
||||||
|
WHERE project = ?
|
||||||
|
ORDER BY created_at_epoch DESC
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
ORDER BY created_at_epoch ASC
|
||||||
|
`);
|
||||||
|
return stmt.all(project, limit) as MemoryRow[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count total memories
|
* Count total memories
|
||||||
*/
|
*/
|
||||||
@@ -199,7 +233,8 @@ export class MemoryStore {
|
|||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
UPDATE memories SET
|
UPDATE memories SET
|
||||||
text = ?, document_id = ?, keywords = ?, created_at = ?, created_at_epoch = ?,
|
text = ?, document_id = ?, keywords = ?, created_at = ?, created_at_epoch = ?,
|
||||||
project = ?, archive_basename = ?, origin = ?
|
project = ?, archive_basename = ?, origin = ?, title = ?, subtitle = ?, facts = ?,
|
||||||
|
concepts = ?, files_touched = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -212,6 +247,11 @@ export class MemoryStore {
|
|||||||
input.project || existing.project,
|
input.project || existing.project,
|
||||||
input.archive_basename !== undefined ? input.archive_basename : existing.archive_basename,
|
input.archive_basename !== undefined ? input.archive_basename : existing.archive_basename,
|
||||||
input.origin || existing.origin,
|
input.origin || existing.origin,
|
||||||
|
input.title !== undefined ? input.title : existing.title,
|
||||||
|
input.subtitle !== undefined ? input.subtitle : existing.subtitle,
|
||||||
|
input.facts !== undefined ? input.facts : existing.facts,
|
||||||
|
input.concepts !== undefined ? input.concepts : existing.concepts,
|
||||||
|
input.files_touched !== undefined ? input.files_touched : existing.files_touched,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ export class OverviewStore {
|
|||||||
return stmt.all(project, limit) as OverviewRow[];
|
return stmt.all(project, limit) as OverviewRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all overviews for a project (oldest to newest)
|
||||||
|
*/
|
||||||
|
getAllForProject(project: string): OverviewRow[] {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM overviews
|
||||||
|
WHERE project = ?
|
||||||
|
ORDER BY created_at_epoch ASC
|
||||||
|
`);
|
||||||
|
return stmt.all(project) as OverviewRow[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recent overviews across all projects
|
* Get recent overviews across all projects
|
||||||
*/
|
*/
|
||||||
@@ -193,4 +205,37 @@ export class OverviewStore {
|
|||||||
const rows = stmt.all() as { project: string }[];
|
const rows = stmt.all() as { project: string }[];
|
||||||
return rows.map(row => row.project);
|
return rows.map(row => row.project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get most recent overview for a specific project
|
||||||
|
*/
|
||||||
|
getByProject(project: string): OverviewRow | null {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM overviews
|
||||||
|
WHERE project = ?
|
||||||
|
ORDER BY created_at_epoch DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
return stmt.get(project) as OverviewRow || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update overview for a project (keeps only most recent)
|
||||||
|
*/
|
||||||
|
upsertByProject(input: OverviewInput): OverviewRow {
|
||||||
|
const existing = this.getByProject(input.project);
|
||||||
|
if (existing) {
|
||||||
|
return this.update(existing.id, input);
|
||||||
|
}
|
||||||
|
return this.create(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete overview by project name
|
||||||
|
*/
|
||||||
|
deleteByProject(project: string): boolean {
|
||||||
|
const stmt = this.db.prepare('DELETE FROM overviews WHERE project = ?');
|
||||||
|
const info = stmt.run(project);
|
||||||
|
return info.changes > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Database } from 'better-sqlite3';
|
||||||
|
import { getDatabase } from './Database.js';
|
||||||
|
import {
|
||||||
|
TranscriptEventInput,
|
||||||
|
TranscriptEventRow,
|
||||||
|
normalizeTimestamp
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data access for transcript_events table
|
||||||
|
*/
|
||||||
|
export class TranscriptEventStore {
|
||||||
|
private db: Database.Database;
|
||||||
|
|
||||||
|
constructor(db?: Database.Database) {
|
||||||
|
this.db = db || getDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update a transcript event
|
||||||
|
*/
|
||||||
|
upsert(event: TranscriptEventInput): TranscriptEventRow {
|
||||||
|
const { isoString, epoch } = normalizeTimestamp(event.captured_at);
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
INSERT INTO transcript_events (
|
||||||
|
session_id,
|
||||||
|
project,
|
||||||
|
event_index,
|
||||||
|
event_type,
|
||||||
|
raw_json,
|
||||||
|
captured_at,
|
||||||
|
captured_at_epoch
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, event_index) DO UPDATE SET
|
||||||
|
project = excluded.project,
|
||||||
|
event_type = excluded.event_type,
|
||||||
|
raw_json = excluded.raw_json,
|
||||||
|
captured_at = excluded.captured_at,
|
||||||
|
captured_at_epoch = excluded.captured_at_epoch
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
event.session_id,
|
||||||
|
event.project || null,
|
||||||
|
event.event_index,
|
||||||
|
event.event_type || null,
|
||||||
|
event.raw_json,
|
||||||
|
isoString,
|
||||||
|
epoch
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getBySessionAndIndex(event.session_id, event.event_index)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk upsert events in a single transaction
|
||||||
|
*/
|
||||||
|
upsertMany(events: TranscriptEventInput[]): TranscriptEventRow[] {
|
||||||
|
const transaction = this.db.transaction((rows: TranscriptEventInput[]) => {
|
||||||
|
const results: TranscriptEventRow[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
results.push(this.upsert(row));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
return transaction(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event by session and index
|
||||||
|
*/
|
||||||
|
getBySessionAndIndex(sessionId: string, eventIndex: number): TranscriptEventRow | null {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM transcript_events
|
||||||
|
WHERE session_id = ? AND event_index = ?
|
||||||
|
`);
|
||||||
|
return stmt.get(sessionId, eventIndex) as TranscriptEventRow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get highest event_index stored for a session
|
||||||
|
*/
|
||||||
|
getMaxEventIndex(sessionId: string): number {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT MAX(event_index) as max_event_index
|
||||||
|
FROM transcript_events
|
||||||
|
WHERE session_id = ?
|
||||||
|
`);
|
||||||
|
const row = stmt.get(sessionId) as { max_event_index: number | null } | undefined;
|
||||||
|
return row?.max_event_index ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List recent events for a session
|
||||||
|
*/
|
||||||
|
listBySession(sessionId: string, limit = 200, offset = 0): TranscriptEventRow[] {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM transcript_events
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY event_index ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
return stmt.all(sessionId, limit, offset) as TranscriptEventRow[];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
// Import migrations to register them
|
|
||||||
import './migrations/index.js';
|
|
||||||
|
|
||||||
// Export main components
|
// Export main components
|
||||||
export { DatabaseManager, getDatabase, initializeDatabase } from './Database.js';
|
export { DatabaseManager, getDatabase, initializeDatabase } from './Database.js';
|
||||||
|
|
||||||
@@ -9,24 +6,38 @@ export { SessionStore } from './SessionStore.js';
|
|||||||
export { MemoryStore } from './MemoryStore.js';
|
export { MemoryStore } from './MemoryStore.js';
|
||||||
export { OverviewStore } from './OverviewStore.js';
|
export { OverviewStore } from './OverviewStore.js';
|
||||||
export { DiagnosticsStore } from './DiagnosticsStore.js';
|
export { DiagnosticsStore } from './DiagnosticsStore.js';
|
||||||
|
export { TranscriptEventStore } from './TranscriptEventStore.js';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
|
||||||
|
// Export migrations
|
||||||
|
export { migrations } from './migrations.js';
|
||||||
|
|
||||||
// Convenience function to get all stores
|
// Convenience function to get all stores
|
||||||
export async function createStores() {
|
export async function createStores() {
|
||||||
const { DatabaseManager } = await import('./Database.js');
|
const { DatabaseManager } = await import('./Database.js');
|
||||||
const db = await DatabaseManager.getInstance().initialize();
|
const { migrations } = await import('./migrations.js');
|
||||||
|
|
||||||
|
// Register migrations before initialization
|
||||||
|
const manager = DatabaseManager.getInstance();
|
||||||
|
for (const migration of migrations) {
|
||||||
|
manager.registerMigration(migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await manager.initialize();
|
||||||
|
|
||||||
const { SessionStore } = await import('./SessionStore.js');
|
const { SessionStore } = await import('./SessionStore.js');
|
||||||
const { MemoryStore } = await import('./MemoryStore.js');
|
const { MemoryStore } = await import('./MemoryStore.js');
|
||||||
const { OverviewStore } = await import('./OverviewStore.js');
|
const { OverviewStore } = await import('./OverviewStore.js');
|
||||||
const { DiagnosticsStore } = await import('./DiagnosticsStore.js');
|
const { DiagnosticsStore } = await import('./DiagnosticsStore.js');
|
||||||
|
const { TranscriptEventStore } = await import('./TranscriptEventStore.js');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions: new SessionStore(db),
|
sessions: new SessionStore(db),
|
||||||
memories: new MemoryStore(db),
|
memories: new MemoryStore(db),
|
||||||
overviews: new OverviewStore(db),
|
overviews: new OverviewStore(db),
|
||||||
diagnostics: new DiagnosticsStore(db)
|
diagnostics: new DiagnosticsStore(db),
|
||||||
|
transcriptEvents: new TranscriptEventStore(db)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Database } from 'better-sqlite3';
|
||||||
|
import { Migration } from './Database.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial schema migration - creates all core tables
|
||||||
|
*/
|
||||||
|
export const migration001: Migration = {
|
||||||
|
version: 1,
|
||||||
|
up: (db: Database.Database) => {
|
||||||
|
// Sessions table - core session tracking
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT UNIQUE NOT NULL,
|
||||||
|
project TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
created_at_epoch INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'compress',
|
||||||
|
archive_path TEXT,
|
||||||
|
archive_bytes INTEGER,
|
||||||
|
archive_checksum TEXT,
|
||||||
|
archived_at TEXT,
|
||||||
|
metadata_json TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at_epoch DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_project_created ON sessions(project, created_at_epoch DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Memories table - compressed memory chunks
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
document_id TEXT UNIQUE,
|
||||||
|
keywords TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
created_at_epoch INTEGER NOT NULL,
|
||||||
|
project TEXT NOT NULL,
|
||||||
|
archive_basename TEXT,
|
||||||
|
origin TEXT NOT NULL DEFAULT 'transcript',
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at_epoch DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_project_created ON memories(project, created_at_epoch DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_document_id ON memories(document_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_origin ON memories(origin);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Overviews table - session summaries (one per project)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS overviews (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
created_at_epoch INTEGER NOT NULL,
|
||||||
|
project TEXT NOT NULL,
|
||||||
|
origin TEXT NOT NULL DEFAULT 'claude',
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_overviews_session ON overviews(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_overviews_project ON overviews(project);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_overviews_created_at ON overviews(created_at_epoch DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_overviews_project_created ON overviews(project, created_at_epoch DESC);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_overviews_project_latest ON overviews(project, created_at_epoch DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Diagnostics table - system health and debug info
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS diagnostics (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
created_at_epoch INTEGER NOT NULL,
|
||||||
|
project TEXT NOT NULL,
|
||||||
|
origin TEXT NOT NULL DEFAULT 'system',
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_diagnostics_session ON diagnostics(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_diagnostics_project ON diagnostics(project);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_diagnostics_severity ON diagnostics(severity);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_diagnostics_created ON diagnostics(created_at_epoch DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Transcript events table - raw conversation events
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS transcript_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
project TEXT,
|
||||||
|
event_index INTEGER NOT NULL,
|
||||||
|
event_type TEXT,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
captured_at TEXT NOT NULL,
|
||||||
|
captured_at_epoch INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(session_id, event_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcript_events_session ON transcript_events(session_id, event_index);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcript_events_project ON transcript_events(project);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcript_events_type ON transcript_events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transcript_events_captured ON transcript_events(captured_at_epoch DESC);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ Created all database tables successfully');
|
||||||
|
},
|
||||||
|
|
||||||
|
down: (db: Database.Database) => {
|
||||||
|
db.exec(`
|
||||||
|
DROP TABLE IF EXISTS transcript_events;
|
||||||
|
DROP TABLE IF EXISTS diagnostics;
|
||||||
|
DROP TABLE IF EXISTS overviews;
|
||||||
|
DROP TABLE IF EXISTS memories;
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 002 - Add hierarchical memory fields (v2 format)
|
||||||
|
*/
|
||||||
|
export const migration002: Migration = {
|
||||||
|
version: 2,
|
||||||
|
up: (db: Database.Database) => {
|
||||||
|
// Add new columns for hierarchical memory structure
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE memories ADD COLUMN title TEXT;
|
||||||
|
ALTER TABLE memories ADD COLUMN subtitle TEXT;
|
||||||
|
ALTER TABLE memories ADD COLUMN facts TEXT;
|
||||||
|
ALTER TABLE memories ADD COLUMN concepts TEXT;
|
||||||
|
ALTER TABLE memories ADD COLUMN files_touched TEXT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for the new fields to improve search performance
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_title ON memories(title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_concepts ON memories(concepts);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ Added hierarchical memory fields to memories table');
|
||||||
|
},
|
||||||
|
|
||||||
|
down: (db: Database.Database) => {
|
||||||
|
// Note: SQLite doesn't support DROP COLUMN in all versions
|
||||||
|
// In production, we'd need to recreate the table without these columns
|
||||||
|
// For now, we'll just log a warning
|
||||||
|
console.log('⚠️ Warning: SQLite ALTER TABLE DROP COLUMN not fully supported');
|
||||||
|
console.log('⚠️ To rollback, manually recreate the memories table');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All migrations in order
|
||||||
|
*/
|
||||||
|
export const migrations: Migration[] = [
|
||||||
|
migration001,
|
||||||
|
migration002
|
||||||
|
];
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { Migration } from '../Database.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initial migration: Create all core tables for claude-mem SQLite index
|
|
||||||
*/
|
|
||||||
export const migration001: Migration = {
|
|
||||||
version: 1,
|
|
||||||
|
|
||||||
up: (db) => {
|
|
||||||
// Create sessions table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT UNIQUE NOT NULL,
|
|
||||||
project TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
created_at_epoch INTEGER NOT NULL,
|
|
||||||
source TEXT DEFAULT 'compress',
|
|
||||||
archive_path TEXT,
|
|
||||||
archive_bytes INTEGER,
|
|
||||||
archive_checksum TEXT,
|
|
||||||
archived_at TEXT,
|
|
||||||
metadata_json TEXT
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create indexes for sessions
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX sessions_project_created_at ON sessions (project, created_at_epoch DESC)
|
|
||||||
`);
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX sessions_source_created ON sessions (source, created_at_epoch DESC)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create overviews table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE overviews (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
created_at_epoch INTEGER NOT NULL,
|
|
||||||
project TEXT NOT NULL,
|
|
||||||
origin TEXT DEFAULT 'claude'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create index for overviews
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX overviews_project_created_at ON overviews (project, created_at_epoch DESC)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create memories table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE memories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
document_id TEXT,
|
|
||||||
keywords TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
created_at_epoch INTEGER NOT NULL,
|
|
||||||
project TEXT NOT NULL,
|
|
||||||
archive_basename TEXT,
|
|
||||||
origin TEXT DEFAULT 'transcript'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create indexes for memories
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX memories_project_created_at ON memories (project, created_at_epoch DESC)
|
|
||||||
`);
|
|
||||||
db.exec(`
|
|
||||||
CREATE UNIQUE INDEX memories_document_id_unique ON memories (document_id) WHERE document_id IS NOT NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create diagnostics table
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE diagnostics (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT REFERENCES sessions(session_id) ON DELETE SET NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
severity TEXT DEFAULT 'warn',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
created_at_epoch INTEGER NOT NULL,
|
|
||||||
project TEXT NOT NULL,
|
|
||||||
origin TEXT DEFAULT 'compressor'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create index for diagnostics
|
|
||||||
db.exec(`
|
|
||||||
CREATE INDEX diagnostics_project_created_at ON diagnostics (project, created_at_epoch DESC)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create archives table (for future archival workflows)
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE archives (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT UNIQUE NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
bytes INTEGER,
|
|
||||||
checksum TEXT,
|
|
||||||
stored_at TEXT NOT NULL,
|
|
||||||
storage_status TEXT DEFAULT 'active'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create titles table (ready for conversation-titles.jsonl migration)
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE titles (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
session_id TEXT UNIQUE NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
project TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('✅ Created initial database schema with all tables and indexes');
|
|
||||||
},
|
|
||||||
|
|
||||||
down: (db) => {
|
|
||||||
// Drop tables in reverse order to respect foreign key constraints
|
|
||||||
const tables = ['titles', 'archives', 'diagnostics', 'memories', 'overviews', 'sessions'];
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🗑️ Dropped all tables from initial migration');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { DatabaseManager } from '../Database.js';
|
|
||||||
import { migration001 } from './001_initial.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register all migrations with the database manager
|
|
||||||
*/
|
|
||||||
export function registerMigrations(): void {
|
|
||||||
const manager = DatabaseManager.getInstance();
|
|
||||||
|
|
||||||
// Register migrations in order
|
|
||||||
manager.registerMigration(migration001);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-register migrations when this module is imported
|
|
||||||
registerMigrations();
|
|
||||||
@@ -37,6 +37,12 @@ export interface MemoryRow {
|
|||||||
project: string;
|
project: string;
|
||||||
archive_basename?: string;
|
archive_basename?: string;
|
||||||
origin: string;
|
origin: string;
|
||||||
|
// Hierarchical memory fields (v2)
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
facts?: string; // JSON array of fact strings
|
||||||
|
concepts?: string; // JSON array of concept strings
|
||||||
|
files_touched?: string; // JSON array of file paths
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagnosticRow {
|
export interface DiagnosticRow {
|
||||||
@@ -50,6 +56,17 @@ export interface DiagnosticRow {
|
|||||||
origin: string;
|
origin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TranscriptEventRow {
|
||||||
|
id: number;
|
||||||
|
session_id: string;
|
||||||
|
project?: string;
|
||||||
|
event_index: number;
|
||||||
|
event_type?: string;
|
||||||
|
raw_json: string;
|
||||||
|
captured_at: string;
|
||||||
|
captured_at_epoch: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ArchiveRow {
|
export interface ArchiveRow {
|
||||||
id: number;
|
id: number;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -100,6 +117,12 @@ export interface MemoryInput {
|
|||||||
project: string;
|
project: string;
|
||||||
archive_basename?: string;
|
archive_basename?: string;
|
||||||
origin?: string;
|
origin?: string;
|
||||||
|
// Hierarchical memory fields (v2)
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
facts?: string; // JSON array of fact strings
|
||||||
|
concepts?: string; // JSON array of concept strings
|
||||||
|
files_touched?: string; // JSON array of file paths
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagnosticInput {
|
export interface DiagnosticInput {
|
||||||
@@ -111,6 +134,15 @@ export interface DiagnosticInput {
|
|||||||
origin?: string;
|
origin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TranscriptEventInput {
|
||||||
|
session_id: string;
|
||||||
|
project?: string;
|
||||||
|
event_index: number;
|
||||||
|
event_type?: string;
|
||||||
|
raw_json: string;
|
||||||
|
captured_at?: string | Date | number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to normalize timestamps from various formats
|
* Helper function to normalize timestamps from various formats
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { log } from '../shared/logger.js';
|
|
||||||
import { PathDiscovery } from './path-discovery.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for Claude Code JSONL conversation entries
|
|
||||||
*/
|
|
||||||
export interface ClaudeCodeMessage {
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: string;
|
|
||||||
gitBranch?: string;
|
|
||||||
cwd: string;
|
|
||||||
type: 'user' | 'assistant' | 'system' | 'result';
|
|
||||||
message: {
|
|
||||||
role: string;
|
|
||||||
content: Array<{
|
|
||||||
type: string;
|
|
||||||
text?: string;
|
|
||||||
thinking?: string;
|
|
||||||
}> | string;
|
|
||||||
};
|
|
||||||
uuid: string;
|
|
||||||
version?: string;
|
|
||||||
isSidechain?: boolean;
|
|
||||||
userType?: string;
|
|
||||||
parentUuid?: string;
|
|
||||||
subtype?: string;
|
|
||||||
model?: string;
|
|
||||||
stop_reason?: string;
|
|
||||||
usage?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface matching TranscriptCompressor's expected format
|
|
||||||
*/
|
|
||||||
export interface TranscriptMessage {
|
|
||||||
type: string;
|
|
||||||
message?: {
|
|
||||||
content?: string | Array<{
|
|
||||||
text?: string;
|
|
||||||
content?: string;
|
|
||||||
}>;
|
|
||||||
role?: string;
|
|
||||||
timestamp?: string;
|
|
||||||
created_at?: string;
|
|
||||||
};
|
|
||||||
content?: string | Array<{
|
|
||||||
text?: string;
|
|
||||||
content?: string;
|
|
||||||
}>;
|
|
||||||
role?: string;
|
|
||||||
uuid?: string;
|
|
||||||
session_id?: string;
|
|
||||||
timestamp?: string;
|
|
||||||
created_at?: string;
|
|
||||||
subtype?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parsed conversation with metadata
|
|
||||||
*/
|
|
||||||
export interface ParsedConversation {
|
|
||||||
sessionId: string;
|
|
||||||
filePath: string;
|
|
||||||
messageCount: number;
|
|
||||||
timestamp: string;
|
|
||||||
gitBranch?: string;
|
|
||||||
cwd: string;
|
|
||||||
messages: TranscriptMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for parsing Claude Code JSONL conversation files
|
|
||||||
*/
|
|
||||||
export class TranscriptParser {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a single JSONL conversation file
|
|
||||||
*/
|
|
||||||
async parseConversation(filePath: string): Promise<ParsedConversation> {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
const claudeMessages: ClaudeCodeMessage[] = [];
|
|
||||||
let parseErrors = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(lines[i]);
|
|
||||||
claudeMessages.push(parsed);
|
|
||||||
} catch (e) {
|
|
||||||
parseErrors++;
|
|
||||||
log.debug(`Parse error on line ${i + 1}: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (claudeMessages.length === 0) {
|
|
||||||
throw new Error(`No valid messages found in ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get metadata from first message
|
|
||||||
const firstMessage = claudeMessages[0];
|
|
||||||
const sessionId = firstMessage.sessionId;
|
|
||||||
const timestamp = firstMessage.timestamp;
|
|
||||||
const gitBranch = firstMessage.gitBranch;
|
|
||||||
const cwd = firstMessage.cwd;
|
|
||||||
|
|
||||||
// Convert to TranscriptMessage format
|
|
||||||
const messages = claudeMessages.map(msg => this.convertMessage(msg));
|
|
||||||
|
|
||||||
log.debug(`Parsed ${filePath}: ${messages.length} messages, ${parseErrors} errors`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
filePath,
|
|
||||||
messageCount: messages.length,
|
|
||||||
timestamp,
|
|
||||||
gitBranch,
|
|
||||||
cwd,
|
|
||||||
messages
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert ClaudeCodeMessage to TranscriptMessage format
|
|
||||||
*/
|
|
||||||
private convertMessage(claudeMsg: ClaudeCodeMessage): TranscriptMessage {
|
|
||||||
const converted: TranscriptMessage = {
|
|
||||||
type: claudeMsg.type,
|
|
||||||
uuid: claudeMsg.uuid,
|
|
||||||
session_id: claudeMsg.sessionId,
|
|
||||||
timestamp: claudeMsg.timestamp,
|
|
||||||
subtype: claudeMsg.subtype
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle message content
|
|
||||||
if (claudeMsg.message) {
|
|
||||||
converted.message = {
|
|
||||||
role: claudeMsg.message.role,
|
|
||||||
timestamp: claudeMsg.timestamp
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(claudeMsg.message.content)) {
|
|
||||||
// Convert content array to expected format
|
|
||||||
converted.message.content = claudeMsg.message.content.map(item => ({
|
|
||||||
text: item.text || item.thinking || '',
|
|
||||||
content: item.text || item.thinking || ''
|
|
||||||
}));
|
|
||||||
} else if (typeof claudeMsg.message.content === 'string') {
|
|
||||||
converted.message.content = claudeMsg.message.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return converted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan Claude projects directory for conversation files
|
|
||||||
*/
|
|
||||||
async scanConversationFiles(): Promise<string[]> {
|
|
||||||
const pathDiscovery = PathDiscovery.getInstance();
|
|
||||||
const claudeDir = path.join(pathDiscovery.getClaudeConfigDirectory(), 'projects');
|
|
||||||
|
|
||||||
if (!fs.existsSync(claudeDir)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectDirs = fs.readdirSync(claudeDir);
|
|
||||||
const conversationFiles: string[] = [];
|
|
||||||
|
|
||||||
for (const projectDir of projectDirs) {
|
|
||||||
const projectPath = path.join(claudeDir, projectDir);
|
|
||||||
if (!fs.statSync(projectPath).isDirectory()) continue;
|
|
||||||
|
|
||||||
const files = fs.readdirSync(projectPath);
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.endsWith('.jsonl')) {
|
|
||||||
conversationFiles.push(path.join(projectPath, file));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conversationFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get conversation metadata without fully parsing
|
|
||||||
*/
|
|
||||||
async getConversationMetadata(filePath: string): Promise<{
|
|
||||||
sessionId: string;
|
|
||||||
timestamp: string;
|
|
||||||
messageCount: number;
|
|
||||||
gitBranch?: string;
|
|
||||||
cwd: string;
|
|
||||||
fileSize: number;
|
|
||||||
}> {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
let firstMessage;
|
|
||||||
try {
|
|
||||||
firstMessage = JSON.parse(lines[0]);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`Invalid JSONL format in ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: firstMessage.sessionId,
|
|
||||||
timestamp: firstMessage.timestamp,
|
|
||||||
messageCount: lines.length,
|
|
||||||
gitBranch: firstMessage.gitBranch,
|
|
||||||
cwd: firstMessage.cwd,
|
|
||||||
fileSize: stats.size
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { existsSync, mkdirSync } from 'fs';
|
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { HookError, CompressionError, Logger, FileLogger } from './types.js';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
export class ErrorHandler {
|
|
||||||
private logger: Logger;
|
|
||||||
private logDir: string;
|
|
||||||
|
|
||||||
// <Block> 7.1 ====================================
|
|
||||||
constructor(enableDebug = false) {
|
|
||||||
this.logDir = join(__dirname, '..', 'logs');
|
|
||||||
this.ensureLogDirectory();
|
|
||||||
|
|
||||||
const logFile = join(
|
|
||||||
this.logDir,
|
|
||||||
`claude-mem-${new Date().toISOString().slice(0, 10)}.log`
|
|
||||||
);
|
|
||||||
this.logger = new FileLogger(logFile, enableDebug);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.2 ====================================
|
|
||||||
private ensureLogDirectory(): void {
|
|
||||||
if (!existsSync(this.logDir)) {
|
|
||||||
mkdirSync(this.logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.3 ====================================
|
|
||||||
handleHookError(error: Error, hookType: string, payload?: unknown): never {
|
|
||||||
// <Block> 7.3a ====================================
|
|
||||||
const hookError =
|
|
||||||
error instanceof HookError
|
|
||||||
? error
|
|
||||||
: new HookError(
|
|
||||||
error.message,
|
|
||||||
hookType,
|
|
||||||
payload as any,
|
|
||||||
'HOOK_EXECUTION_ERROR'
|
|
||||||
);
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
this.logger.error(`Hook execution failed in ${hookType}`, hookError, {
|
|
||||||
hookType,
|
|
||||||
payload: payload ? JSON.stringify(payload) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({
|
|
||||||
continue: false,
|
|
||||||
stopReason: `Hook error: ${hookError.message}`,
|
|
||||||
error: {
|
|
||||||
type: hookError.name,
|
|
||||||
message: hookError.message,
|
|
||||||
code: hookError.code,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.4 ====================================
|
|
||||||
handleCompressionError(
|
|
||||||
error: Error,
|
|
||||||
transcriptPath: string,
|
|
||||||
stage: string
|
|
||||||
): never {
|
|
||||||
// <Block> 7.4a ====================================
|
|
||||||
const compressionError =
|
|
||||||
error instanceof CompressionError
|
|
||||||
? error
|
|
||||||
: new CompressionError(error.message, transcriptPath, stage as any);
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
this.logger.error(`Compression failed during ${stage}`, compressionError, {
|
|
||||||
transcriptPath,
|
|
||||||
stage,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.error(`Compression error: ${compressionError.message}`);
|
|
||||||
console.error(`Stage: ${stage}`);
|
|
||||||
console.error(`Transcript: ${transcriptPath}`);
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.5 ====================================
|
|
||||||
handleValidationError(
|
|
||||||
message: string,
|
|
||||||
context?: Record<string, unknown>
|
|
||||||
): never {
|
|
||||||
this.logger.error('Validation error', undefined, { message, context });
|
|
||||||
|
|
||||||
console.error(`Validation error: ${message}`);
|
|
||||||
// <Block> 7.5a ====================================
|
|
||||||
if (context) {
|
|
||||||
console.error('Context:', JSON.stringify(context, null, 2));
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.6 ====================================
|
|
||||||
logSuccess(operation: string, details?: Record<string, unknown>): void {
|
|
||||||
this.logger.info(`Operation successful: ${operation}`, details);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.7 ====================================
|
|
||||||
logWarning(message: string, details?: Record<string, unknown>): void {
|
|
||||||
this.logger.warn(message, details);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.8 ====================================
|
|
||||||
logDebug(message: string, details?: Record<string, unknown>): void {
|
|
||||||
this.logger.debug(message, details);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
}
|
|
||||||
|
|
||||||
// <Block> 7.9 ====================================
|
|
||||||
export function parseStdinJson<T = unknown>(input: string): T {
|
|
||||||
try {
|
|
||||||
return JSON.parse(input) as T;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to parse JSON input: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.10 ===================================
|
|
||||||
export async function safeExecute<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
errorHandler: ErrorHandler,
|
|
||||||
context: string
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
const message = `Safe execution failed in ${context}: ${error instanceof Error ? error.message : String(error)}`;
|
|
||||||
errorHandler.handleValidationError(message, { context, error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.11 ===================================
|
|
||||||
export function validateFileExists(
|
|
||||||
filePath: string,
|
|
||||||
errorHandler: ErrorHandler
|
|
||||||
): void {
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
errorHandler.handleValidationError(`File not found: ${filePath}`, {
|
|
||||||
filePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
// <Block> 7.12 ===================================
|
|
||||||
/**
|
|
||||||
* Creates a standardized hook response using HookTemplates
|
|
||||||
* @deprecated Use HookTemplates.createHookSuccessResponse or createHookErrorResponse instead
|
|
||||||
* This function is maintained for backward compatibility but should be replaced with HookTemplates.
|
|
||||||
*/
|
|
||||||
export function createHookResponse(
|
|
||||||
success: boolean,
|
|
||||||
data?: Record<string, unknown>
|
|
||||||
): string {
|
|
||||||
// Log deprecation warning in development mode
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('createHookResponse in error-handler.ts is deprecated. Use HookTemplates.createHookSuccessResponse or createHookErrorResponse instead.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
continue: success,
|
|
||||||
suppressOutput: true, // Add standard suppressOutput field for Claude Code compatibility
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
|
|
||||||
return JSON.stringify(response);
|
|
||||||
}
|
|
||||||
// </Block> =======================================
|
|
||||||
|
|
||||||
export const globalErrorHandler = new ErrorHandler(
|
|
||||||
process.env.DEBUG === 'true'
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { PathDiscovery } from '../services/path-discovery.js';
|
||||||
|
|
||||||
|
let logPath: string | null = null;
|
||||||
|
|
||||||
|
function ensureLogPath(): string {
|
||||||
|
if (logPath) {
|
||||||
|
return logPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discovery = PathDiscovery.getInstance();
|
||||||
|
const logsDir = discovery.getLogsDirectory();
|
||||||
|
|
||||||
|
if (!existsSync(logsDir)) {
|
||||||
|
mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath = join(logsDir, 'rolling-memory.log');
|
||||||
|
return logPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RollingLogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export function rollingLog(
|
||||||
|
level: RollingLogLevel,
|
||||||
|
message: string,
|
||||||
|
payload: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const file = ensureLogPath();
|
||||||
|
const entry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
appendFileSync(file, `${JSON.stringify(entry)}\n`, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Logging should never throw user-facing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { readSettings } from './settings.js';
|
||||||
|
|
||||||
|
export interface RollingSettings {
|
||||||
|
captureEnabled: boolean;
|
||||||
|
summaryEnabled: boolean;
|
||||||
|
sessionStartEnabled: boolean;
|
||||||
|
chunkTokenLimit: number;
|
||||||
|
chunkOverlapTokens: number;
|
||||||
|
summaryTurnLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: RollingSettings = {
|
||||||
|
captureEnabled: true,
|
||||||
|
summaryEnabled: true,
|
||||||
|
sessionStartEnabled: true,
|
||||||
|
chunkTokenLimit: 600,
|
||||||
|
chunkOverlapTokens: 200,
|
||||||
|
summaryTurnLimit: 20
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const lowered = value.toLowerCase();
|
||||||
|
if (lowered === 'true') return true;
|
||||||
|
if (lowered === 'false') return false;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value: unknown, fallback: number): number {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRollingSettings(): RollingSettings {
|
||||||
|
const settings = readSettings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
captureEnabled: normalizeBoolean(
|
||||||
|
settings.rollingCaptureEnabled,
|
||||||
|
DEFAULTS.captureEnabled
|
||||||
|
),
|
||||||
|
summaryEnabled: normalizeBoolean(
|
||||||
|
settings.rollingSummaryEnabled,
|
||||||
|
DEFAULTS.summaryEnabled
|
||||||
|
),
|
||||||
|
sessionStartEnabled: normalizeBoolean(
|
||||||
|
settings.rollingSessionStartEnabled,
|
||||||
|
DEFAULTS.sessionStartEnabled
|
||||||
|
),
|
||||||
|
chunkTokenLimit: normalizeNumber(
|
||||||
|
settings.rollingChunkTokens,
|
||||||
|
DEFAULTS.chunkTokenLimit
|
||||||
|
),
|
||||||
|
chunkOverlapTokens: normalizeNumber(
|
||||||
|
settings.rollingChunkOverlapTokens,
|
||||||
|
DEFAULTS.chunkOverlapTokens
|
||||||
|
),
|
||||||
|
summaryTurnLimit: normalizeNumber(
|
||||||
|
settings.rollingSummaryTurnLimit,
|
||||||
|
DEFAULTS.summaryTurnLimit
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRollingCaptureEnabled(): boolean {
|
||||||
|
return getRollingSettings().captureEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRollingSummaryEnabled(): boolean {
|
||||||
|
return getRollingSettings().summaryEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRollingSessionStartEnabled(): boolean {
|
||||||
|
return getRollingSettings().sessionStartEnabled;
|
||||||
|
}
|
||||||
+19
-234
@@ -1,103 +1,17 @@
|
|||||||
export interface HookPayload {
|
/**
|
||||||
session_id: string;
|
* Core Type Definitions
|
||||||
transcript_path: string;
|
*
|
||||||
hook_event_name: string;
|
* Minimal type definitions for the claude-mem system.
|
||||||
}
|
* Only includes types that are actively imported and used.
|
||||||
|
*/
|
||||||
|
|
||||||
export interface PreCompactPayload extends HookPayload {
|
// =============================================================================
|
||||||
hook_event_name: 'PreCompact';
|
// ERROR CLASSES
|
||||||
trigger: 'manual' | 'auto';
|
// =============================================================================
|
||||||
custom_instructions?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionStartPayload extends HookPayload {
|
|
||||||
hook_event_name: 'SessionStart';
|
|
||||||
source: 'startup' | 'compact' | 'vscode' | 'web';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPromptSubmitPayload extends HookPayload {
|
|
||||||
hook_event_name: 'UserPromptSubmit';
|
|
||||||
prompt: string;
|
|
||||||
cwd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreToolUsePayload extends HookPayload {
|
|
||||||
hook_event_name: 'PreToolUse';
|
|
||||||
tool_name: string;
|
|
||||||
tool_input: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PostToolUsePayload extends HookPayload {
|
|
||||||
hook_event_name: 'PostToolUse';
|
|
||||||
tool_name: string;
|
|
||||||
tool_input: Record<string, unknown>;
|
|
||||||
tool_response: Record<string, unknown> & {
|
|
||||||
success?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationPayload extends HookPayload {
|
|
||||||
hook_event_name: 'Notification';
|
|
||||||
message: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StopPayload extends HookPayload {
|
|
||||||
hook_event_name: 'Stop';
|
|
||||||
stop_hook_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseHookResponse {
|
|
||||||
continue?: boolean;
|
|
||||||
stopReason?: string;
|
|
||||||
suppressOutput?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreCompactResponse extends BaseHookResponse {
|
|
||||||
decision?: 'approve' | 'block';
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionStartResponse extends BaseHookResponse {
|
|
||||||
hookSpecificOutput?: {
|
|
||||||
hookEventName: 'SessionStart';
|
|
||||||
additionalContext?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreToolUseResponse extends BaseHookResponse {
|
|
||||||
permissionDecision?: 'allow' | 'deny' | 'ask';
|
|
||||||
permissionDecisionReason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompressionResult {
|
|
||||||
compressedLines: string[];
|
|
||||||
originalTokens: number;
|
|
||||||
compressedTokens: number;
|
|
||||||
compressionRatio: number;
|
|
||||||
memoryNodes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryNode {
|
|
||||||
id: string;
|
|
||||||
type: 'document';
|
|
||||||
content: string;
|
|
||||||
timestamp: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HookError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public hookType: string,
|
|
||||||
public payload?: HookPayload,
|
|
||||||
public code?: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'HookError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for compression failures
|
||||||
|
*/
|
||||||
export class CompressionError extends Error {
|
export class CompressionError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -109,108 +23,8 @@ export class CompressionError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Logger {
|
|
||||||
info(message: string, meta?: Record<string, unknown>): void;
|
|
||||||
warn(message: string, meta?: Record<string, unknown>): void;
|
|
||||||
error(message: string, error?: Error, meta?: Record<string, unknown>): void;
|
|
||||||
debug(message: string, meta?: Record<string, unknown>): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileLogger implements Logger {
|
|
||||||
constructor(
|
|
||||||
private logFile: string,
|
|
||||||
private enableDebug = false
|
|
||||||
) {}
|
|
||||||
|
|
||||||
info(message: string, meta?: Record<string, unknown>): void {
|
|
||||||
this.log('INFO', message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
warn(message: string, meta?: Record<string, unknown>): void {
|
|
||||||
this.log('WARN', message, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
error(message: string, error?: Error, meta?: Record<string, unknown>): void {
|
|
||||||
const errorMeta = error ? { error: error.message, stack: error.stack } : {};
|
|
||||||
this.log('ERROR', message, { ...meta, ...errorMeta });
|
|
||||||
}
|
|
||||||
|
|
||||||
debug(message: string, meta?: Record<string, unknown>): void {
|
|
||||||
if (this.enableDebug) {
|
|
||||||
this.log('DEBUG', message, meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private log(
|
|
||||||
level: string,
|
|
||||||
message: string,
|
|
||||||
meta?: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
|
|
||||||
const logLine = `[${timestamp}] ${level}: ${message}${metaStr}\n`;
|
|
||||||
|
|
||||||
console.error(logLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateHookPayload(
|
|
||||||
payload: unknown,
|
|
||||||
expectedType: string
|
|
||||||
): HookPayload {
|
|
||||||
if (!payload || typeof payload !== 'object') {
|
|
||||||
throw new HookError(
|
|
||||||
`Invalid payload: expected object, got ${typeof payload}`,
|
|
||||||
expectedType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hookPayload = payload as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (!hookPayload.session_id || typeof hookPayload.session_id !== 'string') {
|
|
||||||
throw new HookError(
|
|
||||||
'Missing or invalid session_id',
|
|
||||||
expectedType,
|
|
||||||
hookPayload as unknown as HookPayload
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!hookPayload.transcript_path ||
|
|
||||||
typeof hookPayload.transcript_path !== 'string'
|
|
||||||
) {
|
|
||||||
throw new HookError(
|
|
||||||
'Missing or invalid transcript_path',
|
|
||||||
expectedType,
|
|
||||||
hookPayload as unknown as HookPayload
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hookPayload as unknown as HookPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSuccessResponse(
|
|
||||||
additionalData?: Record<string, unknown>
|
|
||||||
): BaseHookResponse {
|
|
||||||
return {
|
|
||||||
continue: true,
|
|
||||||
...additionalData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createErrorResponse(
|
|
||||||
reason: string,
|
|
||||||
additionalData?: Record<string, unknown>
|
|
||||||
): BaseHookResponse {
|
|
||||||
return {
|
|
||||||
continue: false,
|
|
||||||
stopReason: reason,
|
|
||||||
...additionalData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SETTINGS AND CONFIGURATION TYPES
|
// CONFIGURATION TYPES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,40 +38,11 @@ export interface Settings {
|
|||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
saveMemoriesOnClear?: boolean;
|
saveMemoriesOnClear?: boolean;
|
||||||
claudePath?: string;
|
claudePath?: string;
|
||||||
|
rollingCaptureEnabled?: boolean;
|
||||||
|
rollingSummaryEnabled?: boolean;
|
||||||
|
rollingSessionStartEnabled?: boolean;
|
||||||
|
rollingChunkTokens?: number;
|
||||||
|
rollingChunkOverlapTokens?: number;
|
||||||
|
rollingSummaryTurnLimit?: number;
|
||||||
[key: string]: unknown; // Allow additional properties
|
[key: string]: unknown; // Allow additional properties
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// MCP CLIENT INTERFACE TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Document structure for MCP operations
|
|
||||||
*/
|
|
||||||
export interface MCPDocument {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search result structure from MCP operations
|
|
||||||
*/
|
|
||||||
export interface MCPSearchResult {
|
|
||||||
documents?: MCPDocument[];
|
|
||||||
ids?: string[];
|
|
||||||
metadatas?: Record<string, unknown>[];
|
|
||||||
distances?: number[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for MCP client implementations (Chroma-based)
|
|
||||||
*/
|
|
||||||
export interface IMCPClient {
|
|
||||||
connect(): Promise<void>;
|
|
||||||
disconnect(): Promise<void>;
|
|
||||||
addDocuments(documents: MCPDocument[]): Promise<void>;
|
|
||||||
queryDocuments(query: string, limit?: number): Promise<MCPSearchResult>;
|
|
||||||
getDocuments(ids?: string[]): Promise<MCPSearchResult>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,819 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 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.
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './MemoryStream.jsx';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
'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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import MemoryStream from './MemoryStream.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<MemoryStream />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
Generated
+2707
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,987 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user