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

View File

@@ -1,5 +1,3 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007

316
README.md
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.** 🧠✨

899
dist/claude-mem.min.js vendored

File diff suppressed because one or more lines are too long

144
hook-templates/post-tool-use.js Executable file
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
hook-templates/session-start.js Executable file
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);
}
});

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,278 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
// src/prompts/hook-prompt-renderer.ts
function substituteVariables(template, variables) {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
result = result.split(placeholder).join(value);
}
return result;
}
function truncate(text, maxLength) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + (text.length > maxLength ? "..." : "");
}
function formatTime(timestamp) {
const timePart = timestamp.split("T")[1];
if (!timePart)
return "";
return timePart.slice(0, 8);
}
function renderSystemPrompt(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
return substituteVariables(PROMPTS.system, {
project: variables.project,
sessionId: variables.sessionId,
date: variables.date,
userPrompt: userPromptTruncated
});
}
function renderToolMessage(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
const toolResponseTruncated = truncate(variables.toolResponse, HOOK_CONFIG.maxToolResponseLength);
const timeFormatted = formatTime(variables.timestamp);
return substituteVariables(PROMPTS.tool, {
toolName: variables.toolName,
toolResponse: toolResponseTruncated,
userPrompt: userPromptTruncated,
timestamp: variables.timestamp,
timeFormatted
});
}
function renderEndMessage(variables) {
return substituteVariables(PROMPTS.end, {
project: variables.project,
sessionId: variables.sessionId
});
}
function renderPrompt(type, variables) {
switch (type) {
case "system":
return renderSystemPrompt(variables);
case "tool":
return renderToolMessage(variables);
case "end":
return renderEndMessage(variables);
default:
throw new Error(`Unknown prompt type: ${type}`);
}
}
export {
renderToolMessage,
renderSystemPrompt,
renderPrompt,
renderEndMessage,
PROMPTS,
HOOK_CONFIG
};

View File

@@ -0,0 +1,217 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
export {
TOOL_MESSAGE,
SYSTEM_PROMPT,
PROMPTS,
HOOK_CONFIG,
END_MESSAGE
};

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
hook-templates/stop.js Executable file
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);
});

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);
});

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);
}
});

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 }));
});

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);
}

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()
};
}

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",

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> =======================================

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
src/commands/chroma-mcp.ts Normal file
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;
}

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
src/commands/doctor.ts Normal file
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)
});
}

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);
}
}

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);
}
}

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}`));
}
}
}
}

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'));
}
}

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];

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);
}
}

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);
}
}

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';
}

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);
}
}

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);
}
}

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.');
}
}

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);
}
}

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;

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

View File

@@ -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,
};
}

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
src/prompts/README.md Normal file
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

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 };

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 };

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;

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';

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.

View File

@@ -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;
}

View File

@@ -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 [];
}

View File

@@ -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!');

View File

@@ -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}`;
}

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;
}
}

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;
}

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
);

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;
}
}

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[];
}
}

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)
};
}

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
];

View File

@@ -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');
}
};

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();

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()
};
}
}

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
};
}
}

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
src/shared/rolling-log.ts Normal file
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
}
}

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;
}

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>;
}

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>
</>
);
}

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

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
src/ui/memory-stream/dist/index.html vendored Normal file
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>

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;
}
}

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>

View File

@@ -0,0 +1 @@
export { default } from './MemoryStream.jsx';

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

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>
</>
)
}

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>,
)

2707
src/ui/memory-stream/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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"
}
}

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);
});

View File

@@ -0,0 +1,570 @@
// Component ported and enhanced from https://codepen.io/JuanFuentes/pen/eYEeoyE
import { useRef, useEffect } from 'react';
import * as THREE from 'three';
const vertexShader = `
varying vec2 vUv;
uniform float uTime;
uniform float mouse;
uniform float uEnableWaves;
void main() {
vUv = uv;
float time = uTime * 5.;
float waveFactor = uEnableWaves;
vec3 transformed = position;
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
transformed.z += sin(time + position.x) * waveFactor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
uniform float mouse;
uniform float uTime;
uniform sampler2D uTexture;
void main() {
float time = uTime;
vec2 pos = vUv;
float move = sin(time + mouse) * 0.01;
float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r;
float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g;
float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b;
float a = texture2D(uTexture, pos).a;
gl_FragColor = vec4(r, g, b, a);
}
`;
function map(n, start, stop, start2, stop2) {
return ((n - start) / (stop - start)) * (stop2 - start2) + start2;
}
const PX_RATIO = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
class AsciiFilter {
width = 0;
height = 0;
center = { x: 0, y: 0 };
mouse = { x: 0, y: 0 };
cols = 0;
rows = 0;
constructor(renderer, {
fontSize,
fontFamily,
charset,
invert
} = {}) {
this.renderer = renderer;
this.domElement = document.createElement('div');
this.domElement.style.position = 'absolute';
this.domElement.style.top = '0';
this.domElement.style.left = '0';
this.domElement.style.width = '100%';
this.domElement.style.height = '100%';
this.pre = document.createElement('pre');
this.domElement.appendChild(this.pre);
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
this.domElement.appendChild(this.canvas);
this.deg = 0;
this.invert = invert ?? true;
this.fontSize = fontSize ?? 12;
this.fontFamily = fontFamily ?? "'Courier New', monospace";
this.charset = charset ?? ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
if (this.context) {
this.context.imageSmoothingEnabled = false;
this.context.imageSmoothingEnabled = false;
}
this.onMouseMove = this.onMouseMove.bind(this);
document.addEventListener('mousemove', this.onMouseMove);
}
setSize(width, height) {
this.width = width;
this.height = height;
this.renderer.setSize(width, height);
this.reset();
this.center = { x: width / 2, y: height / 2 };
this.mouse = { x: this.center.x, y: this.center.y };
}
reset() {
if (this.context) {
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
const charWidth = this.context.measureText('A').width;
this.cols = Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize)));
this.rows = Math.floor(this.height / this.fontSize);
this.canvas.width = this.cols;
this.canvas.height = this.rows;
this.pre.style.fontFamily = this.fontFamily;
this.pre.style.fontSize = `${this.fontSize}px`;
this.pre.style.margin = '0';
this.pre.style.padding = '0';
this.pre.style.lineHeight = '1em';
this.pre.style.position = 'absolute';
this.pre.style.left = '50%';
this.pre.style.top = '50%';
this.pre.style.transform = 'translate(-50%, -50%)';
this.pre.style.zIndex = '9';
this.pre.style.backgroundAttachment = 'fixed';
this.pre.style.mixBlendMode = 'difference';
}
}
render(scene, camera) {
this.renderer.render(scene, camera);
const w = this.canvas.width;
const h = this.canvas.height;
if (this.context) {
this.context.clearRect(0, 0, w, h);
if (this.context && w && h) {
this.context.drawImage(this.renderer.domElement, 0, 0, w, h);
}
this.asciify(this.context, w, h);
this.hue();
}
}
onMouseMove(e) {
this.mouse = { x: e.clientX * PX_RATIO, y: e.clientY * PX_RATIO };
}
get dx() {
return this.mouse.x - this.center.x;
}
get dy() {
return this.mouse.y - this.center.y;
}
hue() {
const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI;
this.deg += (deg - this.deg) * 0.075;
this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)`;
}
asciify(ctx, w, h) {
if (w && h) {
const imgData = ctx.getImageData(0, 0, w, h).data;
let str = '';
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = x * 4 + y * 4 * w;
const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];
if (a === 0) {
str += ' ';
continue;
}
let gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
let idx = Math.floor((1 - gray) * (this.charset.length - 1));
if (this.invert) idx = this.charset.length - idx - 1;
str += this.charset[idx];
}
str += '\n';
}
this.pre.innerHTML = str;
}
}
dispose() {
document.removeEventListener('mousemove', this.onMouseMove);
}
}
class CanvasTxt {
constructor(txt, {
fontSize = 200,
fontFamily = 'Arial',
color = '#fdf9f3'
} = {}) {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
this.txt = txt;
this.fontSize = fontSize;
this.fontFamily = fontFamily;
this.color = color;
this.font = `600 ${this.fontSize}px ${this.fontFamily}`;
}
resize() {
if (this.context) {
this.context.font = this.font;
// Split text into lines
const lines = this.txt.split('\n');
// Measure all lines to find max width
let maxWidth = 0;
for (const line of lines) {
const metrics = this.context.measureText(line);
maxWidth = Math.max(maxWidth, metrics.width);
}
// Calculate total height (first line metrics for line height)
const firstMetrics = this.context.measureText(lines[0] || 'A');
const lineHeight = Math.ceil(firstMetrics.actualBoundingBoxAscent + firstMetrics.actualBoundingBoxDescent);
const textWidth = Math.ceil(maxWidth) + 20;
const textHeight = lineHeight * lines.length + 20;
this.canvas.width = textWidth;
this.canvas.height = textHeight;
}
}
render() {
if (this.context) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.fillStyle = this.color;
this.context.font = this.font;
// Split text into lines and render each
const lines = this.txt.split('\n');
const metrics = this.context.measureText(lines[0] || 'A');
const lineHeight = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent);
lines.forEach((line, index) => {
const yPos = 10 + metrics.actualBoundingBoxAscent + (index * lineHeight);
this.context.fillText(line, 10, yPos);
});
}
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
get texture() {
return this.canvas;
}
}
class CanvAscii {
animationFrameId = 0;
constructor(
{
text,
asciiFontSize,
textFontSize,
textColor,
planeBaseHeight,
enableWaves,
enableMouseRotation
},
containerElem,
width,
height
) {
this.textString = text;
this.asciiFontSize = asciiFontSize;
this.textFontSize = textFontSize;
this.textColor = textColor;
this.planeBaseHeight = planeBaseHeight;
this.container = containerElem;
this.width = width;
this.height = height;
this.enableWaves = enableWaves;
this.enableMouseRotation = enableMouseRotation;
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);
this.camera.position.z = 30;
this.scene = new THREE.Scene();
this.mouse = { x: 0, y: 0 };
this.onMouseMove = this.onMouseMove.bind(this);
this.setMesh();
this.setRenderer();
}
setMesh() {
this.textCanvas = new CanvasTxt(this.textString, {
fontSize: this.textFontSize,
fontFamily: 'IBM Plex Mono',
color: this.textColor
});
this.textCanvas.resize();
this.textCanvas.render();
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
this.texture.minFilter = THREE.NearestFilter;
const textAspect = this.textCanvas.width / this.textCanvas.height;
const baseH = this.planeBaseHeight;
const planeW = baseH * textAspect;
const planeH = baseH;
this.geometry = new THREE.PlaneGeometry(planeW, planeH, 36, 36);
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
uniforms: {
uTime: { value: 0 },
mouse: { value: 1.0 },
uTexture: { value: this.texture },
uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 }
}
});
this.mesh = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.mesh);
}
setRenderer() {
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
this.renderer.setPixelRatio(1);
this.renderer.setClearColor(0x000000, 0);
this.filter = new AsciiFilter(this.renderer, {
fontFamily: 'IBM Plex Mono',
fontSize: this.asciiFontSize,
invert: true
});
this.container.appendChild(this.filter.domElement);
this.setSize(this.width, this.height);
this.container.addEventListener('mousemove', this.onMouseMove);
this.container.addEventListener('touchmove', this.onMouseMove);
}
setSize(w, h) {
this.width = w;
this.height = h;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.filter.setSize(w, h);
this.center = { x: w / 2, y: h / 2 };
}
load() {
this.animate();
}
onMouseMove(evt) {
const e = (evt).touches ? (evt).touches[0] : (evt);
const bounds = this.container.getBoundingClientRect();
const x = e.clientX - bounds.left;
const y = e.clientY - bounds.top;
this.mouse = { x, y };
}
animate() {
const animateFrame = () => {
this.animationFrameId = requestAnimationFrame(animateFrame);
this.render();
};
animateFrame();
}
render() {
const time = new Date().getTime() * 0.001;
this.textCanvas.render();
this.texture.needsUpdate = true;
(this.mesh.material).uniforms.uTime.value = Math.sin(time);
this.updateRotation();
this.filter.render(this.scene, this.camera);
}
updateRotation() {
if (!this.enableMouseRotation) return;
const x = map(this.mouse.y, 0, this.height, 0.5, -0.5);
const y = map(this.mouse.x, 0, this.width, -0.5, 0.5);
this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05;
this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05;
}
clear() {
this.scene.traverse(object => {
const obj = object;
if (!obj.isMesh) return;
[obj.material].flat().forEach(material => {
material.dispose();
Object.keys(material).forEach(key => {
const matProp = material[key];
if (matProp && typeof matProp === 'object' && 'dispose' in matProp && typeof matProp.dispose === 'function') {
matProp.dispose();
}
});
});
obj.geometry.dispose();
});
this.scene.clear();
}
dispose() {
cancelAnimationFrame(this.animationFrameId);
this.filter.dispose();
this.container.removeChild(this.filter.domElement);
this.container.removeEventListener('mousemove', this.onMouseMove);
this.container.removeEventListener('touchmove', this.onMouseMove);
this.clear();
this.renderer.dispose();
}
}
export default function ASCIIText({
text = 'David!',
asciiFontSize = 8,
textFontSize = 200,
textColor = '#fdf9f3',
planeBaseHeight = 8,
enableWaves = true,
enableMouseRotation = true
}) {
const containerRef = useRef(null);
const asciiRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
const { width, height } = containerRef.current.getBoundingClientRect();
if (width === 0 || height === 0) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && entry.boundingClientRect.width > 0 && entry.boundingClientRect.height > 0) {
const { width: w, height: h } = entry.boundingClientRect;
asciiRef.current = new CanvAscii({
text,
asciiFontSize,
textFontSize,
textColor,
planeBaseHeight,
enableWaves,
enableMouseRotation
}, containerRef.current, w, h);
asciiRef.current.load();
observer.disconnect();
}
}, { threshold: 0.1 });
observer.observe(containerRef.current);
return () => {
observer.disconnect();
if (asciiRef.current) {
asciiRef.current.dispose();
}
};
}
asciiRef.current = new CanvAscii({
text,
asciiFontSize,
textFontSize,
textColor,
planeBaseHeight,
enableWaves,
enableMouseRotation
}, containerRef.current, width, height);
asciiRef.current.load();
const ro = new ResizeObserver(entries => {
if (!entries[0] || !asciiRef.current) return;
const { width: w, height: h } = entries[0].contentRect;
if (w > 0 && h > 0) {
asciiRef.current.setSize(w, h);
}
});
ro.observe(containerRef.current);
return () => {
ro.disconnect();
if (asciiRef.current) {
asciiRef.current.dispose();
}
};
}, [text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, enableMouseRotation]);
return (
<div
ref={containerRef}
className="ascii-text-container"
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&display=swap');
body {
margin: 0;
padding: 0;
}
.ascii-text-container canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
image-rendering: optimizeSpeed;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
.ascii-text-container pre {
margin: 0;
user-select: none;
padding: 0;
line-height: 1em;
text-align: left;
position: absolute;
left: 0;
top: 0;
background-image: radial-gradient(circle, #ff6188 0%, #fc9867 50%, #ffd866 100%);
background-attachment: fixed;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
z-index: 9;
mix-blend-mode: difference;
}
`}</style>
</div>
);
}

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" />;
}

View File

@@ -0,0 +1,987 @@
import { useState, useEffect } from 'react';
import Orb from './Orb';
import ASCIIText from './ASCIIText';
const DUMMY_DATA = {
title: 'Session Memory Processing',
subtitle: 'Compressing conversation context into semantic memories',
memories: [
{
id: 1,
title: 'First Memory',
subtitle: 'Initial context capture',
facts: ['Fact 1', 'Fact 2', 'Fact 3'],
concepts: ['concept1', 'concept2']
},
{
id: 2,
title: 'Second Memory',
subtitle: 'Additional context',
facts: ['Fact A', 'Fact B'],
concepts: ['concept3']
},
{
id: 3,
title: 'Third Memory',
subtitle: 'More context',
facts: ['Fact X', 'Fact Y', 'Fact Z'],
concepts: ['concept4', 'concept5', 'concept6']
}
],
overview: 'This session involved implementing a progressive UI visualization system for memory processing. The user requested a session card component with four distinct states showing the evolution from empty state through memory accumulation to final overview completion.'
};
export default function OverviewCard({
debugMode = true,
initialState = 'empty',
sessionData = null // { overview, memories }
}) {
const [uiState, setUiState] = useState(initialState);
const [orbOpacity, setOrbOpacity] = useState(0);
const [titleOpacity, setTitleOpacity] = useState(0);
const [asciiFontSize, setAsciiFontSize] = useState(64);
const [cardOpacity, setCardOpacity] = useState(0);
const [titlePosition, setTitlePosition] = useState('center'); // 'center' or 'top'
const [visibleMemories, setVisibleMemories] = useState(0);
const [overviewOpacity, setOverviewOpacity] = useState(0);
const [expandedMemoryId, setExpandedMemoryId] = useState(null); // null = show overview, number = show expanded memory
const [selectedSessionId, setSelectedSessionId] = useState(null);
const [sessions, setSessions] = useState([]);
const [loadedSessionData, setLoadedSessionData] = useState(null);
// Use provided sessionData or loaded session data or fallback to dummy data
const data = sessionData || loadedSessionData || DUMMY_DATA;
// Orb parameters
const [orbHue, setOrbHue] = useState(0);
const [orbHoverIntensity, setOrbHoverIntensity] = useState(0.05);
const [orbRotateOnHover, setOrbRotateOnHover] = useState(false);
const [orbForceHoverState, setOrbForceHoverState] = useState(false);
// Load settings from localStorage or use defaults
const loadSetting = (key, defaultValue) => {
const saved = localStorage.getItem(`overviewCard_${key}`);
return saved !== null ? JSON.parse(saved) : defaultValue;
};
// ASCIIText parameters - Title
const [asciiText, setAsciiText] = useState(() => loadSetting('asciiText', DUMMY_DATA.title));
const [asciiTitleFontSize, setAsciiTitleFontSize] = useState(() => loadSetting('asciiTitleFontSize', 12));
const [asciiTitleTextFontSize, setAsciiTitleTextFontSize] = useState(() => loadSetting('asciiTitleTextFontSize', 200));
const [asciiTitleColor, setAsciiTitleColor] = useState(() => loadSetting('asciiTitleColor', '#60a5fa'));
const [asciiTitlePlaneHeight, setAsciiTitlePlaneHeight] = useState(() => loadSetting('asciiTitlePlaneHeight', 8));
const [asciiTitleEnableWaves, setAsciiTitleEnableWaves] = useState(() => loadSetting('asciiTitleEnableWaves', false));
const [asciiTitleEnableMouseRotation, setAsciiTitleEnableMouseRotation] = useState(() => loadSetting('asciiTitleEnableMouseRotation', false));
const [asciiTitleOffsetY, setAsciiTitleOffsetY] = useState(() => loadSetting('asciiTitleOffsetY', 0));
// ASCIIText parameters - Subtitle
const [asciiSubtitle, setAsciiSubtitle] = useState(() => loadSetting('asciiSubtitle', DUMMY_DATA.subtitle));
const [asciiSubtitleFontSize, setAsciiSubtitleFontSize] = useState(() => loadSetting('asciiSubtitleFontSize', 6));
const [asciiSubtitleTextFontSize, setAsciiSubtitleTextFontSize] = useState(() => loadSetting('asciiSubtitleTextFontSize', 120));
const [asciiSubtitleColor, setAsciiSubtitleColor] = useState(() => loadSetting('asciiSubtitleColor', '#60a5fa'));
const [asciiSubtitlePlaneHeight, setAsciiSubtitlePlaneHeight] = useState(() => loadSetting('asciiSubtitlePlaneHeight', 4.8));
const [asciiSubtitleEnableWaves, setAsciiSubtitleEnableWaves] = useState(() => loadSetting('asciiSubtitleEnableWaves', false));
const [asciiSubtitleEnableMouseRotation, setAsciiSubtitleEnableMouseRotation] = useState(() => loadSetting('asciiSubtitleEnableMouseRotation', false));
const [asciiSubtitleOffsetY, setAsciiSubtitleOffsetY] = useState(() => loadSetting('asciiSubtitleOffsetY', 0));
// Debug panel section expansion state
const [sectionsExpanded, setSectionsExpanded] = useState({
animation: true,
orb: false,
asciiTitle: false,
asciiSubtitle: false
});
// Save to localStorage whenever settings change
useEffect(() => {
localStorage.setItem('overviewCard_asciiText', JSON.stringify(asciiText));
}, [asciiText]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleFontSize', JSON.stringify(asciiTitleFontSize));
}, [asciiTitleFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleTextFontSize', JSON.stringify(asciiTitleTextFontSize));
}, [asciiTitleTextFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleColor', JSON.stringify(asciiTitleColor));
}, [asciiTitleColor]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitlePlaneHeight', JSON.stringify(asciiTitlePlaneHeight));
}, [asciiTitlePlaneHeight]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleEnableWaves', JSON.stringify(asciiTitleEnableWaves));
}, [asciiTitleEnableWaves]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleEnableMouseRotation', JSON.stringify(asciiTitleEnableMouseRotation));
}, [asciiTitleEnableMouseRotation]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiTitleOffsetY', JSON.stringify(asciiTitleOffsetY));
}, [asciiTitleOffsetY]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitle', JSON.stringify(asciiSubtitle));
}, [asciiSubtitle]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleFontSize', JSON.stringify(asciiSubtitleFontSize));
}, [asciiSubtitleFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleTextFontSize', JSON.stringify(asciiSubtitleTextFontSize));
}, [asciiSubtitleTextFontSize]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleColor', JSON.stringify(asciiSubtitleColor));
}, [asciiSubtitleColor]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitlePlaneHeight', JSON.stringify(asciiSubtitlePlaneHeight));
}, [asciiSubtitlePlaneHeight]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleEnableWaves', JSON.stringify(asciiSubtitleEnableWaves));
}, [asciiSubtitleEnableWaves]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleEnableMouseRotation', JSON.stringify(asciiSubtitleEnableMouseRotation));
}, [asciiSubtitleEnableMouseRotation]);
useEffect(() => {
localStorage.setItem('overviewCard_asciiSubtitleOffsetY', JSON.stringify(asciiSubtitleOffsetY));
}, [asciiSubtitleOffsetY]);
// Fetch available sessions
useEffect(() => {
if (debugMode) {
fetch('http://localhost:3001/api/sessions')
.then(res => res.json())
.then(data => setSessions(data))
.catch(err => console.error('Failed to fetch sessions:', err));
}
}, [debugMode]);
// Load session data when selected
useEffect(() => {
if (selectedSessionId && debugMode) {
fetch(`http://localhost:3001/api/session/${selectedSessionId}`)
.then(res => res.json())
.then(data => {
// Transform data to match expected format
const formattedData = {
title: data.overview?.content?.split('.')[0] || 'Session Overview',
subtitle: data.overview?.content?.substring(0, 100) || '',
overview: data.overview?.content || '',
memories: data.memories || []
};
setLoadedSessionData(formattedData);
// Auto-transition to complete state to show the data
if (data.memories?.length > 0) {
setUiState('complete');
setVisibleMemories(data.memories.length);
}
})
.catch(err => console.error('Failed to fetch session data:', err));
}
}, [selectedSessionId, debugMode]);
// State transition effects
useEffect(() => {
switch (uiState) {
case 'empty':
// Reset everything
setOrbOpacity(0);
setTitleOpacity(0);
setAsciiFontSize(64);
setCardOpacity(0);
setTitlePosition('center');
setVisibleMemories(0);
setOverviewOpacity(0);
setAsciiText(DUMMY_DATA.title);
setAsciiSubtitle(DUMMY_DATA.subtitle);
// Fade in orb and title
setTimeout(() => setOrbOpacity(1), 100);
setTimeout(() => {
setTitleOpacity(1);
// Start animating font size down
let size = 64;
const interval = setInterval(() => {
size -= 2;
if (size <= 12) {
size = 12;
clearInterval(interval);
}
setAsciiFontSize(size);
}, 30);
}, 200);
break;
case 'first-memory':
// Card fades in, title moves to top
setCardOpacity(1);
setTitlePosition('top');
setVisibleMemories(1);
break;
case 'accumulating':
// Show all memories
setVisibleMemories(data.memories?.length || DUMMY_DATA.memories.length);
break;
case 'complete':
// Overview fades in, orb fades out, card becomes solid
setOverviewOpacity(1);
setOrbOpacity(0);
// Make card fully opaque by increasing opacity even more
setCardOpacity(1);
break;
default:
break;
}
}, [uiState]);
return (
<div className="relative w-full min-h-screen">
{/* Debug Controls */}
{debugMode && (
<div className="fixed bottom-4 right-4 z-50 bg-gray-900/95 backdrop-blur-xl border border-gray-700 rounded-xl w-96 max-h-[85vh] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-sm font-bold text-blue-400 mb-3">Debug Controls</h3>
{/* Session Selector */}
<div className="mb-3">
<label className="text-xs text-gray-400 mb-1 block">Load Real Session</label>
<select
value={selectedSessionId || ''}
onChange={(e) => setSelectedSessionId(e.target.value || null)}
className="w-full px-2 py-1.5 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
>
<option value="">-- Dummy Data --</option>
{sessions.map((session) => (
<option key={session.session_id} value={session.session_id}>
{session.project} - {new Date(session.created_at).toLocaleDateString()}
</option>
))}
</select>
</div>
{/* State Buttons - 2x2 Grid */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setUiState('empty')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'empty'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
1. Empty
</button>
<button
onClick={() => setUiState('first-memory')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'first-memory'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
2. First
</button>
<button
onClick={() => setUiState('accumulating')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'accumulating'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
3. Accum
</button>
<button
onClick={() => setUiState('complete')}
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
uiState === 'complete'
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
}`}
>
4. Complete
</button>
</div>
</div>
{/* Scrollable Content */}
<div className="overflow-y-auto flex-1 p-4 space-y-2">
{/* Animation State Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, animation: !s.animation }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-purple-400">Animation State</span>
<span className="text-xs text-gray-500">{sectionsExpanded.animation ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.animation && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Orb Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={orbOpacity}
onChange={(e) => setOrbOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{orbOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Title Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={titleOpacity}
onChange={(e) => setTitleOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{titleOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Card Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={cardOpacity}
onChange={(e) => setCardOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{cardOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Overview Opacity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={overviewOpacity}
onChange={(e) => setOverviewOpacity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{overviewOpacity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Title Position</label>
<select
value={titlePosition}
onChange={(e) => setTitlePosition(e.target.value)}
className="px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
>
<option value="center">Center</option>
<option value="top">Top</option>
</select>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Visible Memories</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max={data.memories?.length || 0}
step="1"
value={visibleMemories}
onChange={(e) => setVisibleMemories(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{visibleMemories}/{data.memories?.length || 0}</span>
</div>
</div>
</div>
)}
</div>
{/* Orb Parameters Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, orb: !s.orb }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-blue-400">Orb Parameters</span>
<span className="text-xs text-gray-500">{sectionsExpanded.orb ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.orb && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Hue</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-180"
max="180"
step="1"
value={orbHue}
onChange={(e) => setOrbHue(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{orbHue}°</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Hover Intensity</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="1"
step="0.01"
value={orbHoverIntensity}
onChange={(e) => setOrbHoverIntensity(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{orbHoverIntensity.toFixed(2)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={orbRotateOnHover}
onChange={(e) => setOrbRotateOnHover(e.target.checked)}
className="w-4 h-4"
/>
Rotate On Hover
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={orbForceHoverState}
onChange={(e) => setOrbForceHoverState(e.target.checked)}
className="w-4 h-4"
/>
Force Hover State
</label>
</div>
</div>
)}
</div>
{/* ASCII Title Parameters Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, asciiTitle: !s.asciiTitle }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-emerald-400">ASCII Title</span>
<span className="text-xs text-gray-500">{sectionsExpanded.asciiTitle ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.asciiTitle && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div>
<label className="text-xs text-gray-400 mb-1 block">Text</label>
<textarea
value={asciiText}
onChange={(e) => setAsciiText(e.target.value)}
rows={2}
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">ASCII Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="4"
max="64"
step="1"
value={asciiTitleFontSize}
onChange={(e) => setAsciiTitleFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleFontSize}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Text Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="50"
max="400"
step="10"
value={asciiTitleTextFontSize}
onChange={(e) => setAsciiTitleTextFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleTextFontSize}px</span>
</div>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={asciiTitleColor}
onChange={(e) => setAsciiTitleColor(e.target.value)}
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
/>
<input
type="text"
value={asciiTitleColor}
onChange={(e) => setAsciiTitleColor(e.target.value)}
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
/>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Plane Height</label>
<div className="flex items-center gap-2">
<input
type="range"
min="1"
max="20"
step="0.5"
value={asciiTitlePlaneHeight}
onChange={(e) => setAsciiTitlePlaneHeight(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitlePlaneHeight}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Y Offset</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-500"
max="500"
step="10"
value={asciiTitleOffsetY}
onChange={(e) => setAsciiTitleOffsetY(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleOffsetY}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiTitleEnableWaves}
onChange={(e) => setAsciiTitleEnableWaves(e.target.checked)}
className="w-4 h-4"
/>
Enable Waves
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiTitleEnableMouseRotation}
onChange={(e) => setAsciiTitleEnableMouseRotation(e.target.checked)}
className="w-4 h-4"
/>
Mouse Rotation
</label>
</div>
</div>
)}
</div>
{/* ASCII Subtitle Parameters Section */}
<div className="border border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setSectionsExpanded(s => ({ ...s, asciiSubtitle: !s.asciiSubtitle }))}
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
>
<span className="text-xs font-bold text-amber-400">ASCII Subtitle</span>
<span className="text-xs text-gray-500">{sectionsExpanded.asciiSubtitle ? '▼' : '▶'}</span>
</button>
{sectionsExpanded.asciiSubtitle && (
<div className="p-3 space-y-2 bg-gray-800/10">
<div>
<label className="text-xs text-gray-400 mb-1 block">Text</label>
<textarea
value={asciiSubtitle}
onChange={(e) => setAsciiSubtitle(e.target.value)}
rows={2}
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">ASCII Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="4"
max="64"
step="1"
value={asciiSubtitleFontSize}
onChange={(e) => setAsciiSubtitleFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleFontSize}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Text Font Size</label>
<div className="flex items-center gap-2">
<input
type="range"
min="50"
max="400"
step="10"
value={asciiSubtitleTextFontSize}
onChange={(e) => setAsciiSubtitleTextFontSize(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleTextFontSize}px</span>
</div>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={asciiSubtitleColor}
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
/>
<input
type="text"
value={asciiSubtitleColor}
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
/>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Plane Height</label>
<div className="flex items-center gap-2">
<input
type="range"
min="1"
max="20"
step="0.5"
value={asciiSubtitlePlaneHeight}
onChange={(e) => setAsciiSubtitlePlaneHeight(parseFloat(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitlePlaneHeight}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Y Offset</label>
<div className="flex items-center gap-2">
<input
type="range"
min="-500"
max="500"
step="10"
value={asciiSubtitleOffsetY}
onChange={(e) => setAsciiSubtitleOffsetY(parseInt(e.target.value))}
className="w-32"
/>
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleOffsetY}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiSubtitleEnableWaves}
onChange={(e) => setAsciiSubtitleEnableWaves(e.target.checked)}
className="w-4 h-4"
/>
Enable Waves
</label>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400 flex items-center gap-2">
<input
type="checkbox"
checked={asciiSubtitleEnableMouseRotation}
onChange={(e) => setAsciiSubtitleEnableMouseRotation(e.target.checked)}
className="w-4 h-4"
/>
Mouse Rotation
</label>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Orb Background Overlay */}
<div
className="fixed inset-0 pointer-events-none transition-opacity duration-500"
style={{ opacity: orbOpacity }}
>
<Orb
hue={orbHue}
hoverIntensity={orbHoverIntensity}
rotateOnHover={orbRotateOnHover}
forceHoverState={orbForceHoverState}
/>
</div>
{/* Floating Title (State 1: Empty) */}
{titlePosition === 'center' && (
<div
className="fixed inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-500"
style={{ opacity: titleOpacity }}
>
<div className="relative w-full flex flex-col items-center">
<div
className="relative w-full h-64"
style={{ transform: `translateY(${asciiTitleOffsetY}px)` }}
>
<ASCIIText
text={asciiText}
asciiFontSize={asciiTitleFontSize}
textFontSize={asciiTitleTextFontSize}
textColor={asciiTitleColor}
planeBaseHeight={asciiTitlePlaneHeight}
enableWaves={asciiTitleEnableWaves}
enableMouseRotation={asciiTitleEnableMouseRotation}
/>
</div>
<div
className="relative w-full h-32"
style={{ transform: `translateY(${asciiSubtitleOffsetY}px)` }}
>
<ASCIIText
text={asciiSubtitle}
asciiFontSize={asciiSubtitleFontSize}
textFontSize={asciiSubtitleTextFontSize}
textColor={asciiSubtitleColor}
planeBaseHeight={asciiSubtitlePlaneHeight}
enableWaves={asciiSubtitleEnableWaves}
enableMouseRotation={asciiSubtitleEnableMouseRotation}
/>
</div>
</div>
</div>
)}
{/* Session Card (States 2-4) */}
<div
className="max-w-6xl mx-auto px-4 py-20 transition-opacity duration-500"
style={{ opacity: cardOpacity }}
>
<div className="relative">
{/* Blur background effect */}
<div className="absolute -inset-4 bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-emerald-600/20 rounded-3xl blur-2xl" />
{/* Card with backdrop blur */}
<div
className="relative rounded-3xl p-12 border border-gray-800 transition-all duration-500"
style={{
backgroundColor: uiState === 'complete'
? 'rgba(10, 10, 15, 0.95)'
: 'rgba(10, 10, 15, 0.7)',
backdropFilter: 'blur(20px)'
}}
>
{/* Title at top of card (States 2-4) */}
{titlePosition === 'top' && (
<div className="mb-8">
<h1 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-300 via-purple-300 to-emerald-300 mb-4 leading-tight">
{data.title || 'Session Overview'}
</h1>
<p className="text-xl text-gray-400 leading-relaxed">
{data.subtitle || ''}
</p>
</div>
)}
{/* Overview Section (State 4: Complete) */}
{uiState === 'complete' && data.overview && (
<div
className="mb-8 pb-8 border-b border-gray-800 transition-opacity duration-500"
style={{ opacity: overviewOpacity }}
>
<h3 className="text-sm font-bold text-emerald-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
SESSION OVERVIEW
</h3>
<p className="text-gray-300 leading-relaxed">
{data.overview}
</p>
</div>
)}
{/* Expanded Memory View */}
{expandedMemoryId !== null && (
<div>
{/* Back Button */}
<button
onClick={() => setExpandedMemoryId(null)}
className="flex items-center gap-2 mb-6 px-4 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50 hover:border-gray-600 transition-all"
>
<span className="text-lg"></span>
<span className="text-sm font-medium">Back to Overview</span>
</button>
{/* Full Memory Card */}
{(() => {
const memory = data.memories?.find(m => m.id === expandedMemoryId);
if (!memory) return null;
return (
<div>
<div className="mb-8">
<h2 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-4">
{memory.title}
</h2>
<p className="text-xl text-gray-400">
{memory.subtitle}
</p>
</div>
{memory.facts && memory.facts.length > 0 && (
<div className="mb-8">
<h3 className="text-sm font-bold text-blue-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
FACTS EXTRACTED
</h3>
<div className="space-y-3">
{memory.facts.map((fact, i) => (
<div key={i} className="flex gap-3 text-gray-300 leading-relaxed">
<span className="text-blue-400 font-mono text-xs mt-1"></span>
<span>{fact}</span>
</div>
))}
</div>
</div>
)}
{memory.concepts && memory.concepts.length > 0 && (
<div>
<h3 className="text-sm font-bold text-purple-400 mb-4 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
CONCEPTS
</h3>
<div className="flex flex-wrap gap-2">
{memory.concepts.map((concept, i) => (
<span
key={i}
className="px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-400/30 text-purple-300 text-sm font-medium"
>
{concept}
</span>
))}
</div>
</div>
)}
</div>
);
})()}
</div>
)}
{/* Memory Mini-cards (Overview) */}
{expandedMemoryId === null && (
<div className="grid grid-cols-3 gap-4">
{(data.memories || []).slice(0, visibleMemories).map((memory, index) => (
<div
key={memory.id}
onClick={() => setExpandedMemoryId(memory.id)}
className="border border-gray-700/50 rounded-xl p-4 bg-gray-900/30 cursor-pointer hover:bg-gray-800/40 hover:border-gray-600/50 transition-all"
style={{
animation: 'fadeInUp 0.5s ease-out',
animationDelay: `${index * 0.1}s`,
animationFillMode: 'both'
}}
>
<h3 className="text-base font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-2">
{memory.title}
</h3>
<p className="text-xs text-gray-400 line-clamp-2 mb-3">
{memory.subtitle}
</p>
{/* Preview badges */}
<div className="flex gap-2">
{memory.facts && memory.facts.length > 0 && (
<span className="px-2 py-0.5 rounded text-xs bg-blue-500/10 border border-blue-400/30 text-blue-300">
{memory.facts.length} facts
</span>
)}
{memory.concepts && memory.concepts.length > 0 && (
<span className="px-2 py-0.5 rounded text-xs bg-purple-500/10 border border-purple-400/30 text-purple-300">
{memory.concepts.length} concepts
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
<style>{`
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

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
}
})