mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
Release v3.9.9
Published from npm package build Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
316
README.md
316
README.md
@@ -1,86 +1,248 @@
|
||||
# 🧠 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.
|
||||
|
||||
## ⚡️ 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?
|
||||
## ⚡️ Quick Start
|
||||
|
||||
```bash
|
||||
npm install -g claude-mem
|
||||
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.** 🧠✨
|
||||
899
dist/claude-mem.min.js
vendored
899
dist/claude-mem.min.js
vendored
File diff suppressed because one or more lines are too long
144
hook-templates/post-tool-use.js
Executable file
144
hook-templates/post-tool-use.js
Executable file
@@ -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);
|
||||
});
|
||||
57
hook-templates/session-start.js
Executable file
57
hook-templates/session-start.js
Executable file
@@ -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,30 +39,32 @@ export function createHookResponse(hookType, success, options = {}) {
|
||||
if (hookType === 'SessionStart') {
|
||||
if (success && options.context) {
|
||||
return {
|
||||
continue: true,
|
||||
suppressOutput: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext: options.context
|
||||
}
|
||||
};
|
||||
} else if (success) {
|
||||
// No context - just suppress output without any message
|
||||
} else {
|
||||
return {
|
||||
continue: 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
|
||||
return {
|
||||
@@ -115,9 +117,10 @@ export function formatSessionStartContext(contextData) {
|
||||
*/
|
||||
export async function executeCliCommand(command, args = [], options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { input, ...spawnOptions } = options;
|
||||
const process = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...options
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...spawnOptions
|
||||
});
|
||||
|
||||
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) => {
|
||||
resolve({
|
||||
stdout: stdout.trim(),
|
||||
@@ -224,4 +234,4 @@ export function debugLog(message, data = {}) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.error(`[${timestamp}] HOOK DEBUG: ${message}`, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
278
hook-templates/shared/hook-prompt-renderer.js
Normal file
278
hook-templates/shared/hook-prompt-renderer.js
Normal file
@@ -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
|
||||
};
|
||||
217
hook-templates/shared/hook-prompts.config.js
Normal file
217
hook-templates/shared/hook-prompts.config.js
Normal file
@@ -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
|
||||
};
|
||||
108
hook-templates/shared/path-resolver.js
Normal file
108
hook-templates/shared/path-resolver.js
Normal file
@@ -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()
|
||||
};
|
||||
}
|
||||
121
hook-templates/stop.js
Executable file
121
hook-templates/stop.js
Executable file
@@ -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);
|
||||
});
|
||||
133
hook-templates/user-prompt-submit.js
Executable file
133
hook-templates/user-prompt-submit.js
Executable file
@@ -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()
|
||||
};
|
||||
}
|
||||
13
package.json
13
package.json
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "3.7.2",
|
||||
"version": "3.9.9",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"claude-agent-sdk",
|
||||
"mcp",
|
||||
"memory",
|
||||
"compression",
|
||||
@@ -36,21 +36,18 @@
|
||||
"claude-mem": "./dist/claude-mem.min.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.88",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@modelcontextprotocol/sdk": "^0.5.0",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "^5.6.0",
|
||||
"chromadb": "^3.0.14",
|
||||
"commander": "^14.0.0",
|
||||
"glob": "^11.0.3",
|
||||
"gradient-string": "^3.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"oh-my-logo": "^0.3.2"
|
||||
"handlebars": "^4.7.8"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"hooks",
|
||||
"hook-templates",
|
||||
"commands",
|
||||
"src",
|
||||
".mcp.json",
|
||||
|
||||
189
src/bin/cli.ts
189
src/bin/cli.ts
@@ -7,20 +7,26 @@ import { Command } from 'commander';
|
||||
import { PACKAGE_NAME, PACKAGE_VERSION, PACKAGE_DESCRIPTION } from '../shared/config.js';
|
||||
|
||||
// Import command handlers
|
||||
import { compress } from '../commands/compress.js';
|
||||
import { install } from '../commands/install.js';
|
||||
import { uninstall } from '../commands/uninstall.js';
|
||||
import { status } from '../commands/status.js';
|
||||
import { logs } from '../commands/logs.js';
|
||||
import { loadContext } from '../commands/load-context.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 { save } from '../commands/save.js';
|
||||
import { changelog } from '../commands/changelog.js';
|
||||
// Cloud functionality disabled - incomplete setup
|
||||
// import { cloudCommand } from '../commands/cloud.js';
|
||||
import { importHistory } from '../commands/import-history.js';
|
||||
import { TranscriptCompressor } from '../core/compression/TranscriptCompressor.js';
|
||||
import { doctor } from '../commands/doctor.js';
|
||||
import { storeMemory } from '../commands/store-memory.js';
|
||||
import { storeOverview } from '../commands/store-overview.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();
|
||||
// </Block> =======================================
|
||||
@@ -35,19 +41,6 @@ program
|
||||
// </Block> =======================================
|
||||
|
||||
// <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
|
||||
// Natural pattern: Define command with its options and handler
|
||||
// Install command
|
||||
@@ -86,6 +79,20 @@ program
|
||||
.command('status')
|
||||
.description('Check installation status of Claude Memory System')
|
||||
.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> 1.7 ====================================
|
||||
@@ -148,20 +155,14 @@ const trashCmd = program
|
||||
trashCmd
|
||||
.command('view')
|
||||
.description('View contents of trash bin')
|
||||
.action(async () => {
|
||||
const { viewTrash } = await import('../commands/trash-view.js');
|
||||
await viewTrash();
|
||||
});
|
||||
.action(viewTrash);
|
||||
|
||||
// Trash empty subcommand
|
||||
trashCmd
|
||||
.command('empty')
|
||||
.description('Permanently delete all files in trash')
|
||||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.action(async (options: any) => {
|
||||
const { emptyTrash } = await import('../commands/trash-empty.js');
|
||||
await emptyTrash(options);
|
||||
});
|
||||
.action(emptyTrash);
|
||||
|
||||
// Restore command
|
||||
program
|
||||
@@ -170,15 +171,39 @@ program
|
||||
.action(restore);
|
||||
// </Block> =======================================
|
||||
|
||||
// Cloud command
|
||||
// Cloud functionality disabled - incomplete setup
|
||||
// program.addCommand(cloudCommand);
|
||||
|
||||
// Save command
|
||||
// Store memory command (for SDK streaming)
|
||||
program
|
||||
.command('save <message>')
|
||||
.description('Save a message to the memory system')
|
||||
.action(save);
|
||||
.command('store-memory')
|
||||
.description('Store a memory to all storage layers (used by SDK)')
|
||||
.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
|
||||
program
|
||||
@@ -193,67 +218,53 @@ program
|
||||
.option('-v, --verbose', 'Show detailed output')
|
||||
.action(changelog);
|
||||
|
||||
// Import History command
|
||||
// Generate title command
|
||||
program
|
||||
.command('import-history')
|
||||
.description('Import historical Claude Code conversations into memory')
|
||||
.option('-v, --verbose', 'Show detailed output')
|
||||
.option('-m, --multi', 'Enable multi-select mode (default is single-select)')
|
||||
.action(importHistory);
|
||||
|
||||
// Migrate Index command
|
||||
program
|
||||
.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();
|
||||
});
|
||||
.command('generate-title <prompt>')
|
||||
.description('Generate a session title and subtitle from a prompt')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--oneline', 'Output as single line (title - subtitle)')
|
||||
.option('--save', 'Save title and subtitle to session metadata')
|
||||
.option('--project <name>', 'Project name (required with --save)')
|
||||
.option('--session <id>', 'Session ID (required with --save)')
|
||||
.action(generateTitle);
|
||||
|
||||
// </Block> =======================================
|
||||
|
||||
// Debug command to show filtered output
|
||||
program
|
||||
.command('debug-filter')
|
||||
.description('Show filtered transcript output (first 5 messages)')
|
||||
.argument('<transcript-path>', 'Path to transcript file')
|
||||
.action((transcriptPath) => {
|
||||
const compressor = new TranscriptCompressor();
|
||||
compressor.showFilteredOutput(transcriptPath);
|
||||
});
|
||||
// <Block> 1.12 ===================================
|
||||
// Dynamic Chroma MCP Commands
|
||||
// Natural pattern: Register all Chroma MCP tools as CLI commands
|
||||
try {
|
||||
const chromaTools = loadChromaMCPTools();
|
||||
|
||||
for (const tool of chromaTools) {
|
||||
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 ===================================
|
||||
// CLI Execution
|
||||
// Natural pattern: After defining all commands, parse and execute
|
||||
// Parse arguments and execute
|
||||
program.parse();
|
||||
// </Block> =======================================
|
||||
// </Block> =======================================
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { query } from '@anthropic-ai/claude-code';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getClaudePath } from '../shared/settings.js';
|
||||
|
||||
196
src/commands/chroma-mcp.ts
Normal file
196
src/commands/chroma-mcp.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
100
src/commands/doctor.ts
Normal file
100
src/commands/doctor.ts
Normal file
@@ -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)
|
||||
});
|
||||
}
|
||||
179
src/commands/generate-title.ts
Normal file
179
src/commands/generate-title.ts
Normal file
@@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,11 +211,13 @@ function detectExistingInstallation(): {
|
||||
scope: undefined as InstallScope | undefined
|
||||
};
|
||||
|
||||
// Check for hooks
|
||||
const hooksDir = PathDiscovery.getHooksDirectory();
|
||||
result.hasHooks = existsSync(hooksDir) &&
|
||||
existsSync(join(hooksDir, 'pre-compact.js')) &&
|
||||
existsSync(join(hooksDir, 'session-start.js'));
|
||||
// Check for runtime hooks (installed to user's hooks directory from hook-templates/)
|
||||
const runtimeHooksDir = PathDiscovery.getHooksDirectory();
|
||||
result.hasHooks = existsSync(runtimeHooksDir) &&
|
||||
existsSync(join(runtimeHooksDir, '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
|
||||
const pathDiscovery = PathDiscovery.getInstance();
|
||||
@@ -223,23 +225,19 @@ function detectExistingInstallation(): {
|
||||
const projectMcpPath = pathDiscovery.getProjectMcpConfigPath();
|
||||
|
||||
if (existsSync(userMcpPath)) {
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(userMcpPath, 'utf8'));
|
||||
if (config.mcpServers?.['claude-mem']) {
|
||||
result.hasChromaMcp = true;
|
||||
result.scope = 'user';
|
||||
}
|
||||
} catch {}
|
||||
const config = JSON.parse(readFileSync(userMcpPath, 'utf8'));
|
||||
if (config.mcpServers?.['claude-mem']) {
|
||||
result.hasChromaMcp = true;
|
||||
result.scope = 'user';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (existsSync(projectMcpPath)) {
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(projectMcpPath, 'utf8'));
|
||||
if (config.mcpServers?.['claude-mem']) {
|
||||
result.hasChromaMcp = true;
|
||||
result.scope = 'project';
|
||||
}
|
||||
} catch {}
|
||||
const config = JSON.parse(readFileSync(projectMcpPath, 'utf8'));
|
||||
if (config.mcpServers?.['claude-mem']) {
|
||||
result.hasChromaMcp = true;
|
||||
result.scope = 'project';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for settings
|
||||
@@ -370,10 +368,10 @@ async function backupExistingConfig(): Promise<string | null> {
|
||||
try {
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
// Backup hooks if they exist
|
||||
const hooksDir = pathDiscovery.getHooksDirectory();
|
||||
if (existsSync(hooksDir)) {
|
||||
copyFileRecursively(hooksDir, join(backupDir, 'hooks'));
|
||||
// Backup runtime hooks if they exist
|
||||
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||
if (existsSync(runtimeHooksDir)) {
|
||||
copyFileRecursively(runtimeHooksDir, join(backupDir, 'hooks'));
|
||||
}
|
||||
|
||||
// Backup settings
|
||||
@@ -432,28 +430,46 @@ 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 hooksDir = pathDiscovery.getHooksDirectory();
|
||||
|
||||
// Find the installed package hooks directory
|
||||
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||
|
||||
// Find the installed package hook-templates directory
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
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 packageHooksDir: string | null = null;
|
||||
|
||||
// Walk up the tree to find the hooks directory
|
||||
let packageHookTemplatesDir: string | null = null;
|
||||
|
||||
// Walk up the tree to find the hook-templates directory
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const hooksPath = join(currentDir, 'hooks');
|
||||
|
||||
// Check if this directory has the hook files
|
||||
if (existsSync(join(hooksPath, 'pre-compact.js'))) {
|
||||
packageHooksDir = hooksPath;
|
||||
const hookTemplatesPath = join(currentDir, 'hook-templates');
|
||||
|
||||
// Check if this directory has the hook template files
|
||||
if (existsSync(join(hookTemplatesPath, 'session-start.js'))) {
|
||||
packageHookTemplatesDir = hookTemplatesPath;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// Move up one directory
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
@@ -462,47 +478,46 @@ function writeHookFiles(timeout: number = 180000): void {
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
// If we still haven't found it, use PathDiscovery to find package hooks
|
||||
if (!packageHooksDir) {
|
||||
try {
|
||||
packageHooksDir = pathDiscovery.findPackageHooksDirectory();
|
||||
} catch (error) {
|
||||
throw new Error('Cannot dynamically locate hooks directory. The package may be corrupted.');
|
||||
|
||||
// If we still haven't found it, use PathDiscovery to find package hook templates
|
||||
if (!packageHookTemplatesDir) {
|
||||
packageHookTemplatesDir = pathDiscovery.findPackageHookTemplatesDirectory();
|
||||
}
|
||||
|
||||
// Copy hook template files from the package to runtime hooks directory
|
||||
const hookFiles = ['session-start.js', 'stop.js', 'user-prompt-submit.js', 'post-tool-use.js'];
|
||||
|
||||
for (const hookFile of hookFiles) {
|
||||
const runtimeHookPath = join(runtimeHooksDir, hookFile);
|
||||
const sourceTemplatePath = join(packageHookTemplatesDir, hookFile);
|
||||
|
||||
copyFileSync(sourceTemplatePath, runtimeHookPath);
|
||||
chmodSync(runtimeHookPath, 0o755);
|
||||
}
|
||||
|
||||
|
||||
// Copy shared directory if it exists in hook-templates
|
||||
if (packageHookTemplatesDir) {
|
||||
const sourceSharedTemplateDir = join(packageHookTemplatesDir, 'shared');
|
||||
const runtimeSharedDir = join(runtimeHooksDir, 'shared');
|
||||
|
||||
if (existsSync(sourceSharedTemplateDir)) {
|
||||
copyFileRecursively(sourceSharedTemplateDir, runtimeSharedDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy hook files from the package instead of creating wrappers
|
||||
const hooks = ['pre-compact.js', 'session-start.js', 'session-end.js'];
|
||||
|
||||
for (const hookName of hooks) {
|
||||
const sourcePath = join(packageHooksDir, hookName);
|
||||
const destPath = join(hooksDir, hookName);
|
||||
|
||||
if (existsSync(sourcePath)) {
|
||||
copyFileSync(sourcePath, destPath);
|
||||
chmodSync(destPath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Copy shared directory if it exists
|
||||
const sourceSharedDir = join(packageHooksDir, 'shared');
|
||||
const destSharedDir = join(hooksDir, 'shared');
|
||||
|
||||
if (existsSync(sourceSharedDir)) {
|
||||
copyFileRecursively(sourceSharedDir, destSharedDir);
|
||||
}
|
||||
|
||||
// Write configuration with custom timeout
|
||||
const hookConfigPath = join(hooksDir, 'config.json');
|
||||
|
||||
// Write runtime hook configuration with custom timeout
|
||||
// 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 = {
|
||||
packageName: PACKAGE_NAME,
|
||||
cliCommand: PACKAGE_NAME,
|
||||
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> {
|
||||
const pathDiscovery = PathDiscovery.getInstance();
|
||||
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
|
||||
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
|
||||
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
|
||||
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
|
||||
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||
// Runtime hooks (copied from hook-templates/ during installation)
|
||||
const sessionStartScript = join(runtimeHooksDir, 'session-start.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 = {};
|
||||
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
|
||||
// Non-tool hooks: filter out configs where hooks contain our commands
|
||||
if (settings.hooks.PreCompact) {
|
||||
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) {
|
||||
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((cfg: any) =>
|
||||
!cfg.hooks?.some((hook: any) =>
|
||||
hook.command?.includes(PACKAGE_NAME) || hook.command?.includes('session-start.js')
|
||||
)
|
||||
);
|
||||
if (!settings.hooks.SessionStart.length) delete settings.hooks.SessionStart;
|
||||
}
|
||||
|
||||
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;
|
||||
// Remove both old and SDK hooks for clean reinstall
|
||||
const hookTypes = ['SessionStart', 'Stop', 'UserPromptSubmit', 'PostToolUse'];
|
||||
|
||||
for (const hookType of hookTypes) {
|
||||
if (settings.hooks[hookType]) {
|
||||
settings.hooks[hookType] = settings.hooks[hookType].filter((cfg: any) =>
|
||||
!cfg.hooks?.some((hook: any) =>
|
||||
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[hookType].length) delete settings.hooks[hookType];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔒 LOCKED by @docs-agent | Change to 🔑 to allow @docs-agent edits
|
||||
*
|
||||
* OFFICIAL DOCS: Claude Code Hooks Configuration v2025
|
||||
* Last Verified: 2025-08-31
|
||||
*
|
||||
* Hook Configuration Structure Requirements:
|
||||
* - 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:
|
||||
* {
|
||||
* hooks: [{
|
||||
* type: "command",
|
||||
* command: "/path/to/script.js"
|
||||
* }]
|
||||
* }
|
||||
*
|
||||
* @see https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||
* @see docs/claude-code/hook-configuration.md for full documentation
|
||||
*
|
||||
* OFFICIAL DOCS: Claude Code Hooks Configuration
|
||||
* Source: https://docs.claude.com/en/docs/claude-code/hooks
|
||||
* Last Verified: 2025-10-02
|
||||
*
|
||||
* Hook Configuration Structure Requirements (from official docs):
|
||||
*
|
||||
* Tool Hooks (PreToolUse, PostToolUse):
|
||||
* - MUST include 'matcher' field with tool name pattern (supports regex/wildcards)
|
||||
* - Example: { matcher: "*", hooks: [{ type: "command", command: "...", timeout: 180 }] }
|
||||
*
|
||||
* Non-Tool Hooks (SessionStart, Stop, UserPromptSubmit, SessionEnd, etc.):
|
||||
* - 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)
|
||||
// Official docs: https://docs.claude.com/en/docs/claude-code/hooks
|
||||
// Hook type: SessionStart (non-tool event - no matcher required)
|
||||
if (!settings.hooks.SessionStart) {
|
||||
settings.hooks.SessionStart = [];
|
||||
}
|
||||
|
||||
|
||||
// ✅ CORRECT: Non-tool hooks have no 'pattern' or 'matcher' field
|
||||
// Timeout is 180 SECONDS (3 minutes) - sufficient for context loading
|
||||
settings.hooks.SessionStart.push({
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: sessionStartScript,
|
||||
timeout: 180
|
||||
type: "command", // Required field - only "command" type supported
|
||||
command: sessionStartScript, // Absolute path to hook script
|
||||
timeout: 180 // Seconds (not milliseconds) - per official docs
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Add SessionEnd hook (only if the file exists)
|
||||
if (existsSync(sessionEndScript)) {
|
||||
if (!settings.hooks.SessionEnd) {
|
||||
settings.hooks.SessionEnd = [];
|
||||
// Add Stop hook - Non-tool hook (no matcher field)
|
||||
// Official docs: https://docs.claude.com/en/docs/claude-code/hooks
|
||||
// Hook type: Stop (non-tool event - no matcher required)
|
||||
if (existsSync(stopScript)) {
|
||||
if (!settings.hooks.Stop) {
|
||||
settings.hooks.Stop = [];
|
||||
}
|
||||
|
||||
// ✅ CORRECT: Non-tool hooks have no 'pattern' or 'matcher' field
|
||||
settings.hooks.SessionEnd.push({
|
||||
|
||||
// ✅ CORRECT: Non-tool hooks have no 'matcher' field
|
||||
// Timeout is 60 SECONDS (1 minute) - sufficient for session overview generation
|
||||
settings.hooks.Stop.push({
|
||||
hooks: [{
|
||||
type: "command",
|
||||
command: sessionEndScript,
|
||||
timeout: 180
|
||||
type: "command", // Required field - only "command" type supported
|
||||
command: stopScript, // Absolute path to hook script
|
||||
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
|
||||
}]
|
||||
});
|
||||
}
|
||||
@@ -764,23 +807,19 @@ async function configureSmartTrashAlias(): Promise<void> {
|
||||
|
||||
for (const configPath of shellConfigs) {
|
||||
if (!existsSync(configPath)) continue;
|
||||
|
||||
try {
|
||||
let content = readFileSync(configPath, 'utf8');
|
||||
|
||||
// Check if alias already exists
|
||||
if (content.includes(aliasLine)) {
|
||||
continue; // Already configured
|
||||
}
|
||||
|
||||
// Add the alias
|
||||
const aliasBlock = `\n${commentLine}\n${aliasLine}\n`;
|
||||
content += aliasBlock;
|
||||
|
||||
writeFileSync(configPath, content);
|
||||
} catch (error) {
|
||||
// Silent fail - not critical
|
||||
|
||||
let content = readFileSync(configPath, 'utf8');
|
||||
|
||||
// Check if alias already exists
|
||||
if (content.includes(aliasLine)) {
|
||||
continue; // Already configured
|
||||
}
|
||||
|
||||
// Add the alias
|
||||
const aliasBlock = `\n${commentLine}\n${aliasLine}\n`;
|
||||
content += aliasBlock;
|
||||
|
||||
writeFileSync(configPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,17 +925,24 @@ function installClaudeCommands(force: boolean = false): void {
|
||||
async function verifyInstallation(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Verifying installation');
|
||||
|
||||
|
||||
const issues: string[] = [];
|
||||
|
||||
// Check hooks
|
||||
|
||||
// Check runtime hooks (installed from hook-templates/)
|
||||
const pathDiscovery = PathDiscovery.getInstance();
|
||||
const hooksDir = pathDiscovery.getHooksDirectory();
|
||||
if (!existsSync(join(hooksDir, 'pre-compact.js'))) {
|
||||
issues.push('Pre-compact hook not found');
|
||||
}
|
||||
if (!existsSync(join(hooksDir, 'session-start.js'))) {
|
||||
issues.push('Session-start hook not found');
|
||||
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||
|
||||
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 (issues.length > 0) {
|
||||
@@ -1005,7 +1051,7 @@ export async function install(options: OptionValues = {}): Promise<void> {
|
||||
name: 'Installing memory hooks',
|
||||
action: async () => {
|
||||
await sleep(400);
|
||||
writeHookFiles(config.hookTimeout);
|
||||
writeHookFiles(config.hookTimeout, config.forceReinstall);
|
||||
await sleep(200);
|
||||
}
|
||||
},
|
||||
@@ -1030,11 +1076,9 @@ export async function install(options: OptionValues = {}): Promise<void> {
|
||||
const pathDiscovery = PathDiscovery.getInstance();
|
||||
const userSettingsPath = pathDiscovery.getUserSettingsPath();
|
||||
let userSettings: Settings = {};
|
||||
|
||||
|
||||
if (existsSync(userSettingsPath)) {
|
||||
try {
|
||||
userSettings = JSON.parse(readFileSync(userSettingsPath, 'utf8'));
|
||||
} catch {}
|
||||
userSettings = JSON.parse(readFileSync(userSettingsPath, 'utf8'));
|
||||
}
|
||||
|
||||
userSettings.backend = 'chroma';
|
||||
@@ -1145,4 +1189,4 @@ ${chalk.gray(' • /clear now saves memories automatically (takes ~1 minute)')}
|
||||
|
||||
// Final flourish
|
||||
console.log(fastRainbow('\n✨ Welcome to the future of persistent AI conversations! ✨\n'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { OptionValues } from 'commander';
|
||||
import fs from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PathDiscovery } from '../services/path-discovery.js';
|
||||
import {
|
||||
import {
|
||||
createCompletionMessage,
|
||||
createContextualError,
|
||||
createUserFriendlyError,
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
outputSessionStartContent
|
||||
} from '../prompts/templates/context/ContextTemplates.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 {
|
||||
folderCount: number;
|
||||
@@ -19,6 +22,45 @@ interface TrashStatus {
|
||||
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 {
|
||||
const aliases = new Set<string>();
|
||||
aliases.add(projectName);
|
||||
@@ -67,6 +109,124 @@ function getTrashStatus(): TrashStatus {
|
||||
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> {
|
||||
try {
|
||||
// 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
|
||||
let recentMemories: MemoryRow[] = [];
|
||||
let recentOverviews: OverviewRow[] = [];
|
||||
let recentSessions: SessionRow[] = [];
|
||||
|
||||
// Auto-detect current project for session-start format if no project specified
|
||||
let projectToUse = options.project;
|
||||
@@ -92,14 +251,19 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
|
||||
projectToUse = PathDiscovery.getCurrentProjectName();
|
||||
}
|
||||
|
||||
if (options.format === 'session-start') {
|
||||
await renderRollingSessionStart(projectToUse);
|
||||
return;
|
||||
}
|
||||
|
||||
const overviewLimit = options.format === 'json' ? 5 : 3;
|
||||
|
||||
if (projectToUse) {
|
||||
recentMemories = await storage.getRecentMemoriesForProject(projectToUse, 10);
|
||||
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, options.format === 'session-start' ? 5 : 3);
|
||||
recentSessions = await storage.getRecentSessionsForProject(projectToUse, 5);
|
||||
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, overviewLimit);
|
||||
} else {
|
||||
recentMemories = await storage.getRecentMemories(10);
|
||||
recentOverviews = await storage.getRecentOverviews(options.format === 'session-start' ? 5 : 3);
|
||||
recentSessions = await storage.getRecentSessions(5);
|
||||
recentOverviews = await storage.getRecentOverviews(overviewLimit);
|
||||
}
|
||||
|
||||
// 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
|
||||
}));
|
||||
|
||||
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 (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0 && sessionsAsJSON.length === 0) {
|
||||
if (options.format === 'session-start') {
|
||||
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
|
||||
}
|
||||
if (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the same output logic as the original implementation
|
||||
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') {
|
||||
if (options.format === 'json') {
|
||||
// For JSON format, combine last 10 of each type
|
||||
const recentObjects = [...memoriesAsJSON, ...overviewsAsJSON];
|
||||
console.log(JSON.stringify(recentObjects));
|
||||
@@ -189,7 +317,7 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
|
||||
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(`🗑️ Trash – ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} – use \`claude-mem restore\``);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
@@ -276,10 +404,10 @@ async function loadContextFromJSONL(options: OptionValues = {}): Promise<void> {
|
||||
}
|
||||
|
||||
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 recentOverviews = filteredOverviews.slice(-5);
|
||||
const recentSessions = filteredSessions.slice(-5);
|
||||
const recentOverviews = filteredOverviews.slice(-10);
|
||||
const recentSessions = filteredSessions.slice(-10);
|
||||
|
||||
// Combine them for the display
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { join, resolve, dirname } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
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));
|
||||
|
||||
@@ -10,13 +13,14 @@ export async function status(): Promise<void> {
|
||||
console.log('🔍 Claude Memory System Status Check');
|
||||
console.log('=====================================\n');
|
||||
|
||||
console.log('📂 Installed Hook Scripts:');
|
||||
console.log('📂 Runtime Hook Scripts (installed from hook-templates/):');
|
||||
const pathDiscovery = PathDiscovery.getInstance();
|
||||
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
|
||||
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
|
||||
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
|
||||
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
|
||||
|
||||
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
|
||||
const stopScript = join(runtimeHooksDir, 'stop.js');
|
||||
const userPromptScript = join(runtimeHooksDir, 'user-prompt-submit.js');
|
||||
const postToolScript = join(runtimeHooksDir, 'post-tool-use.js');
|
||||
|
||||
const checkScript = (path: string, name: string) => {
|
||||
if (existsSync(path)) {
|
||||
console.log(` ✅ ${name}: Found at ${path}`);
|
||||
@@ -24,10 +28,11 @@ export async function status(): Promise<void> {
|
||||
console.log(` ❌ ${name}: Not found at ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
checkScript(preCompactScript, 'pre-compact.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('');
|
||||
|
||||
@@ -43,28 +48,35 @@ export async function status(): Promise<void> {
|
||||
|
||||
try {
|
||||
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) =>
|
||||
matcher.hooks?.some((hook: any) =>
|
||||
matcher.hooks?.some((hook: any) =>
|
||||
hook.command?.includes('session-start.js') || hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
const hasSessionEnd = settings.hooks?.SessionEnd?.some((matcher: any) =>
|
||||
matcher.hooks?.some((hook: any) =>
|
||||
hook.command?.includes('session-end.js') || hook.command?.includes('claude-mem')
|
||||
|
||||
const hasStop = settings.hooks?.Stop?.some((matcher: any) =>
|
||||
matcher.hooks?.some((hook: any) =>
|
||||
hook.command?.includes('stop.js') || hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
console.log(` PreCompact: ${hasPreCompact ? '✅' : '❌'}`);
|
||||
|
||||
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(` SessionStart: ${hasSessionStart ? '✅' : '❌'}`);
|
||||
console.log(` SessionEnd: ${hasSessionEnd ? '✅' : '❌'}`);
|
||||
console.log(` Stop: ${hasStop ? '✅' : '❌'}`);
|
||||
console.log(` UserPromptSubmit: ${hasUserPrompt ? '✅' : '❌'}`);
|
||||
console.log(` PostToolUse: ${hasPostTool ? '✅' : '❌'}`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(` ⚠️ Could not parse settings`);
|
||||
@@ -136,7 +148,32 @@ export async function status(): Promise<void> {
|
||||
console.log(' ✅ Storage backend: Chroma MCP');
|
||||
console.log(` 📍 Data location: ${pathDiscovery.getChromaDirectory()}`);
|
||||
console.log(' 🔍 Features: Vector search, semantic similarity, document storage');
|
||||
|
||||
|
||||
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:');
|
||||
@@ -149,15 +186,15 @@ export async function status(): Promise<void> {
|
||||
try {
|
||||
if (existsSync(globalPath)) {
|
||||
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;
|
||||
installLocation = 'Global';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (existsSync(projectPath)) {
|
||||
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;
|
||||
installLocation = installLocation === 'Global' ? 'Global + Project' : 'Project';
|
||||
}
|
||||
|
||||
154
src/commands/store-memory.ts
Normal file
154
src/commands/store-memory.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
45
src/commands/store-overview.ts
Normal file
45
src/commands/store-overview.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,69 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
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> {
|
||||
console.log('🔄 Uninstalling Claude Memory System hooks...');
|
||||
|
||||
@@ -26,10 +87,10 @@ export async function uninstall(options: OptionValues = {}): Promise<void> {
|
||||
}
|
||||
|
||||
const pathDiscovery = PathDiscovery.getInstance();
|
||||
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
|
||||
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
|
||||
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
|
||||
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
|
||||
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
|
||||
const preCompactScript = join(runtimeHooksDir, 'pre-compact.js');
|
||||
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
|
||||
const sessionEndScript = join(runtimeHooksDir, 'session-end.js');
|
||||
|
||||
let removedCount = 0;
|
||||
|
||||
@@ -39,95 +100,99 @@ export async function uninstall(options: OptionValues = {}): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(location.path, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (!settings.hooks) {
|
||||
console.log(`⏭️ No hooks configured in ${location.name} settings`);
|
||||
continue;
|
||||
const content = readFileSync(location.path, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (!settings.hooks) {
|
||||
console.log(`⏭️ No hooks configured in ${location.name} settings`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
|
||||
if (settings.hooks.PreCompact) {
|
||||
const filteredPreCompact = settings.hooks.PreCompact.filter((matcher: any) =>
|
||||
!matcher.hooks?.some((hook: any) =>
|
||||
hook.command === preCompactScript ||
|
||||
hook.command?.includes('pre-compact.js') ||
|
||||
hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
if (filteredPreCompact.length !== settings.hooks.PreCompact.length) {
|
||||
settings.hooks.PreCompact = filteredPreCompact.length ? filteredPreCompact : undefined;
|
||||
modified = true;
|
||||
console.log(`✅ Removed PreCompact hook from ${location.name} settings`);
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
|
||||
if (settings.hooks.PreCompact) {
|
||||
const filteredPreCompact = settings.hooks.PreCompact.filter((matcher: any) =>
|
||||
!matcher.hooks?.some((hook: any) =>
|
||||
hook.command === preCompactScript ||
|
||||
hook.command?.includes('pre-compact.js') ||
|
||||
hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
if (filteredPreCompact.length !== settings.hooks.PreCompact.length) {
|
||||
settings.hooks.PreCompact = filteredPreCompact.length ? filteredPreCompact : undefined;
|
||||
modified = true;
|
||||
console.log(`✅ Removed PreCompact hook from ${location.name} settings`);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.hooks.SessionStart) {
|
||||
const filteredSessionStart = settings.hooks.SessionStart.filter((matcher: any) =>
|
||||
!matcher.hooks?.some((hook: any) =>
|
||||
hook.command === sessionStartScript ||
|
||||
hook.command?.includes('session-start.js') ||
|
||||
hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
if (filteredSessionStart.length !== settings.hooks.SessionStart.length) {
|
||||
settings.hooks.SessionStart = filteredSessionStart.length ? filteredSessionStart : undefined;
|
||||
modified = true;
|
||||
console.log(`✅ Removed SessionStart hook from ${location.name} settings`);
|
||||
}
|
||||
|
||||
if (settings.hooks.SessionStart) {
|
||||
const filteredSessionStart = settings.hooks.SessionStart.filter((matcher: any) =>
|
||||
!matcher.hooks?.some((hook: any) =>
|
||||
hook.command === sessionStartScript ||
|
||||
hook.command?.includes('session-start.js') ||
|
||||
hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
if (filteredSessionStart.length !== settings.hooks.SessionStart.length) {
|
||||
settings.hooks.SessionStart = filteredSessionStart.length ? filteredSessionStart : undefined;
|
||||
modified = true;
|
||||
console.log(`✅ Removed SessionStart hook from ${location.name} settings`);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.hooks.SessionEnd) {
|
||||
const filteredSessionEnd = settings.hooks.SessionEnd.filter((matcher: any) =>
|
||||
!matcher.hooks?.some((hook: any) =>
|
||||
hook.command === sessionEndScript ||
|
||||
hook.command?.includes('session-end.js') ||
|
||||
hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
if (filteredSessionEnd.length !== settings.hooks.SessionEnd.length) {
|
||||
settings.hooks.SessionEnd = filteredSessionEnd.length ? filteredSessionEnd : undefined;
|
||||
modified = true;
|
||||
console.log(`✅ Removed SessionEnd hook from ${location.name} settings`);
|
||||
}
|
||||
|
||||
if (settings.hooks.SessionEnd) {
|
||||
const filteredSessionEnd = settings.hooks.SessionEnd.filter((matcher: any) =>
|
||||
!matcher.hooks?.some((hook: any) =>
|
||||
hook.command === sessionEndScript ||
|
||||
hook.command?.includes('session-end.js') ||
|
||||
hook.command?.includes('claude-mem')
|
||||
)
|
||||
);
|
||||
|
||||
if (filteredSessionEnd.length !== settings.hooks.SessionEnd.length) {
|
||||
settings.hooks.SessionEnd = filteredSessionEnd.length ? filteredSessionEnd : undefined;
|
||||
modified = true;
|
||||
console.log(`✅ Removed SessionEnd hook from ${location.name} settings`);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.hooks.PreCompact === undefined) delete settings.hooks.PreCompact;
|
||||
if (settings.hooks.SessionStart === undefined) delete settings.hooks.SessionStart;
|
||||
if (settings.hooks.SessionEnd === undefined) delete settings.hooks.SessionEnd;
|
||||
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
||||
|
||||
if (modified) {
|
||||
const backupPath = location.path + '.backup.' + Date.now();
|
||||
writeFileSync(backupPath, content);
|
||||
console.log(`📋 Created backup: ${backupPath}`);
|
||||
|
||||
writeFileSync(location.path, JSON.stringify(settings, null, 2));
|
||||
removedCount++;
|
||||
console.log(`✅ Updated ${location.name} settings: ${location.path}`);
|
||||
} else {
|
||||
console.log(`ℹ️ No Claude Memory System hooks found in ${location.name} settings`);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(`⚠️ Could not process ${location.name} settings: ${error.message}`);
|
||||
}
|
||||
|
||||
if (settings.hooks.PreCompact === undefined) delete settings.hooks.PreCompact;
|
||||
if (settings.hooks.SessionStart === undefined) delete settings.hooks.SessionStart;
|
||||
if (settings.hooks.SessionEnd === undefined) delete settings.hooks.SessionEnd;
|
||||
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
||||
|
||||
if (modified) {
|
||||
const backupPath = location.path + '.backup.' + Date.now();
|
||||
writeFileSync(backupPath, content);
|
||||
console.log(`📋 Created backup: ${backupPath}`);
|
||||
|
||||
writeFileSync(location.path, JSON.stringify(settings, null, 2));
|
||||
removedCount++;
|
||||
console.log(`✅ Updated ${location.name} settings: ${location.path}`);
|
||||
} else {
|
||||
console.log(`ℹ️ No Claude Memory System hooks found in ${location.name} settings`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Smart Trash alias from shell configs
|
||||
const removedAlias = await removeSmartTrashAlias();
|
||||
|
||||
console.log('');
|
||||
if (removedCount > 0) {
|
||||
if (removedCount > 0 || removedAlias) {
|
||||
console.log('✨ Uninstallation complete!');
|
||||
console.log('The Claude Memory System hooks have been removed from your settings.');
|
||||
if (removedCount > 0) {
|
||||
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('Note: Your compressed transcripts and archives are preserved.');
|
||||
console.log('To reinstall: claude-mem install');
|
||||
} 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.');
|
||||
}
|
||||
}
|
||||
80
src/commands/update-session-metadata.ts
Normal file
80
src/commands/update-session-metadata.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
187
src/constants.ts
187
src/constants.ts
@@ -1,92 +1,9 @@
|
||||
/**
|
||||
* Claude Memory System - Core Constants
|
||||
*
|
||||
* This file contains core application constants, CLI messages,
|
||||
* configuration templates, and infrastructure-related constants.
|
||||
*
|
||||
* This file contains debug logging templates used throughout the application.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
// =============================================================================
|
||||
@@ -100,107 +17,9 @@ export const DEBUG_MESSAGES = {
|
||||
SESSION_ID: (id: string) => `🔍 Session ID: ${id}`,
|
||||
PROJECT_NAME: (name: string) => `📝 PROJECT NAME: ${name}`,
|
||||
CLAUDE_SDK_CALL: '🤖 Calling Claude SDK to analyze and populate memory database...',
|
||||
TRANSCRIPT_STATS: (size: number, count: number) =>
|
||||
TRANSCRIPT_STATS: (size: number, count: number) =>
|
||||
`📊 Transcript size: ${size} characters, ${count} messages`,
|
||||
COMPRESSION_COMPLETE: (count: number) => `✅ COMPRESSION COMPLETE\n Total summaries extracted: ${count}`,
|
||||
CLAUDE_PATH_FOUND: (path: string) => `🎯 Found Claude Code at: ${path}`,
|
||||
MCP_CONFIG_USED: (path: string) => `📋 Using MCP config: ${path}`
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
224
src/prompts/README.md
Normal file
224
src/prompts/README.md
Normal file
@@ -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 };
|
||||
159
src/prompts/hook-prompt-renderer.ts
Normal file
159
src/prompts/hook-prompt-renderer.ts
Normal file
@@ -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 };
|
||||
306
src/prompts/hook-prompts.config.ts
Normal file
306
src/prompts/hook-prompts.config.ts
Normal file
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -263,28 +208,6 @@ export function formatTimeAgo(timestamp: string | Date): string {
|
||||
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)
|
||||
// =============================================================================
|
||||
@@ -308,55 +231,6 @@ interface SessionGroup {
|
||||
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
|
||||
*/
|
||||
@@ -377,66 +251,11 @@ interface SessionOverviewGroup {
|
||||
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
|
||||
* 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) {
|
||||
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');
|
||||
this._packageRoot = dirname(packageJsonPath);
|
||||
return this._packageRoot;
|
||||
} catch {}
|
||||
} catch {
|
||||
// Continue to next method
|
||||
}
|
||||
|
||||
// Method 2: Walk up from current module location
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
@@ -190,15 +192,13 @@ export class PathDiscovery {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const packageJsonPath = join(currentDir, 'package.json');
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = require(packageJsonPath);
|
||||
if (packageJson.name === 'claude-mem') {
|
||||
this._packageRoot = currentDir;
|
||||
return this._packageRoot;
|
||||
}
|
||||
} catch {}
|
||||
const packageJson = require(packageJsonPath);
|
||||
if (packageJson.name === 'claude-mem') {
|
||||
this._packageRoot = currentDir;
|
||||
return this._packageRoot;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
@@ -206,36 +206,46 @@ export class PathDiscovery {
|
||||
|
||||
// Method 3: Try npm list command
|
||||
try {
|
||||
const npmOutput = execSync('npm list -g claude-mem --json 2>/dev/null || npm list claude-mem --json 2>/dev/null', {
|
||||
encoding: 'utf8'
|
||||
const npmOutput = execSync('npm list -g claude-mem --json 2>/dev/null || npm list claude-mem --json 2>/dev/null', {
|
||||
encoding: 'utf8'
|
||||
});
|
||||
const npmData = JSON.parse(npmOutput);
|
||||
|
||||
|
||||
if (npmData.dependencies?.['claude-mem']?.resolved) {
|
||||
this._packageRoot = dirname(npmData.dependencies['claude-mem'].resolved);
|
||||
return this._packageRoot;
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Continue to error
|
||||
}
|
||||
|
||||
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 hooksDir = join(packageRoot, 'hooks');
|
||||
|
||||
// Verify it contains expected hook files
|
||||
const requiredHooks = ['pre-compact.js', 'session-start.js'];
|
||||
for (const hookFile of requiredHooks) {
|
||||
if (!existsSync(join(hooksDir, hookFile))) {
|
||||
throw new Error(`Package hooks directory missing required file: ${hookFile}`);
|
||||
const hookTemplatesDir = join(packageRoot, 'hook-templates');
|
||||
|
||||
// Verify it contains expected hook template files
|
||||
const requiredHookTemplates = [
|
||||
'session-start.js',
|
||||
'stop.js',
|
||||
'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,9 +335,19 @@ export class PathDiscovery {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return require('path').basename(process.cwd());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,68 +367,7 @@ export class PathDiscovery {
|
||||
* Check if a path exists and is accessible
|
||||
*/
|
||||
static isPathAccessible(path: string): boolean {
|
||||
try {
|
||||
return existsSync(path) && statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return existsSync(path) && statSync(path).isDirectory();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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;
|
||||
}
|
||||
@@ -17,12 +17,12 @@ export class MemoryStore {
|
||||
*/
|
||||
create(input: MemoryInput): MemoryRow {
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at);
|
||||
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO memories (
|
||||
session_id, text, document_id, keywords, created_at, created_at_epoch,
|
||||
project, archive_basename, origin
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
session_id, text, document_id, keywords, created_at, created_at_epoch,
|
||||
project, archive_basename, origin, title, subtitle, facts, concepts, files_touched
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const info = stmt.run(
|
||||
@@ -34,7 +34,12 @@ export class MemoryStore {
|
||||
epoch,
|
||||
input.project,
|
||||
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)!;
|
||||
@@ -158,15 +163,44 @@ export class MemoryStore {
|
||||
* Get memories by origin type
|
||||
*/
|
||||
getByOrigin(origin: string, limit?: number): MemoryRow[] {
|
||||
const query = limit
|
||||
const query = limit
|
||||
? 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
|
||||
: 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC';
|
||||
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const params = limit ? [origin, limit] : [origin];
|
||||
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
|
||||
*/
|
||||
@@ -195,11 +229,12 @@ export class MemoryStore {
|
||||
}
|
||||
|
||||
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
|
||||
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE memories SET
|
||||
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 = ?
|
||||
`);
|
||||
|
||||
@@ -212,6 +247,11 @@ export class MemoryStore {
|
||||
input.project || existing.project,
|
||||
input.archive_basename !== undefined ? input.archive_basename : existing.archive_basename,
|
||||
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
|
||||
);
|
||||
|
||||
|
||||
@@ -68,14 +68,26 @@ export class OverviewStore {
|
||||
*/
|
||||
getRecentForProject(project: string, limit = 5): OverviewRow[] {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM overviews
|
||||
SELECT * FROM overviews
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
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
|
||||
*/
|
||||
@@ -193,4 +205,37 @@ export class OverviewStore {
|
||||
const rows = stmt.all() as { project: string }[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
107
src/services/sqlite/TranscriptEventStore.ts
Normal file
107
src/services/sqlite/TranscriptEventStore.ts
Normal file
@@ -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 { DatabaseManager, getDatabase, initializeDatabase } from './Database.js';
|
||||
|
||||
@@ -9,24 +6,38 @@ export { SessionStore } from './SessionStore.js';
|
||||
export { MemoryStore } from './MemoryStore.js';
|
||||
export { OverviewStore } from './OverviewStore.js';
|
||||
export { DiagnosticsStore } from './DiagnosticsStore.js';
|
||||
export { TranscriptEventStore } from './TranscriptEventStore.js';
|
||||
|
||||
// Export types
|
||||
export * from './types.js';
|
||||
|
||||
// Export migrations
|
||||
export { migrations } from './migrations.js';
|
||||
|
||||
// Convenience function to get all stores
|
||||
export async function createStores() {
|
||||
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 { MemoryStore } = await import('./MemoryStore.js');
|
||||
const { OverviewStore } = await import('./OverviewStore.js');
|
||||
const { DiagnosticsStore } = await import('./DiagnosticsStore.js');
|
||||
|
||||
const { TranscriptEventStore } = await import('./TranscriptEventStore.js');
|
||||
|
||||
return {
|
||||
sessions: new SessionStore(db),
|
||||
memories: new MemoryStore(db),
|
||||
overviews: new OverviewStore(db),
|
||||
diagnostics: new DiagnosticsStore(db)
|
||||
diagnostics: new DiagnosticsStore(db),
|
||||
transcriptEvents: new TranscriptEventStore(db)
|
||||
};
|
||||
}
|
||||
|
||||
169
src/services/sqlite/migrations.ts
Normal file
169
src/services/sqlite/migrations.ts
Normal file
@@ -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;
|
||||
archive_basename?: 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 {
|
||||
@@ -50,6 +56,17 @@ export interface DiagnosticRow {
|
||||
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 {
|
||||
id: number;
|
||||
session_id: string;
|
||||
@@ -100,6 +117,12 @@ export interface MemoryInput {
|
||||
project: string;
|
||||
archive_basename?: 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 {
|
||||
@@ -111,6 +134,15 @@ export interface DiagnosticInput {
|
||||
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
|
||||
*/
|
||||
@@ -149,4 +181,4 @@ export function normalizeTimestamp(timestamp: string | Date | number | undefined
|
||||
isoString: date.toISOString(),
|
||||
epoch: date.getTime()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
42
src/shared/rolling-log.ts
Normal file
42
src/shared/rolling-log.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
87
src/shared/rolling-settings.ts
Normal file
87
src/shared/rolling-settings.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -1,103 +1,17 @@
|
||||
export interface HookPayload {
|
||||
session_id: string;
|
||||
transcript_path: string;
|
||||
hook_event_name: string;
|
||||
}
|
||||
/**
|
||||
* Core Type Definitions
|
||||
*
|
||||
* 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';
|
||||
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';
|
||||
}
|
||||
}
|
||||
// =============================================================================
|
||||
// ERROR CLASSES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Custom error class for compression failures
|
||||
*/
|
||||
export class CompressionError extends Error {
|
||||
constructor(
|
||||
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;
|
||||
saveMemoriesOnClear?: boolean;
|
||||
claudePath?: string;
|
||||
rollingCaptureEnabled?: boolean;
|
||||
rollingSummaryEnabled?: boolean;
|
||||
rollingSessionStartEnabled?: boolean;
|
||||
rollingChunkTokens?: number;
|
||||
rollingChunkOverlapTokens?: number;
|
||||
rollingSummaryTurnLimit?: number;
|
||||
[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>;
|
||||
}
|
||||
|
||||
819
src/ui/memory-stream/MemoryStream.jsx
Normal file
819
src/ui/memory-stream/MemoryStream.jsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
src/ui/memory-stream/README.md
Normal file
101
src/ui/memory-stream/README.md
Normal file
@@ -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
|
||||
BIN
src/ui/memory-stream/claude-mem-logo.webp
Normal file
BIN
src/ui/memory-stream/claude-mem-logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
22
src/ui/memory-stream/components.json
Normal file
22
src/ui/memory-stream/components.json
Normal file
@@ -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": {}
|
||||
}
|
||||
1
src/ui/memory-stream/dist/assets/index-5_3SV7cT.css
vendored
Normal file
1
src/ui/memory-stream/dist/assets/index-5_3SV7cT.css
vendored
Normal file
File diff suppressed because one or more lines are too long
88
src/ui/memory-stream/dist/assets/index-BjZoir4u.js
vendored
Normal file
88
src/ui/memory-stream/dist/assets/index-BjZoir4u.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
src/ui/memory-stream/dist/index.html
vendored
Normal file
13
src/ui/memory-stream/dist/index.html
vendored
Normal file
@@ -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>
|
||||
120
src/ui/memory-stream/index.css
Normal file
120
src/ui/memory-stream/index.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
12
src/ui/memory-stream/index.html
Normal file
12
src/ui/memory-stream/index.html
Normal file
@@ -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>
|
||||
1
src/ui/memory-stream/index.js
Normal file
1
src/ui/memory-stream/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './MemoryStream.jsx';
|
||||
8
src/ui/memory-stream/jsconfig.json
Normal file
8
src/ui/memory-stream/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
604
src/ui/memory-stream/layout.jsx
Normal file
604
src/ui/memory-stream/layout.jsx
Normal file
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
10
src/ui/memory-stream/main.jsx
Normal file
10
src/ui/memory-stream/main.jsx
Normal file
@@ -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>,
|
||||
)
|
||||
2707
src/ui/memory-stream/package-lock.json
generated
Normal file
2707
src/ui/memory-stream/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
src/ui/memory-stream/package.json
Normal file
34
src/ui/memory-stream/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
232
src/ui/memory-stream/server.js
Normal file
232
src/ui/memory-stream/server.js
Normal file
@@ -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);
|
||||
});
|
||||
570
src/ui/memory-stream/src/components/ASCIIText.jsx
Normal file
570
src/ui/memory-stream/src/components/ASCIIText.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
274
src/ui/memory-stream/src/components/Orb.jsx
Normal file
274
src/ui/memory-stream/src/components/Orb.jsx
Normal file
@@ -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" />;
|
||||
}
|
||||
987
src/ui/memory-stream/src/components/OverviewCard.jsx
Normal file
987
src/ui/memory-stream/src/components/OverviewCard.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
6
src/ui/memory-stream/src/lib/utils.js
Normal file
6
src/ui/memory-stream/src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
19
src/ui/memory-stream/vite.config.js
Normal file
19
src/ui/memory-stream/vite.config.js
Normal file
@@ -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