Release v3.9.9

Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
Alex Newman
2025-10-03 18:20:47 -04:00
parent 4d5b307a74
commit 85ed7c3d2f
85 changed files with 11156 additions and 7458 deletions
-2
View File
@@ -1,5 +1,3 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
+239 -77
View File
@@ -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.
## ⚡️ 10Second Setup
```bash
npm install -g claude-mem && claude-mem install
```
Thats it. Restart Claude Code and youre 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? Its 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 Its 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 Youll Ever Need
```bash
claude-mem install # Set up/repair integration
claude-mem status # Check everythings working
claude-mem load-context # Peek at what it remembers
claude-mem logs # If youre curious
claude-mem uninstall # Remove hooks
# Extras
claude-mem trash-view # See whats 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 Somethings 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.** 🧠✨
+420 -479
View File
File diff suppressed because one or more lines are too long
+144
View 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
View 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);
}
}
}
@@ -0,0 +1,278 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
// src/prompts/hook-prompt-renderer.ts
function substituteVariables(template, variables) {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
result = result.split(placeholder).join(value);
}
return result;
}
function truncate(text, maxLength) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + (text.length > maxLength ? "..." : "");
}
function formatTime(timestamp) {
const timePart = timestamp.split("T")[1];
if (!timePart)
return "";
return timePart.slice(0, 8);
}
function renderSystemPrompt(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
return substituteVariables(PROMPTS.system, {
project: variables.project,
sessionId: variables.sessionId,
date: variables.date,
userPrompt: userPromptTruncated
});
}
function renderToolMessage(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
const toolResponseTruncated = truncate(variables.toolResponse, HOOK_CONFIG.maxToolResponseLength);
const timeFormatted = formatTime(variables.timestamp);
return substituteVariables(PROMPTS.tool, {
toolName: variables.toolName,
toolResponse: toolResponseTruncated,
userPrompt: userPromptTruncated,
timestamp: variables.timestamp,
timeFormatted
});
}
function renderEndMessage(variables) {
return substituteVariables(PROMPTS.end, {
project: variables.project,
sessionId: variables.sessionId
});
}
function renderPrompt(type, variables) {
switch (type) {
case "system":
return renderSystemPrompt(variables);
case "tool":
return renderToolMessage(variables);
case "end":
return renderEndMessage(variables);
default:
throw new Error(`Unknown prompt type: ${type}`);
}
}
export {
renderToolMessage,
renderSystemPrompt,
renderPrompt,
renderEndMessage,
PROMPTS,
HOOK_CONFIG
};
@@ -0,0 +1,217 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
export {
TOOL_MESSAGE,
SYSTEM_PROMPT,
PROMPTS,
HOOK_CONFIG,
END_MESSAGE
};
+108
View 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
View 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
View 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);
});
-89
View File
@@ -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);
}
});
-61
View File
@@ -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 }));
});
-170
View File
@@ -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);
}
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
/**
* Path resolver utility for Claude Memory hooks
* Provides proper path handling using environment variables
*/
import { join } from 'path';
import { homedir } from 'os';
/**
* Gets the base data directory for claude-mem
* @returns {string} Data directory path
*/
export function getDataDir() {
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
}
/**
* Gets the settings file path
* @returns {string} Settings file path
*/
export function getSettingsPath() {
return join(getDataDir(), 'settings.json');
}
/**
* Gets the archives directory path
* @returns {string} Archives directory path
*/
export function getArchivesDir() {
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
}
/**
* Gets the logs directory path
* @returns {string} Logs directory path
*/
export function getLogsDir() {
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
}
/**
* Gets the compact flag file path
* @returns {string} Compact flag file path
*/
export function getCompactFlagPath() {
return join(getDataDir(), '.compact-running');
}
/**
* Gets all common paths used by hooks
* @returns {Object} Object containing all common paths
*/
export function getPaths() {
return {
dataDir: getDataDir(),
settingsPath: getSettingsPath(),
archivesDir: getArchivesDir(),
logsDir: getLogsDir(),
compactFlagPath: getCompactFlagPath()
};
}
+5 -8
View File
@@ -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",
+100 -89
View File
@@ -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 -1
View File
@@ -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
View 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;
}
-43
View File
@@ -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
View 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
View 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);
}
}
-146
View File
@@ -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);
}
}
-536
View File
@@ -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}`));
}
}
}
}
+226 -182
View File
@@ -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'));
}
}
+177 -49
View File
@@ -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];
-300
View File
@@ -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);
}
}
-90
View File
@@ -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);
}
}
+65 -28
View File
@@ -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
View 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
View 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);
}
}
+147 -82
View File
@@ -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
View 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);
}
}
+3 -184
View File
@@ -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;
-238
View File
@@ -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,
};
}
-128
View File
@@ -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
View 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
-191
View File
@@ -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
View 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
View 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;
-30
View File
@@ -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';
-190
View File
@@ -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}`;
}
-364
View File
@@ -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;
}
}
+47 -88
View File
@@ -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;
}
+49 -9
View File
@@ -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
);
+47 -2
View File
@@ -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
View 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[];
}
}
+17 -6
View File
@@ -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
View 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');
}
};
-15
View File
@@ -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();
+33 -1
View File
@@ -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()
};
}
}
-218
View File
@@ -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
};
}
}
-200
View File
@@ -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
View 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
View 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;
}
+19 -234
View File
@@ -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
View 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
View 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

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