Compare commits

..

8 Commits

Author SHA1 Message Date
Alex Newman 5f15695c3f Release v3.9.16
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-06 22:55:16 -04:00
Alex Newman c49533c250 Release v3.9.14
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 21:47:35 -04:00
Alex Newman 4f49cb1bc9 Release v3.9.13
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 21:45:36 -04:00
Alex Newman 874726b193 Release v3.9.12
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 21:09:11 -04:00
Alex Newman 5244a12422 Release v3.9.11
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 20:55:36 -04:00
Alex Newman 5b30764fa8 Release v3.9.10
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 18:27:36 -04:00
Alex Newman 85ed7c3d2f Release v3.9.9
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-10-03 18:20:47 -04:00
Alex Newman 4d5b307a74 Release v3.7.2
Published from npm package build
Source: https://github.com/thedotmack/claude-mem-source
2025-09-22 14:14:51 -04:00
67 changed files with 5670 additions and 8005 deletions
-2
View File
@@ -1,5 +1,3 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
+440 -56
View File
@@ -1,86 +1,470 @@
# 🧠 Claude Memory System (claude-mem)
<div align="center">
## Remember that one thing? Neither do we… but `claude-mem` does! 😵‍💫
<img src="claude-mem-logo-lm.webp#gh-light-mode-only" alt="claude-mem logo" width="360" height="auto" />
<img src="claude-mem-logo-dm.webp#gh-dark-mode-only" alt="claude-mem logo" width="360" height="auto" />
Stop repeating yourself. `claude-mem` remembers what you and Claude Code figure out, so every new chat starts smarter than the last.
<p>
Memory compression and persistence system for Claude Code conversations
</p>
## ⚡️ 10Second Setup
```bash
npm install -g claude-mem && claude-mem install
```
<!-- Badges -->
<p>
<a href="https://www.npmjs.com/package/claude-mem">
<img src="https://img.shields.io/npm/v/claude-mem.svg" alt="npm version" />
</a>
<a href="https://github.com/thedotmack/claude-mem/graphs/contributors">
<img src="https://img.shields.io/github/contributors/thedotmack/claude-mem" alt="contributors" />
</a>
<a href="">
<img src="https://img.shields.io/github/last-commit/thedotmack/claude-mem" alt="last update" />
</a>
<a href="https://github.com/thedotmack/claude-mem/network/members">
<img src="https://img.shields.io/github/forks/thedotmack/claude-mem" alt="forks" />
</a>
<a href="https://github.com/thedotmack/claude-mem/stargazers">
<img src="https://img.shields.io/github/stars/thedotmack/claude-mem" alt="stars" />
</a>
<a href="https://github.com/thedotmack/claude-mem/issues/">
<img src="https://img.shields.io/github/issues/thedotmack/claude-mem" alt="open issues" />
</a>
<a href="https://github.com/thedotmack/claude-mem/blob/main/LICENSE">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="license" />
</a>
<a href="https://nodejs.org/">
<img src="https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg" alt="node version" />
</a>
<a href="https://modelcontextprotocol.io">
<img src="https://img.shields.io/badge/MCP-compatible-purple.svg" alt="MCP compatible" />
</a>
<a href="https://claude.com/claude-code">
<img src="https://img.shields.io/badge/Claude%20Code-enabled-orange.svg" alt="Claude Code enabled" />
</a>
</p>
Thats it. Restart Claude Code and youre good. No config. No tedious setup or dependencies.
<h4>
<a href="https://github.com/thedotmack/claude-mem">Documentation</a>
<span> · </span>
<a href="https://github.com/thedotmack/claude-mem/issues/">Report Bug</a>
<span> · </span>
<a href="https://github.com/thedotmack/claude-mem/issues/">Request Feature</a>
</h4>
</div>
## ✨ What You Get
<br />
- 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
<!-- Table of Contents -->
# :notebook_with_decorative_cover: Table of Contents
## 🗑️ Smart Trash™ (Your Panic Button)
- [About the Project](#star2-about-the-project)
* [Tech Stack](#space_invader-tech-stack)
* [Features](#dart-features)
- [Getting Started](#toolbox-getting-started)
* [Prerequisites](#bangbang-prerequisites)
* [Installation](#gear-installation)
* [Running Tests](#test_tube-running-tests)
- [Usage](#eyes-usage)
* [Basic Commands](#basic-commands)
* [Hook System](#hook-system)
* [Memory Operations](#memory-operations)
* [ChromaDB MCP Tools](#chromadb-mcp-tools)
* [Advanced Usage](#advanced-usage)
- [Architecture](#building_construction-architecture)
- [Configuration](#wrench-configuration)
- [Roadmap](#compass-roadmap)
- [Contributing](#wave-contributing)
- [License](#warning-license)
- [Contact](#handshake-contact)
- [Acknowledgements](#gem-acknowledgements)
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
<!-- About the Project -->
## :star2: About the Project
## 🧭 Minimal Commands Youll Ever Need
claude-mem automatically captures, compresses, and retrieves context across Claude Code sessions, enabling true long-term memory through semantic search and intelligent compression.
```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
Perfect for developers who want their AI assistant to remember project context, past decisions, and conversation history across sessions without manual context management.
# Extras
claude-mem trash-view # See whats in Smart Trash™
claude-mem restore # Restore deleted items
```
<!-- TechStack -->
### :space_invader: Tech Stack
## 📁 Where Stuff Lives (super simple)
<details>
<summary>Core Technologies</summary>
<ul>
<li><a href="https://www.typescriptlang.org/">TypeScript</a></li>
<li><a href="https://nodejs.org/">Node.js</a></li>
<li><a href="https://bun.sh/">Bun</a></li>
</ul>
</details>
```
~/.claude-mem/
├── index/ # memory index
├── archives/ # transcripts
├── hooks/ # integration bits
├── trash/ # Smart Trash™
└── logs/ # diagnostics
```
<details>
<summary>Storage & Memory</summary>
<ul>
<li><a href="https://www.trychroma.com/">ChromaDB</a> - Vector database for semantic search</li>
<li><a href="https://www.sqlite.org/">SQLite</a> - Metadata and session tracking</li>
<li><a href="https://github.com/WiseLibs/better-sqlite3">better-sqlite3</a> - Fast SQLite bindings</li>
</ul>
</details>
## ✅ Requirements
<details>
<summary>AI & Integration</summary>
<ul>
<li><a href="https://github.com/anthropics/anthropic-sdk-typescript">Anthropic Agent SDK</a> - Async compression</li>
<li><a href="https://modelcontextprotocol.io">Model Context Protocol (MCP)</a> - Tool integration</li>
<li><a href="https://claude.com/claude-code">Claude Code</a> - Streaming hooks</li>
</ul>
</details>
- Node.js 18+
- Claude Code
<!-- Features -->
### :dart: Features
## 🆘 If Somethings Weird
- :brain: **Automatic Memory Compression** - Real-time conversation capture and intelligent summarization
- :mag: **Semantic Search** - ChromaDB-powered vector search for intelligent context retrieval
- :package: **Project Isolation** - Memories segregated by project with multi-project support
- :arrows_counterclockwise: **Session Persistence** - Context loads automatically at session start and `/clear` command
- :dart: **MCP Integration** - 15+ ChromaDB tools via Model Context Protocol
- :floppy_disk: **SQLite Storage** - Fast metadata and session tracking with embedded database
- :wastebasket: **Smart Trash** - Safe file deletion with recovery capabilities
- :zap: **Streaming Hooks** - Sub-50ms overhead for real-time event capture
- :robot: **Agent SDK Compression** - Async transcript processing without blocking conversations
- :bar_chart: **Session Overviews** - Automatic session summaries with temporal context
```bash
claude-mem status # quick health check
claude-mem install --force # fixes most issues
```
<!-- Getting Started -->
## :toolbox: Getting Started
## 📄 License
<!-- Prerequisites -->
### :bangbang: Prerequisites
Licensed under AGPL-3.0. See `LICENSE`.
This project requires Node.js and works best with Claude Code
---
- Node.js >= 18.0.0
- Claude Code with MCP support
- macOS/Linux (POSIX-compliant system)
- Bun >= 1.0.0 (optional, for development)
## Ready to remember more and repeat less?
<!-- Installation -->
### :gear: Installation
Install claude-mem globally via npm
```bash
npm install -g claude-mem
claude-mem install
```
Your future self will thank you. 🧠✨
The interactive installer will guide you through three installation scopes:
- **User** - Install for current user (default, recommended)
- **Project** - Install for current project only
- **Local** - Install to custom directory
<!-- Running Tests -->
### :test_tube: Running Tests
To run tests, use the following commands
```bash
# Run all tests
bun test
# Run integration tests
npm run test:integration
# Run with coverage
npm run test:coverage
```
<!-- Usage -->
## :eyes: Usage
### Basic Commands
```bash
# Check installation status
claude-mem status
# View operation logs
claude-mem logs
# Load context for current project
claude-mem load-context --project my-project
# View compressed memories (interactive)
claude-mem restore
# Manage trash bin
claude-mem trash view
claude-mem restore
claude-mem trash empty
```
### Hook System
claude-mem integrates with Claude Code via streaming hooks that capture conversation events:
- **user-prompt-submit** - Captures user prompts in real-time
- **post-tool-use** - Spawns Agent SDK for async compression
- **stop-streaming** - Generates session overview and cleanup
- **session-start** - Loads relevant context automatically
Hooks are configured during installation with a 180-second timeout and run transparently in the background.
### Memory Operations
#### Manual Compression
```bash
claude-mem compress
```
Compress Claude Code transcripts into searchable memories with semantic embeddings.
#### Context Loading
```bash
# Load last 10 memories for current project
claude-mem load-context
# Load specific number of memories
claude-mem load-context --count 20
# Filter by project
claude-mem load-context --project my-app
# Output raw JSON
claude-mem load-context --raw
```
#### Trash Management
claude-mem includes Smart Trash for safe file operations:
```bash
# Move files to trash
claude-mem trash file.txt
claude-mem trash -r directory/
# View trash contents
claude-mem trash view
# Restore files interactively
claude-mem restore
# Empty trash permanently
claude-mem trash empty
```
### ChromaDB MCP Tools
claude-mem exposes 15+ ChromaDB operations via MCP:
```bash
# List collections
claude-mem chroma-list-collections
# Create collection
claude-mem chroma-create-collection --collection-name memories
# Query documents semantically
claude-mem chroma-query-documents \
--collection-name memories \
--query-texts '["authentication implementation"]' \
--n-results 5
# Add documents
claude-mem chroma-add-documents \
--collection-name memories \
--documents '["content here"]' \
--ids '["mem-001"]'
# Get documents by ID
claude-mem chroma-get-documents \
--collection-name memories \
--ids '["mem-001"]'
# Update documents
claude-mem chroma-update-documents \
--collection-name memories \
--ids '["mem-001"]' \
--documents '["updated content"]'
# Delete documents
claude-mem chroma-delete-documents \
--collection-name memories \
--ids '["mem-001"]'
```
See all available Chroma MCP commands with `claude-mem --help`.
### Advanced Usage
#### Session Title Generation
```bash
# Generate title and subtitle from prompt
claude-mem generate-title "implemented authentication with OAuth"
# Output as JSON
claude-mem generate-title "fixed bug in checkout" --json
# Save to database
claude-mem generate-title "added feature" --session-id abc123 --save
```
#### Diagnostics
```bash
# Run environment diagnostics
claude-mem doctor
# Output as JSON
claude-mem doctor --json
```
#### Changelog Generation
```bash
# Generate changelog from memories
claude-mem changelog
# Preview without saving
claude-mem changelog --preview
# Generate for specific version
claude-mem changelog --generate 3.9.0
# Search historical versions
claude-mem changelog --historical 5
```
## :building_construction: Architecture
### Storage Structure
```
~/.claude-mem/
├── archives/ # Compressed transcript backups
├── chroma/ # ChromaDB vector database
├── trash/ # Smart Trash with recovery
├── hooks/ # Hook configurations
├── logs/ # Operation logs
└── claude-mem.db # SQLite metadata database
```
### Memory System
**Rolling Memory** - Real-time conversation turn capture via hooks with immediate ChromaDB storage
**TranscriptCompressor** - Intelligent chunking and compression of large conversations
**MCP Server** - 15+ ChromaDB tools for memory operations and semantic search
**SQLite Backend** - Session tracking, metadata management, and diagnostics storage
### Hook Integration
Hooks communicate via JSON stdin/stdout and run with minimal overhead:
1. **user-prompt-submit** - Stores user prompt immediately in ChromaDB
2. **post-tool-use** - Spawns Agent SDK subprocess for async compression
3. **stop-streaming** - Generates session overview, deletes SDK transcript
4. **session-start** - Loads project-specific context invisibly
### Project Structure
```
src/
├── bin/ # CLI entry point
├── commands/ # Command implementations
├── core/ # Core compression logic
├── services/ # SQLite, ChromaDB, path discovery
├── shared/ # Configuration and utilities
└── mcp-server.ts # MCP server implementation
hook-templates/ # Hook source files
dist/ # Minified production bundle
test/ # Unit and integration tests
```
## :wrench: Configuration
### Hook Timeout
Default hook timeout is 180 seconds. Configure during installation:
```bash
claude-mem install --timeout 300000 # 5 minutes
```
### MCP Server
Skip MCP server installation if needed:
```bash
claude-mem install --skip-mcp
```
### Force Reinstall
```bash
claude-mem install --force
```
<!-- Roadmap -->
## :compass: Roadmap
* [x] Real-time conversation capture with streaming hooks
* [x] ChromaDB vector storage for semantic search
* [x] SQLite metadata and session tracking
* [x] MCP server with 15+ ChromaDB tools
* [x] Smart Trash for safe file deletion
* [x] Automatic session overviews
* [ ] Web UI for memory visualization
* [ ] Cross-platform Windows support
* [ ] Memory analytics and insights
<!-- Contributing -->
## :wave: Contributing
<a href="https://github.com/thedotmack/claude-mem/graphs/contributors">
<img src="https://contrib.rocks/image?repo=thedotmack/claude-mem" />
</a>
Contributions are always welcome!
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
<!-- License -->
## :warning: License
Distributed under the AGPL-3.0 License. See [LICENSE](LICENSE) for more information.
<!-- Contact -->
## :handshake: Contact
Alex Newman - [@thedotmack](https://github.com/thedotmack)
Project Link: [https://github.com/thedotmack/claude-mem](https://github.com/thedotmack/claude-mem)
NPM Package: [https://www.npmjs.com/package/claude-mem](https://www.npmjs.com/package/claude-mem)
<!-- Acknowledgments -->
## :gem: Acknowledgements
- [ChromaDB](https://www.trychroma.com/) - Vector database for AI applications
- [Anthropic](https://www.anthropic.com/) - Claude AI and Agent SDK
- [Model Context Protocol](https://modelcontextprotocol.io) - Standardized AI tool integration
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) - Fast SQLite bindings
- [Shields.io](https://shields.io/) - Beautiful README badges
- [Awesome README Template](https://github.com/Louis3797/awesome-readme-template) - Template inspiration
---
**Philosophy**: claude-mem follows the **Make It Work First** approach - direct execution over defensive validation, natural failures instead of artificial guards, and memory as a living, evolving system. Context improves with use through semantic search, project isolation, and temporal relevance.
**Built with TypeScript, ChromaDB, SQLite, and the Anthropic Agent SDK**
+265
View File
@@ -0,0 +1,265 @@
# Windows Installation Guide
## Overview
Claude-mem now includes **experimental Windows support** as of v3.10.0 (October 2025). The cross-platform architecture uses the [Platform utility](/src/utils/platform.ts) for handling Windows-specific differences.
## System Requirements
- **Windows 10/11** (64-bit recommended)
- **Node.js** >= 18.0.0
- **PowerShell** 5.1 or later (for hook execution)
- **Claude Code** with MCP support
- **npm** package manager
**Note**: Bun (>=1.0.0) is only required for development work on claude-mem itself, not for running it.
## Known Windows-Specific Differences
### 1. Hook Execution
- **Unix/macOS**: Hooks execute via `/bin/sh`
- **Windows**: Hooks execute via `powershell`
All hooks are `.js` files that work on both platforms through Node.js.
### 2. Path Handling
Windows paths use backslashes (`\`) but Node.js normalizes these automatically. The data directory is located at:
```
C:\Users\YourUsername\.claude-mem\
```
### 3. File Permissions
- **Unix/macOS**: Hook files get `chmod 755` permissions
- **Windows**: Permission setting is a no-op (not needed)
### 4. Smart Trash™ Alias
The Smart Trash feature creates shell aliases differently:
**Windows (PowerShell)**:
```powershell
# claude-mem smart trash alias
function rm { claude-mem trash $args }
```
PowerShell profiles are located at:
- `Documents\PowerShell\Microsoft.PowerShell_profile.ps1`
- `Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1`
**Unix/macOS (Bash/Zsh)**:
```bash
# claude-mem smart trash alias
alias rm='claude-mem trash'
```
### 5. UV Package Manager Installation
The installer automatically installs `uv` using platform-specific methods:
**Windows**:
```powershell
powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"
```
**Unix/macOS**:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
## Installation Steps
### 1. Install claude-mem globally
```powershell
npm install -g claude-mem
```
### 2. Run the installer
```powershell
claude-mem install
```
The installer will:
1. ✅ Create directory structure at `C:\Users\YourUsername\.claude-mem\`
2. ✅ Install UV package manager via PowerShell
3. ✅ Install Chroma MCP server for vector database
4. ✅ Add CLAUDE.md instructions to `C:\Users\YourUsername\.claude\`
5. ✅ Install slash commands (save.md, remember.md, claude-mem.md)
6. ✅ Install memory hooks with PowerShell execution support
7. ✅ Configure Claude Code settings
### 3. Restart Claude Code
After installation, restart Claude Code to activate the memory system.
## Windows-Specific Caveats
### PowerShell Execution Policy
If hooks fail to execute, you may need to adjust your PowerShell execution policy:
```powershell
# Check current policy
Get-ExecutionPolicy
# Allow local scripts (choose one):
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
# OR for more security:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
```
### Path Environment Variable
After installing UV, you may need to restart PowerShell or update your PATH:
```powershell
# Add to PATH if needed
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
```
The installer attempts to set this automatically, but it only affects the current process.
### Better-SQLite3 Native Module
The hooks use `better-sqlite3` for database access, which requires native compilation:
- The installer runs `npm install` in the hooks directory
- If this fails, hooks may still work if `better-sqlite3` is globally available
- Build errors are silently ignored to prevent installation failure
If you see database errors in logs:
```powershell
cd $env:USERPROFILE\.claude-mem\hooks
npm install better-sqlite3 --build-from-source
```
## File Locations on Windows
| Component | Windows Path |
|-----------|-------------|
| Data directory | `C:\Users\YourUsername\.claude-mem\` |
| ChromaDB | `C:\Users\YourUsername\.claude-mem\chroma\` |
| SQLite database | `C:\Users\YourUsername\.claude-mem\claude-mem.db` |
| Hooks | `C:\Users\YourUsername\.claude-mem\hooks\` |
| Archives | `C:\Users\YourUsername\.claude-mem\archives\` |
| Trash | `C:\Users\YourUsername\.claude-mem\trash\` |
| CLAUDE.md | `C:\Users\YourUsername\.claude\CLAUDE.md` |
| Settings | `C:\Users\YourUsername\AppData\Roaming\Claude\settings.json` |
| Commands | `C:\Users\YourUsername\.claude\commands\` |
## Testing Your Installation
### 1. Check installation status
```powershell
claude-mem status
```
### 2. Run diagnostics
```powershell
claude-mem doctor
```
This will verify:
- ✅ Directory structure
- ✅ Hook configuration
- ✅ Database accessibility
- ✅ MCP server integration
### 3. Test memory storage
Start Claude Code and have a brief conversation. Then check:
```powershell
claude-mem status
```
You should see memory counts increase.
### 4. Search memories
In Claude Code, ask:
```
Search my memories for [your topic]
```
Claude should use the `mcp__claude-mem__chroma_query_documents` tool automatically.
## Known Issues & Limitations
### ⚠️ PowerShell Profile Creation
- **Issue**: PowerShell profiles may not exist by default
- **Impact**: Smart Trash alias installation creates profile directories
- **Status**: Installer handles this automatically
### ⚠️ Native Module Compilation
- **Issue**: `better-sqlite3` requires build tools
- **Impact**: May need Visual Studio Build Tools installed
- **Workaround**: Installer attempts silent installation; manually rebuild if needed
### ⚠️ Path Case Sensitivity
- **Issue**: Windows paths are case-insensitive but ChromaDB metadata stores exact case
- **Impact**: Path comparisons may fail in edge cases
- **Status**: Generally works fine; use consistent casing
## Getting Help
If you encounter Windows-specific issues:
1. **Run diagnostics**:
```powershell
claude-mem doctor
```
2. **Check logs**:
```powershell
claude-mem logs
```
Or view directly:
```powershell
type $env:USERPROFILE\.claude-mem\logs\*.log
```
3. **Verify hook execution**:
Check Claude Code's hook output for errors during session start/stop
4. **Report issues**:
- Include `claude-mem doctor` output
- Include relevant logs
- Specify Windows version and PowerShell version
- File at: https://github.com/thedotmack/claude-mem/issues
## Development on Windows
If you're developing claude-mem on Windows:
### Prerequisites
- Install Bun (Windows support is experimental): https://bun.sh/
- Install Git for Windows
- Install Visual Studio Build Tools for native modules
### Build Commands
```powershell
# Install dependencies
npm install
# Build minified bundle
npm run build
# Link for local testing
bun link
# Reinstall with latest build
claude-mem install --force
```
**Note**: Some npm scripts use Unix-style paths and may not work on Windows. Core development is recommended on Unix/macOS systems.
## Summary
**Works on Windows**:
- Memory capture via streaming hooks
- ChromaDB semantic search
- SQLite metadata storage
- MCP server integration
- All CLI commands
- Smart Trash™ system
- Automatic context loading
⚠️ **May Require Extra Setup**:
- PowerShell execution policy
- Visual Studio Build Tools for native modules
- Manual PATH configuration
The core claude-mem functionality is fully operational on Windows, with the memory capture, storage, and retrieval systems working identically to Unix/macOS.
+460 -489
View File
File diff suppressed because one or more lines are too long
+150
View File
@@ -0,0 +1,150 @@
#!/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';
import { initializeDatabase, getActiveStreamingSessionsForProject } from './shared/hook-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
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 from database
const db = initializeDatabase();
const sessions = getActiveStreamingSessionsForProject(db, project);
if (!sessions || sessions.length === 0) {
debugLog('PostToolUse: No streaming session found', { project });
db.close();
process.exit(0);
}
const sessionData = sessions[0];
const sdkSessionId = sessionData.sdk_session_id;
// 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 });
// Close database connection
db.close();
} catch (error) {
debugLog('PostToolUse: Error sending to SDK', { error: error.message });
}
// Exit cleanly after async processing completes
process.exit(0);
});
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Session Start Hook (SDK Version)
*
* Calls the CLI to load relevant context from ChromaDB at session start.
*/
import { createHookResponse, debugLog } from './shared/hook-helpers.js';
// Read stdin
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', async () => {
const payload = input ? JSON.parse(input) : {};
debugLog('SessionStart hook invoked (SDK version)', { cwd: payload.cwd });
const { cwd, source } = payload;
// Run on startup or /clear
if (source !== 'startup' && source !== 'clear') {
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
try {
// Call the CLI to load context
const { executeCliCommand } = await import('./shared/hook-helpers.js');
const result = await executeCliCommand('claude-mem', ['load-context', '--format', 'session-start']);
if (result.success && result.stdout) {
// Use the CLI output directly as context (it's already formatted)
const response = createHookResponse('SessionStart', true, {
context: result.stdout
});
console.log(JSON.stringify(response));
process.exit(0);
} else {
// Return without context
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
} catch (error) {
// Continue without context on error
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
});
@@ -2,7 +2,7 @@
/**
* Hook Helper Functions
*
*
* This module provides JavaScript wrappers around the TypeScript PromptOrchestrator
* and HookTemplates system, making them accessible to the JavaScript hook scripts.
*/
@@ -10,6 +10,9 @@
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import Database from 'better-sqlite3';
import os from 'os';
import fs from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -39,30 +42,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 +120,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 +141,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 +237,194 @@ export function debugLog(message, data = {}) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] HOOK DEBUG: ${message}`, data);
}
}
}
// =============================================================================
// DATABASE HELPERS (inline SQL to avoid 'claude-mem' import issues)
// =============================================================================
/**
* Get the claude-mem data directory path
*/
function getDataDirectory() {
return join(os.homedir(), '.claude-mem');
}
/**
* Get or create the database connection
*/
function getDatabase() {
const dataDir = getDataDirectory();
const dbPath = join(dataDir, 'claude-mem.db');
// Ensure directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(dbPath);
// Apply optimized SQLite settings
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
db.pragma('temp_store = memory');
return db;
}
/**
* Ensure the streaming_sessions table exists
*/
function ensureStreamingSessionsTable(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS streaming_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
sdk_session_id TEXT,
project TEXT NOT NULL,
title TEXT,
subtitle TEXT,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
updated_at TEXT,
updated_at_epoch INTEGER,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'failed'))
)
`);
// Create indices if they don't exist
db.exec(`
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_claude_id
ON streaming_sessions(claude_session_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_sdk_id
ON streaming_sessions(sdk_session_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_project_status
ON streaming_sessions(project, status)
`);
}
/**
* Create a new streaming session record
*/
export function createStreamingSession(db, { claude_session_id, project, user_prompt, started_at }) {
ensureStreamingSessionsTable(db);
const timestamp = started_at || new Date().toISOString();
const epoch = new Date(timestamp).getTime();
const stmt = db.prepare(`
INSERT INTO streaming_sessions (
claude_session_id, project, user_prompt, started_at, started_at_epoch, status
) VALUES (?, ?, ?, ?, ?, 'active')
`);
const info = stmt.run(claude_session_id, project, user_prompt || null, timestamp, epoch);
return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?').get(info.lastInsertRowid);
}
/**
* Update a streaming session by internal ID
*/
export function updateStreamingSession(db, id, updates) {
const timestamp = new Date().toISOString();
const epoch = Date.now();
const parts = [];
const values = [];
if (updates.sdk_session_id !== undefined) {
parts.push('sdk_session_id = ?');
values.push(updates.sdk_session_id);
}
if (updates.title !== undefined) {
parts.push('title = ?');
values.push(updates.title);
}
if (updates.subtitle !== undefined) {
parts.push('subtitle = ?');
values.push(updates.subtitle);
}
if (updates.status !== undefined) {
parts.push('status = ?');
values.push(updates.status);
}
if (updates.completed_at !== undefined) {
const completedTimestamp = typeof updates.completed_at === 'string'
? updates.completed_at
: new Date(updates.completed_at).toISOString();
const completedEpoch = new Date(completedTimestamp).getTime();
parts.push('completed_at = ?', 'completed_at_epoch = ?');
values.push(completedTimestamp, completedEpoch);
}
// Always update the updated_at timestamp
parts.push('updated_at = ?', 'updated_at_epoch = ?');
values.push(timestamp, epoch);
values.push(id);
const stmt = db.prepare(`
UPDATE streaming_sessions
SET ${parts.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?').get(id);
}
/**
* Get active streaming sessions for a project
*/
export function getActiveStreamingSessionsForProject(db, project) {
ensureStreamingSessionsTable(db);
const stmt = db.prepare(`
SELECT * FROM streaming_sessions
WHERE project = ? AND status = 'active'
ORDER BY started_at_epoch DESC
`);
return stmt.all(project);
}
/**
* Mark a session as completed
*/
export function markStreamingSessionCompleted(db, id) {
const timestamp = new Date().toISOString();
const epoch = Date.now();
const stmt = db.prepare(`
UPDATE streaming_sessions
SET status = ?,
completed_at = ?,
completed_at_epoch = ?,
updated_at = ?,
updated_at_epoch = ?
WHERE id = ?
`);
stmt.run('completed', timestamp, epoch, timestamp, epoch, id);
return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?').get(id);
}
/**
* Initialize database with migrations and return connection
*/
export function initializeDatabase() {
const db = getDatabase();
ensureStreamingSessionsTable(db);
return db;
}
@@ -0,0 +1,278 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
// src/prompts/hook-prompt-renderer.ts
function substituteVariables(template, variables) {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
result = result.split(placeholder).join(value);
}
return result;
}
function truncate(text, maxLength) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + (text.length > maxLength ? "..." : "");
}
function formatTime(timestamp) {
const timePart = timestamp.split("T")[1];
if (!timePart)
return "";
return timePart.slice(0, 8);
}
function renderSystemPrompt(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
return substituteVariables(PROMPTS.system, {
project: variables.project,
sessionId: variables.sessionId,
date: variables.date,
userPrompt: userPromptTruncated
});
}
function renderToolMessage(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
const toolResponseTruncated = truncate(variables.toolResponse, HOOK_CONFIG.maxToolResponseLength);
const timeFormatted = formatTime(variables.timestamp);
return substituteVariables(PROMPTS.tool, {
toolName: variables.toolName,
toolResponse: toolResponseTruncated,
userPrompt: userPromptTruncated,
timestamp: variables.timestamp,
timeFormatted
});
}
function renderEndMessage(variables) {
return substituteVariables(PROMPTS.end, {
project: variables.project,
sessionId: variables.sessionId
});
}
function renderPrompt(type, variables) {
switch (type) {
case "system":
return renderSystemPrompt(variables);
case "tool":
return renderToolMessage(variables);
case "end":
return renderEndMessage(variables);
default:
throw new Error(`Unknown prompt type: ${type}`);
}
}
export {
renderToolMessage,
renderSystemPrompt,
renderPrompt,
renderEndMessage,
PROMPTS,
HOOK_CONFIG
};
@@ -0,0 +1,217 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
export {
TOOL_MESSAGE,
SYSTEM_PROMPT,
PROMPTS,
HOOK_CONFIG,
END_MESSAGE
};
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Path resolver utility for Claude Memory hooks
* Provides proper path handling using environment variables
*/
import { join, basename } from 'path';
import { homedir } from 'os';
/**
* Gets the base data directory for claude-mem
* @returns {string} Data directory path
*/
export function getDataDir() {
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
}
/**
* Gets the settings file path
* @returns {string} Settings file path
*/
export function getSettingsPath() {
return join(getDataDir(), 'settings.json');
}
/**
* Gets the archives directory path
* @returns {string} Archives directory path
*/
export function getArchivesDir() {
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
}
/**
* Gets the logs directory path
* @returns {string} Logs directory path
*/
export function getLogsDir() {
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
}
/**
* Gets the compact flag file path
* @returns {string} Compact flag file path
*/
export function getCompactFlagPath() {
return join(getDataDir(), '.compact-running');
}
/**
* Gets the claude-mem package root directory
* @returns {Promise<string>} Package root path
*/
export async function getPackageRoot() {
// Method 1: Check if we're running from development
const devPath = join(homedir(), 'Scripts', 'claude-mem-source');
const { existsSync } = await import('fs');
if (existsSync(join(devPath, 'package.json'))) {
return devPath;
}
// Method 2: Follow the binary symlink
try {
const { execSync } = await import('child_process');
const { realpathSync } = await import('fs');
const binPath = execSync('which claude-mem', { encoding: 'utf8' }).trim();
const realBinPath = realpathSync(binPath);
// Binary is typically at package_root/dist/claude-mem.min.js
return join(realBinPath, '../..');
} catch {}
throw new Error('Cannot locate claude-mem package root');
}
/**
* Gets the project root directory
* Uses CLAUDE_PROJECT_DIR environment variable if available, otherwise falls back to cwd
* @returns {string} Project root path
*/
export function getProjectRoot() {
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
}
/**
* Derives project name from CLAUDE_PROJECT_DIR or current working directory
* Priority: CLAUDE_PROJECT_DIR > cwd parameter > process.cwd()
* @param {string} [cwd] - Optional current working directory from hook payload
* @returns {string} Project name (basename of project directory)
*/
export function getProjectName(cwd) {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || cwd || process.cwd();
return basename(projectRoot);
}
/**
* Gets all common paths used by hooks
* @returns {Object} Object containing all common paths
*/
export function getPaths() {
return {
dataDir: getDataDir(),
settingsPath: getSettingsPath(),
archivesDir: getArchivesDir(),
logsDir: getLogsDir(),
compactFlagPath: getCompactFlagPath()
};
}
+134
View File
@@ -0,0 +1,134 @@
#!/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';
import { initializeDatabase, getActiveStreamingSessionsForProject, markStreamingSessionCompleted } from './shared/hook-helpers.js';
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';
// Immediately clear activity flag for UI indicator
const activityFlagPath = path.join(process.env.HOME || '', '.claude-mem', 'activity.flag');
try {
fs.writeFileSync(activityFlagPath, JSON.stringify({ active: false, timestamp: Date.now() }));
} catch (error) {
// Silent fail - non-critical
}
// Return immediately with async mode
console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
try {
// Load SDK session info from database
const db = initializeDatabase();
const sessions = getActiveStreamingSessionsForProject(db, project);
if (!sessions || sessions.length === 0) {
debugLog('Stop: No streaming session found', { project });
db.close();
process.exit(0);
}
const sessionData = sessions[0];
const sdkSessionId = sessionData.sdk_session_id;
const claudeSessionId = sessionData.claude_session_id;
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 });
}
// Mark session as completed in database
markStreamingSessionCompleted(db, sessionData.id);
debugLog('Stop: Session ended and marked complete', { project, sessionId: sessionData.id });
// Close database connection
db.close();
} catch (error) {
debugLog('Stop: Error ending session', { error: error.message });
}
// Exit cleanly after async processing completes
process.exit(0);
});
+157
View File
@@ -0,0 +1,157 @@
#!/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';
import { initializeDatabase, createStreamingSession, updateStreamingSession } from './shared/hook-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
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 });
// Immediately signal activity start for UI indicator
const activityFlagPath = path.join(process.env.HOME || '', '.claude-mem', 'activity.flag');
try {
fs.writeFileSync(activityFlagPath, JSON.stringify({ active: true, project, timestamp: Date.now() }));
} catch (error) {
// Silent fail - non-critical
}
// Generate title and subtitle non-blocking
if (prompt && session_id && project) {
import('child_process').then(({ spawn }) => {
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 {
// Initialize database and create session record FIRST
const db = initializeDatabase();
// Create session record immediately - this gives us a tracking ID
const sessionRecord = createStreamingSession(db, {
claude_session_id: session_id,
project,
user_prompt: prompt,
started_at: timestamp
});
debugLog('UserPromptSubmit: Created session record', {
internalId: sessionRecord.id,
claudeSessionId: session_id
});
// Build system prompt using centralized config
const systemPrompt = renderSystemPrompt({
project,
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) {
// Update session record with SDK session ID
updateStreamingSession(db, sessionRecord.id, {
sdk_session_id: sdkSessionId
});
debugLog('UserPromptSubmit: SDK session started', {
internalId: sessionRecord.id,
sdkSessionId
});
}
// Close database connection
db.close();
} catch (error) {
debugLog('UserPromptSubmit: Error starting SDK session', { error: error.message });
}
// Return success to Claude Code
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
});
-89
View File
@@ -1,89 +0,0 @@
#!/usr/bin/env node
/**
* Pre-Compact Hook for Claude Memory System
*
* Updated to use the centralized PromptOrchestrator and HookTemplates system.
* This hook validates the pre-compact request and executes compression using
* standardized response templates for consistent Claude Code integration.
*/
import { loadCliCommand } from './shared/config-loader.js';
import { getLogsDir } from './shared/path-resolver.js';
import {
createHookResponse,
executeCliCommand,
validateHookPayload,
debugLog
} from './shared/hook-helpers.js';
// Set up stdin immediately
process.stdin.setEncoding('utf8');
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
// Read input from stdin
let input = '';
process.stdin.on('data', chunk => {
input += chunk;
});
process.stdin.on('end', async () => {
try {
// Load CLI command inside try-catch to handle config errors properly
const cliCommand = loadCliCommand();
const payload = JSON.parse(input);
debugLog('Pre-compact hook started', { payload });
// Validate payload using centralized validation
const validation = validateHookPayload(payload, 'PreCompact');
if (!validation.valid) {
const response = createHookResponse('PreCompact', false, { reason: validation.error });
debugLog('Validation failed', { response });
// Exit silently - validation failure is expected flow control
process.exit(0);
}
// Check for environment-based blocking conditions
if (payload.trigger === 'auto' && process.env.DISABLE_AUTO_COMPRESSION === 'true') {
const response = createHookResponse('PreCompact', false, {
reason: 'Auto-compression disabled by configuration'
});
debugLog('Auto-compression disabled', { response });
// Exit silently - disabled compression is expected flow control
process.exit(0);
}
// Execute compression using standardized CLI execution helper
debugLog('Executing compression command', {
command: cliCommand,
args: ['compress', payload.transcript_path]
});
const result = await executeCliCommand(cliCommand, ['compress', payload.transcript_path]);
if (!result.success) {
const response = createHookResponse('PreCompact', false, {
reason: `Compression failed: ${result.stderr || 'Unknown error'}`
});
debugLog('Compression command failed', { stderr: result.stderr, response });
console.log(`claude-mem error: compression failed, see logs at ${getLogsDir()}`);
process.exit(1); // Exit with error code for actual compression failure
}
// Success - exit silently (suppressOutput is true)
debugLog('Compression completed successfully');
process.exit(0);
} catch (error) {
const response = createHookResponse('PreCompact', false, {
reason: `Hook execution error: ${error.message}`
});
debugLog('Pre-compact hook error', { error: error.message, response });
console.log(`claude-mem error: hook failed, see logs at ${getLogsDir()}`);
process.exit(1);
}
});
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env node
/**
* Session End Hook - Handles session end events including /clear
*/
import { loadCliCommand } from './shared/config-loader.js';
import { getSettingsPath, getArchivesDir } from './shared/path-resolver.js';
import { execSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
const cliCommand = loadCliCommand();
// Check if save-on-clear is enabled
function isSaveOnClearEnabled() {
const settingsPath = getSettingsPath();
if (existsSync(settingsPath)) {
try {
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
return settings.saveMemoriesOnClear === true;
} catch (error) {
return false;
}
}
return false;
}
// Set up stdin immediately before any async operations
process.stdin.setEncoding('utf8');
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
// Read input
let input = '';
process.stdin.on('data', chunk => {
input += chunk;
});
process.stdin.on('end', async () => {
const data = JSON.parse(input);
// Check if this is a clear event and save-on-clear is enabled
if (data.reason === 'clear' && isSaveOnClearEnabled()) {
console.error('🧠 Saving memories before clearing context...');
try {
// Use the CLI to compress current transcript
execSync(`${cliCommand} compress --output ${getArchivesDir()}`, {
stdio: 'inherit',
env: { ...process.env, CLAUDE_MEM_SILENT: 'true' }
});
console.error('✅ Memories saved successfully');
} catch (error) {
console.error('[session-end] Failed to save memories:', error.message);
// Don't block the clear operation if memory saving fails
}
}
// Always continue
console.log(JSON.stringify({ continue: true }));
});
-170
View File
@@ -1,170 +0,0 @@
#!/usr/bin/env node
/**
* Session Start Hook - Load context when Claude Code starts
*
* Updated to use the centralized PromptOrchestrator and HookTemplates system.
* This hook loads previous session context using standardized formatting and
* provides rich context messages for Claude Code integration.
*/
import path from 'path';
import { loadCliCommand } from './shared/config-loader.js';
import {
createHookResponse,
formatSessionStartContext,
executeCliCommand,
parseContextData,
validateHookPayload,
debugLog
} from './shared/hook-helpers.js';
const cliCommand = loadCliCommand();
// Set up stdin immediately before any async operations
process.stdin.setEncoding('utf8');
process.stdin.resume(); // Explicitly enter flowing mode to prevent data loss
// Read input from stdin
let input = '';
process.stdin.on('data', chunk => {
input += chunk;
});
process.stdin.on('end', async () => {
try {
const payload = JSON.parse(input);
debugLog('Session start hook started', { payload });
// Validate payload using centralized validation
const validation = validateHookPayload(payload, 'SessionStart');
if (!validation.valid) {
debugLog('Payload validation failed', { error: validation.error });
// For session start, continue even with invalid payload but log the error
const response = createHookResponse('SessionStart', false, {
error: `Payload validation failed: ${validation.error}`
});
console.log(JSON.stringify(response));
process.exit(0);
}
// Skip load-context when source is "resume" to avoid duplicate context
if (payload.source === 'resume') {
debugLog('Skipping load-context for resume source');
// Output valid JSON response with suppressOutput for resume
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
// Extract project name from current working directory
const projectName = path.basename(process.cwd());
// Load context using standardized CLI execution helper
const contextResult = await executeCliCommand(cliCommand, [
'load-context',
'--format', 'session-start',
'--project', projectName
]);
if (!contextResult.success) {
debugLog('Context loading failed', { stderr: contextResult.stderr });
// Don't fail the session start, just provide error context
const response = createHookResponse('SessionStart', false, {
error: contextResult.stderr || 'Failed to load context'
});
console.log(JSON.stringify(response));
process.exit(0);
}
const rawContext = contextResult.stdout;
debugLog('Raw context loaded', { contextLength: rawContext.length });
// Check if the output is actually an error message (starts with ❌)
if (rawContext && rawContext.trim().startsWith('❌')) {
debugLog('Detected error message in stdout', { rawContext });
// Extract the clean error message without the emoji and format
const errorMatch = rawContext.match(/❌\s*[^:]+:\s*([^\n]+)(?:\n\n💡\s*(.+))?/);
let errorMsg = 'No previous memories found';
let suggestion = '';
if (errorMatch) {
errorMsg = errorMatch[1] || errorMsg;
suggestion = errorMatch[2] || '';
}
// Create a clean response without duplicating the error formatting
const response = createHookResponse('SessionStart', false, {
error: errorMsg + (suggestion ? `. ${suggestion}` : '')
});
console.log(JSON.stringify(response));
process.exit(0);
}
if (!rawContext || !rawContext.trim()) {
debugLog('No context available, creating empty response');
// No context available - use standardized empty response
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
// Parse context data and format using centralized templates
const contextData = parseContextData(rawContext);
contextData.projectName = projectName;
// If we have raw context (not structured data), use it directly
let formattedContext;
if (contextData.rawContext) {
formattedContext = contextData.rawContext;
} else {
// Use standardized formatting for structured context
formattedContext = formatSessionStartContext(contextData);
}
debugLog('Context formatted successfully', {
memoryCount: contextData.memoryCount,
hasStructuredData: !contextData.rawContext
});
// Create standardized session start response using HookTemplates
const response = createHookResponse('SessionStart', true, {
context: formattedContext
});
console.log(JSON.stringify(response));
process.exit(0);
} catch (error) {
debugLog('Session start hook error', { error: error.message });
// Even on error, continue the session with error information
const response = createHookResponse('SessionStart', false, {
error: `Hook execution error: ${error.message}`
});
console.log(JSON.stringify(response));
process.exit(0);
}
});
/**
* Extracts project name from transcript path
* @param {string} transcriptPath - Path to transcript file
* @returns {string|null} Extracted project name or null
*/
function extractProjectName(transcriptPath) {
if (!transcriptPath) return null;
// Look for project pattern: /path/to/PROJECT_NAME/.claude/
// Need to get PROJECT_NAME, not the parent directory
const parts = transcriptPath.split(path.sep);
const claudeIndex = parts.indexOf('.claude');
if (claudeIndex > 0) {
// Get the directory immediately before .claude
return parts[claudeIndex - 1];
}
// Fall back to directory containing the transcript
const dir = path.dirname(transcriptPath);
return path.basename(dir);
}
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
/**
* Path resolver utility for Claude Memory hooks
* Provides proper path handling using environment variables
*/
import { join } from 'path';
import { homedir } from 'os';
/**
* Gets the base data directory for claude-mem
* @returns {string} Data directory path
*/
export function getDataDir() {
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
}
/**
* Gets the settings file path
* @returns {string} Settings file path
*/
export function getSettingsPath() {
return join(getDataDir(), 'settings.json');
}
/**
* Gets the archives directory path
* @returns {string} Archives directory path
*/
export function getArchivesDir() {
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
}
/**
* Gets the logs directory path
* @returns {string} Logs directory path
*/
export function getLogsDir() {
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
}
/**
* Gets the compact flag file path
* @returns {string} Compact flag file path
*/
export function getCompactFlagPath() {
return join(getDataDir(), '.compact-running');
}
/**
* Gets all common paths used by hooks
* @returns {Object} Object containing all common paths
*/
export function getPaths() {
return {
dataDir: getDataDir(),
settingsPath: getSettingsPath(),
archivesDir: getArchivesDir(),
logsDir: getLogsDir(),
compactFlagPath: getCompactFlagPath()
};
}
+7 -9
View File
@@ -1,10 +1,10 @@
{
"name": "claude-mem",
"version": "3.7.1",
"version": "3.9.16",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
"claude-code",
"claude-agent-sdk",
"mcp",
"memory",
"compression",
@@ -36,24 +36,22 @@
"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",
"CHANGELOG.md"
"CHANGELOG.md",
"README_WINDOWS.md"
]
}
+105 -89
View File
@@ -7,20 +7,26 @@ import { Command } from 'commander';
import { PACKAGE_NAME, PACKAGE_VERSION, PACKAGE_DESCRIPTION } from '../shared/config.js';
// Import command handlers
import { compress } from '../commands/compress.js';
import { install } from '../commands/install.js';
import { uninstall } from '../commands/uninstall.js';
import { status } from '../commands/status.js';
import { logs } from '../commands/logs.js';
import { loadContext } from '../commands/load-context.js';
import { trash } from '../commands/trash.js';
import { viewTrash } from '../commands/trash-view.js';
import { emptyTrash } from '../commands/trash-empty.js';
import { restore } from '../commands/restore.js';
import { save } from '../commands/save.js';
import { changelog } from '../commands/changelog.js';
// Cloud functionality disabled - incomplete setup
// import { cloudCommand } from '../commands/cloud.js';
import { importHistory } from '../commands/import-history.js';
import { TranscriptCompressor } from '../core/compression/TranscriptCompressor.js';
import { doctor } from '../commands/doctor.js';
import { storeMemory } from '../commands/store-memory.js';
import { storeOverview } from '../commands/store-overview.js';
import { updateSessionMetadata } from '../commands/update-session-metadata.js';
import { generateTitle } from '../commands/generate-title.js';
import {
executeChromaMCPTool,
loadChromaMCPTools,
generateCommandOptions
} from '../commands/chroma-mcp.js';
const program = new Command();
// </Block> =======================================
@@ -35,19 +41,6 @@ program
// </Block> =======================================
// <Block> 1.3 ====================================
// Compress Command Definition
// Natural pattern: Define command with its options and handler
// Compress command
program
.command('compress [transcript]')
.description('Compress a Claude Code transcript into memory')
.option('--output <path>', 'Output directory for compressed files')
.option('--dry-run', 'Show what would be compressed without doing it')
.option('-v, --verbose', 'Show detailed output')
.action(compress);
// </Block> =======================================
// <Block> 1.4 ====================================
// Install Command Definition
// Natural pattern: Define command with its options and handler
// Install command
@@ -86,6 +79,20 @@ program
.command('status')
.description('Check installation status of Claude Memory System')
.action(status);
// Doctor command
program
.command('doctor')
.description('Run environment and pipeline diagnostics for rolling memory')
.option('--json', 'Output JSON instead of text')
.action(async (options: any) => {
try {
await doctor(options);
} catch (error: any) {
console.error(`doctor failed: ${error.message || error}`);
process.exitCode = 1;
}
});
// </Block> =======================================
// <Block> 1.7 ====================================
@@ -148,20 +155,14 @@ const trashCmd = program
trashCmd
.command('view')
.description('View contents of trash bin')
.action(async () => {
const { viewTrash } = await import('../commands/trash-view.js');
await viewTrash();
});
.action(viewTrash);
// Trash empty subcommand
trashCmd
.command('empty')
.description('Permanently delete all files in trash')
.option('-f, --force', 'Skip confirmation prompt')
.action(async (options: any) => {
const { emptyTrash } = await import('../commands/trash-empty.js');
await emptyTrash(options);
});
.action(emptyTrash);
// Restore command
program
@@ -170,15 +171,39 @@ program
.action(restore);
// </Block> =======================================
// Cloud command
// Cloud functionality disabled - incomplete setup
// program.addCommand(cloudCommand);
// Save command
// Store memory command (for SDK streaming)
program
.command('save <message>')
.description('Save a message to the memory system')
.action(save);
.command('store-memory')
.description('Store a memory to all storage layers (used by SDK)')
.requiredOption('--id <id>', 'Memory ID')
.requiredOption('--project <project>', 'Project name')
.requiredOption('--session <session>', 'Session ID')
.requiredOption('--date <date>', 'Date (YYYY-MM-DD)')
.requiredOption('--title <title>', 'Memory title (3-8 words)')
.requiredOption('--subtitle <subtitle>', 'Memory subtitle (max 24 words)')
.requiredOption('--facts <json>', 'Atomic facts as JSON array')
.option('--concepts <json>', 'Concept tags as JSON array')
.option('--files <json>', 'Files touched as JSON array')
.action(storeMemory);
// Store overview command (for SDK streaming)
program
.command('store-overview')
.description('Store a session overview (used by SDK)')
.requiredOption('--project <project>', 'Project name')
.requiredOption('--session <session>', 'Session ID')
.requiredOption('--content <content>', 'Overview content')
.action(storeOverview);
// Update session metadata command (for SDK streaming)
program
.command('update-session-metadata')
.description('Update session title and subtitle (used by SDK)')
.requiredOption('--project <project>', 'Project name')
.requiredOption('--session <session>', 'Session ID')
.requiredOption('--title <title>', 'Session title (3-6 words)')
.option('--subtitle <subtitle>', 'Session subtitle (max 20 words)')
.action(updateSessionMetadata);
// Changelog command
program
@@ -193,67 +218,58 @@ 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('--session-id <id>', 'Claude session ID to update')
.option('--save', 'Save the generated title to the database (requires --session-id)')
.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> =======================================
// <Block> 1.12 ===================================
// Module Exports for Programmatic Use
// Export database and utility classes for hooks and external consumers
export { DatabaseManager, StreamingSessionStore, migrations, initializeDatabase, getDatabase } from '../services/sqlite/index.js';
// </Block> =======================================
+1 -1
View File
@@ -1,5 +1,5 @@
import { OptionValues } from 'commander';
import { query } from '@anthropic-ai/claude-code';
import { query } from '@anthropic-ai/claude-agent-sdk';
import fs from 'fs';
import path from 'path';
import { getClaudePath } from '../shared/settings.js';
+196
View File
@@ -0,0 +1,196 @@
import { OptionValues } from 'commander';
import ChromaMCPClient from '../../chroma-mcp-tools/chroma-mcp-client.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Generic Chroma MCP tool executor
* Dynamically calls any Chroma MCP tool with provided arguments
*/
export async function executeChromaMCPTool(toolName: string, options: OptionValues): Promise<void> {
const client = new ChromaMCPClient();
try {
await client.connect();
// Convert commander options to tool arguments
const toolArgs = convertOptionsToArgs(toolName, options);
// Call the MCP tool
const result = await client.callTool(toolName, toolArgs);
// Parse and format the result nicely
const formatted = formatMCPResult(result);
console.log(formatted);
await client.disconnect();
process.exit(0);
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error calling MCP tool',
tool: toolName
}, null, 2));
await client.disconnect();
process.exit(1);
}
}
/**
* Format MCP tool result for clean CLI output
*/
function formatMCPResult(result: any): string {
// If result has content array (MCP protocol format)
if (result?.content && Array.isArray(result.content)) {
const textContent = result.content
.filter((item: any) => item.type === 'text')
.map((item: any) => item.text)
.join('\n');
// Try to parse as JSON for prettier output
try {
const parsed = JSON.parse(textContent);
return JSON.stringify(parsed, null, 2);
} catch {
// Not JSON, return as-is
return textContent;
}
}
// If result is already an object, pretty print it
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
}
// Fallback to string
return String(result);
}
/**
* Convert CLI options to MCP tool arguments
* Handles type conversion and array parsing
*/
function convertOptionsToArgs(toolName: string, options: OptionValues): Record<string, any> {
const args: Record<string, any> = {};
for (const [key, value] of Object.entries(options)) {
// Skip commander internal properties
if (key.startsWith('_') || typeof value === 'function') {
continue;
}
// Try to parse JSON strings
if (typeof value === 'string') {
try {
args[key] = JSON.parse(value);
} catch {
args[key] = value;
}
} else {
args[key] = value;
}
}
return args;
}
/**
* Load Chroma MCP tool definitions from JSON
*/
export function loadChromaMCPTools(): Array<{
name: string;
description: string;
inputSchema: any;
}> {
// Try multiple path resolutions for dev vs production
const possiblePaths = [
path.join(__dirname, '../../chroma-mcp-tools/CHROMA_MCP_TOOLS.json'),
path.join(process.cwd(), 'chroma-mcp-tools/CHROMA_MCP_TOOLS.json'),
path.join(__dirname, '../chroma-mcp-tools/CHROMA_MCP_TOOLS.json')
];
for (const toolsPath of possiblePaths) {
if (fs.existsSync(toolsPath)) {
const toolsJson = fs.readFileSync(toolsPath, 'utf-8');
return JSON.parse(toolsJson);
}
}
throw new Error('Could not find CHROMA_MCP_TOOLS.json');
}
/**
* Generate CLI command options from MCP tool schema
*/
export function generateCommandOptions(schema: any): Array<{
flag: string;
description: string;
required: boolean;
type: string;
}> {
const options: Array<{
flag: string;
description: string;
required: boolean;
type: string;
}> = [];
if (!schema.properties) return options;
const required = schema.required || [];
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const prop = propSchema as any;
const isRequired = required.includes(propName);
// Determine type
let type = 'string';
if (prop.type === 'integer' || prop.type === 'number') {
type = 'number';
} else if (prop.type === 'array') {
type = 'array';
} else if (prop.type === 'object') {
type = 'json';
} else if (prop.anyOf) {
// Handle nullable types
const nonNullType = prop.anyOf.find((t: any) => t.type !== 'null');
if (nonNullType?.type === 'integer' || nonNullType?.type === 'number') {
type = 'number';
} else if (nonNullType?.type === 'array') {
type = 'array';
} else if (nonNullType?.type === 'object') {
type = 'json';
}
}
// Build flag
const flag = isRequired
? `--${propName} <${type}>`
: `--${propName} [${type}]`;
// Build description
let description = prop.title || propName;
if (prop.default !== undefined) {
description += ` (default: ${JSON.stringify(prop.default)})`;
}
if (type === 'array') {
description += ' (JSON array)';
} else if (type === 'json') {
description += ' (JSON object)';
}
options.push({
flag,
description,
required: isRequired,
type
});
}
return options;
}
-43
View File
@@ -1,43 +0,0 @@
import { OptionValues } from 'commander';
import { basename, dirname } from 'path';
import {
createLoadingMessage,
createCompletionMessage,
createOperationSummary,
createUserFriendlyError
} from '../prompts/templates/context/ContextTemplates.js';
export async function compress(transcript?: string, options: OptionValues = {}): Promise<void> {
console.log(createLoadingMessage('compressing'));
if (!transcript) {
console.log(createUserFriendlyError('Compression', 'No transcript file provided', 'Please provide a path to a transcript file'));
return;
}
try {
const startTime = Date.now();
// Import and run compression
const { TranscriptCompressor } = await import('../core/compression/TranscriptCompressor.js');
const compressor = new TranscriptCompressor({
verbose: options.verbose || false
});
const sessionId = options.sessionId || basename(transcript, '.jsonl');
const archivePath = await compressor.compress(transcript, sessionId);
const duration = Date.now() - startTime;
console.log(createCompletionMessage('Compression', undefined, `Session archived as ${basename(archivePath)}`));
console.log(createOperationSummary('compress', { count: 1, duration, details: `Session: ${sessionId}` }));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(createUserFriendlyError(
'Compression',
errorMessage,
'Check that the transcript file exists and you have write permissions'
));
throw error; // Re-throw to maintain existing error handling behavior
}
}
+100
View File
@@ -0,0 +1,100 @@
import { OptionValues } from 'commander';
import fs from 'fs';
import path from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
import { createStores } from '../services/sqlite/index.js';
import { rollingLog } from '../shared/rolling-log.js';
type CheckStatus = 'pass' | 'fail' | 'warn';
interface CheckResult {
name: string;
status: CheckStatus;
details?: string;
}
function printCheck(result: CheckResult): void {
const icon =
result.status === 'pass' ? '✅' : result.status === 'warn' ? '⚠️ ' : '❌';
const message = result.details ? `${result.name}: ${result.details}` : result.name;
console.log(`${icon} ${message}`);
}
export async function doctor(options: OptionValues = {}): Promise<void> {
const discovery = PathDiscovery.getInstance();
const checks: CheckResult[] = [];
// Data directory
try {
const dataDir = discovery.getDataDirectory();
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
checks.push({ name: `Data directory created at ${dataDir}`, status: 'warn' });
} else {
const stats = fs.statSync(dataDir);
let writable = false;
try {
fs.accessSync(dataDir, fs.constants.W_OK);
writable = true;
} catch {}
checks.push({
name: `Data directory ${dataDir}`,
status: stats.isDirectory() && writable ? 'pass' : 'fail',
details: stats.isDirectory() && writable ? 'accessible' : 'not writable'
});
}
} catch (error: any) {
checks.push({
name: 'Data directory',
status: 'fail',
details: error?.message || String(error)
});
}
// SQLite connectivity
let stores; // reuse for queue check
try {
stores = await createStores();
const sessionCount = stores.sessions.count();
checks.push({
name: 'SQLite database',
status: 'pass',
details: `${sessionCount} session${sessionCount === 1 ? '' : 's'} present`
});
} catch (error: any) {
checks.push({
name: 'SQLite database',
status: 'fail',
details: error?.message || String(error)
});
}
// Chroma connectivity
try {
const chromaDir = discovery.getChromaDirectory();
const chromaExists = fs.existsSync(chromaDir);
checks.push({
name: 'Chroma vector store',
status: chromaExists ? 'pass' : 'warn',
details: chromaExists ? `data dir ${path.resolve(chromaDir)}` : 'Not yet initialized'
});
} catch (error: any) {
checks.push({
name: 'Chroma vector store',
status: 'warn',
details: error?.message || 'Unable to check Chroma directory'
});
}
if (options.json) {
console.log(JSON.stringify({ checks }, null, 2));
} else {
console.log('claude-mem doctor');
console.log('=================');
checks.forEach(printCheck);
}
rollingLog('info', 'doctor run completed', {
status: checks.map((c) => c.status)
});
}
+181
View File
@@ -0,0 +1,181 @@
import { OptionValues } from 'commander';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { getClaudePath } from '../shared/settings.js';
import { DatabaseManager } from '../services/sqlite/Database.js';
import { StreamingSessionStore } from '../services/sqlite/StreamingSessionStore.js';
import { migrations } from '../services/sqlite/migrations.js';
/**
* Generate a session title and subtitle from a user prompt
* CLI command that uses Agent SDK (like changelog.ts)
*
* Can be called in two modes:
* 1. Standalone: generate-title "user prompt" --json
* 2. With session: generate-title "user prompt" --session-id <id> --save
*/
export async function generateTitle(prompt: string, options: OptionValues): Promise<void> {
if (!prompt || prompt.trim().length === 0) {
console.error(JSON.stringify({
success: false,
error: 'Prompt is required'
}));
process.exit(1);
}
// If --session-id provided, validate that session exists
let streamingStore: StreamingSessionStore | null = null;
let sessionRecord = null;
if (options.sessionId) {
try {
const dbManager = DatabaseManager.getInstance();
for (const migration of migrations) {
dbManager.registerMigration(migration);
}
const db = await dbManager.initialize();
streamingStore = new StreamingSessionStore(db);
sessionRecord = streamingStore.getByClaudeSessionId(options.sessionId);
if (!sessionRecord) {
console.error(JSON.stringify({
success: false,
error: `Session not found: ${options.sessionId}`
}));
process.exit(1);
}
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: `Database error: ${error.message}`
}));
process.exit(1);
}
}
const systemPrompt = `You are a title and subtitle generator for claude-mem session metadata.
Your job is to analyze a user's request and generate:
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();
// If --save and we have a session, update the database
if (options.save && streamingStore && sessionRecord) {
try {
streamingStore.update(sessionRecord.id, {
title,
subtitle
});
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: `Failed to save title: ${error.message}`
}));
process.exit(1);
}
}
// Output format depends on options
if (options.json) {
console.log(JSON.stringify({
success: true,
title,
subtitle,
sessionId: sessionRecord?.claude_session_id
}, null, 2));
} else if (options.oneline) {
console.log(`${title} - ${subtitle}`);
} else {
console.log(title);
console.log(subtitle);
}
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error generating title'
}));
process.exit(1);
}
}
-146
View File
@@ -1,146 +0,0 @@
/**
* Hook command handlers for binary distribution
* These execute the actual hook logic embedded in the binary
*/
import { basename, sep } from 'path';
import { compress } from './compress.js';
import { loadContext } from './load-context.js';
/**
* Pre-compact hook handler
* Runs compression on the Claude Code transcript
*/
export async function preCompactHook(): Promise<void> {
try {
// Read hook data from stdin (Claude Code sends JSON)
let inputData = '';
// Set up stdin to read data
process.stdin.setEncoding('utf8');
// Collect all input data
for await (const chunk of process.stdin) {
inputData += chunk;
}
// Parse the JSON input
let transcriptPath: string | undefined;
if (inputData) {
try {
const hookData = JSON.parse(inputData);
transcriptPath = hookData.transcript_path;
} catch (parseError) {
// If JSON parsing fails, treat the input as a direct path
transcriptPath = inputData.trim();
}
}
// Fallback to environment variable or command line argument
if (!transcriptPath) {
transcriptPath = process.env.TRANSCRIPT_PATH || process.argv[2];
}
if (!transcriptPath) {
console.log('🗜️ Compressing session transcript...');
console.log('❌ No transcript path provided to pre-compact hook');
console.log('Hook data received:', inputData || 'none');
console.log('Environment TRANSCRIPT_PATH:', process.env.TRANSCRIPT_PATH || 'not set');
console.log('Command line args:', process.argv.slice(2));
return;
}
// Run compression with the transcript path
await compress(transcriptPath, { dryRun: false });
} catch (error: any) {
console.error('Pre-compact hook failed:', error.message);
process.exit(1);
}
}
/**
* Session-start hook handler
* Loads context for the new session
*/
export async function sessionStartHook(): Promise<void> {
try {
// Read hook data from stdin (Claude Code sends JSON)
let inputData = '';
// Set up stdin to read data
process.stdin.setEncoding('utf8');
// Collect all input data
for await (const chunk of process.stdin) {
inputData += chunk;
}
// Parse the JSON input to get the current working directory
let project: string | undefined;
if (inputData) {
try {
const hookData = JSON.parse(inputData);
// Extract project name from cwd if provided
if (hookData.cwd) {
project = basename(hookData.cwd);
}
} catch (parseError) {
// If JSON parsing fails, continue without project filtering
console.error('Failed to parse session-start hook data:', parseError);
}
}
// If no project from hook data, try to get from current working directory
if (!project) {
project = basename(process.cwd());
}
// Load context with session-start format and project filtering
await loadContext({ format: 'session-start', count: '10', project });
} catch (error: any) {
console.error('Session-start hook failed:', error.message);
process.exit(1);
}
}
/**
* Session-end hook handler
* Compresses session transcript when ending with /clear
*/
export async function sessionEndHook(): Promise<void> {
try {
// Read hook data from stdin (Claude Code sends JSON)
let inputData = '';
// Set up stdin to read data
process.stdin.setEncoding('utf8');
// Collect all input data
for await (const chunk of process.stdin) {
inputData += chunk;
}
// Parse the JSON input to check the reason for session end
if (inputData) {
try {
const hookData = JSON.parse(inputData);
// If reason is "clear", compress the session transcript before it's deleted
if (hookData.reason === 'clear' && hookData.transcript_path) {
console.log('🗜️ Compressing current session before /clear...');
await compress(hookData.transcript_path, { dryRun: false });
}
} catch (parseError) {
// If JSON parsing fails, log but don't fail the hook
console.error('Failed to parse hook data:', parseError);
}
}
console.log('Session ended successfully');
} catch (error: any) {
console.error('Session-end hook failed:', error.message);
process.exit(1);
}
}
-536
View File
@@ -1,536 +0,0 @@
#!/usr/bin/env node
import * as p from '@clack/prompts';
import path from 'path';
import fs from 'fs';
import os from 'os';
import chalk from 'chalk';
import { TranscriptCompressor } from '../core/compression/TranscriptCompressor.js';
import { TitleGenerator, TitleGenerationRequest } from '../core/titles/TitleGenerator.js';
import { getStorageProvider, needsMigration } from '../shared/storage.js';
interface ConversationMetadata {
sessionId: string;
timestamp: string;
messageCount: number;
branch?: string;
cwd: string;
fileSize: number;
}
interface ConversationItem extends ConversationMetadata {
filePath: string;
projectName: string;
parsedDate: Date;
relativeDate: string;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function formatRelativeDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
return `${Math.floor(diffDays / 365)}y ago`;
}
function parseTimestamp(timestamp: string, fallbackPath: string): Date {
try {
const parsed = new Date(timestamp);
if (!isNaN(parsed.getTime())) return parsed;
} catch {}
// Fallback: try to extract from filename
const match = fallbackPath.match(/(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})/);
if (match) {
const [_, year, month, day, hour, minute, second] = match;
return new Date(
parseInt(year),
parseInt(month) - 1,
parseInt(day),
parseInt(hour),
parseInt(minute),
parseInt(second)
);
}
// Last resort: file stats
const stats = fs.statSync(fallbackPath);
return stats.mtime;
}
function extractFirstUserMessage(filePath: string): string {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
for (const line of lines) {
try {
const message = JSON.parse(line);
if (message.type === 'user' && message.message?.content) {
const messageContent = message.message.content;
if (Array.isArray(messageContent)) {
const textContent = messageContent
.filter(item => item.type === 'text')
.map(item => item.text)
.join(' ');
if (textContent.trim()) return textContent.trim();
} else if (typeof messageContent === 'string') {
return messageContent.trim();
}
}
} catch {}
}
return 'Conversation'; // Fallback
} catch {
return 'Conversation'; // Fallback
}
}
async function loadImportedSessions(): Promise<Set<string>> {
try {
// Check if migration is needed and warn the user
if (await needsMigration()) {
console.warn('⚠️ JSONL to SQLite migration recommended. Run: claude-mem migrate-index');
}
const storage = await getStorageProvider();
// Use storage provider to get all session IDs efficiently
return await storage.getAllSessionIds();
} catch (error) {
console.warn('Failed to load imported sessions, proceeding with empty set:', error);
return new Set<string>();
}
}
async function scanConversations(): Promise<{ conversations: ConversationItem[]; skippedCount: number }> {
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
if (!fs.existsSync(claudeDir)) {
return { conversations: [], skippedCount: 0 };
}
const projects = fs.readdirSync(claudeDir)
.filter(dir => fs.statSync(path.join(claudeDir, dir)).isDirectory());
const conversations: ConversationItem[] = [];
const importedSessionIds = await loadImportedSessions();
let skippedCount = 0;
for (const project of projects) {
const projectDir = path.join(claudeDir, project);
const files = fs.readdirSync(projectDir)
.filter(file => file.endsWith('.jsonl'))
.map(file => path.join(projectDir, file));
for (const filePath of files) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
// Parse first line for metadata
const firstLine = JSON.parse(lines[0]);
const messageCount = lines.length;
const stats = fs.statSync(filePath);
const fileSize = stats.size;
const metadata: ConversationMetadata = {
sessionId: firstLine.sessionId || path.basename(filePath, '.jsonl'),
timestamp: firstLine.timestamp || stats.mtime.toISOString(),
messageCount,
branch: firstLine.branch,
cwd: firstLine.cwd || projectDir,
fileSize
};
// Skip if already imported
if (importedSessionIds.has(metadata.sessionId)) {
skippedCount++;
continue;
}
const projectName = path.basename(path.dirname(filePath));
const parsedDate = parseTimestamp(metadata.timestamp, filePath);
const relativeDate = formatRelativeDate(parsedDate);
conversations.push({
filePath,
...metadata,
projectName,
parsedDate,
relativeDate
});
} catch {}
}
}
return { conversations, skippedCount };
}
export async function importHistory(options: { verbose?: boolean; multi?: boolean } = {}) {
console.clear();
p.intro(chalk.bgCyan.black(' CLAUDE-MEM IMPORT '));
const s = p.spinner();
s.start('Scanning conversation history');
const { conversations, skippedCount } = await scanConversations();
if (conversations.length === 0) {
s.stop('No new conversations found');
const message = skippedCount > 0
? `All ${skippedCount} conversation${skippedCount === 1 ? ' is' : 's are'} already imported.`
: 'No conversations found.';
p.outro(chalk.yellow(message));
return;
}
// Sort by date (newest first)
conversations.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
const statusMessage = skippedCount > 0
? `Found ${conversations.length} new conversation${conversations.length === 1 ? '' : 's'} (${skippedCount} already imported)`
: `Found ${conversations.length} new conversation${conversations.length === 1 ? '' : 's'}`;
s.stop(statusMessage);
// Group conversations by project for better organization
const projectGroups = conversations.reduce((acc, conv) => {
if (!acc[conv.projectName]) acc[conv.projectName] = [];
acc[conv.projectName].push(conv);
return acc;
}, {} as Record<string, ConversationItem[]>);
// Create selection options
const importMode = await p.select({
message: 'How would you like to import?',
options: [
{ value: 'browse', label: 'Browse by Project', hint: 'Select project then conversations' },
{ value: 'project', label: 'Import Entire Project', hint: 'Select project and import all conversations' },
{ value: 'recent', label: 'Recent Conversations', hint: 'Import most recent across all projects' },
{ value: 'search', label: 'Search', hint: 'Search for specific conversations' }
]
});
if (p.isCancel(importMode)) {
p.cancel('Import cancelled');
return;
}
let selectedConversations: ConversationItem[] = [];
if (importMode === 'browse') {
// Project selection
const projectOptions = Object.entries(projectGroups)
.sort((a, b) => b[1][0].parsedDate.getTime() - a[1][0].parsedDate.getTime())
.map(([project, convs]) => ({
value: project,
label: project,
hint: `${convs.length} conversation${convs.length === 1 ? '' : 's'}, latest: ${convs[0].relativeDate}`
}));
const selectedProject = await p.select({
message: 'Select a project',
options: projectOptions
});
if (p.isCancel(selectedProject)) {
p.cancel('Import cancelled');
return;
}
const projectConvs = projectGroups[selectedProject as string];
// Ask about title generation
const generateTitles = await p.confirm({
message: 'Would you like to generate titles for easier browsing?',
initialValue: false
});
if (p.isCancel(generateTitles)) {
p.cancel('Import cancelled');
return;
}
if (generateTitles) {
await processTitleGeneration(projectConvs, selectedProject as string);
}
// Conversation selection within project
const titleGenerator = new TitleGenerator();
const convOptions = projectConvs.map(conv => {
const title = titleGenerator.getTitleForSession(conv.sessionId);
const displayTitle = title ? `"${title}" • ` : '';
return {
value: conv.sessionId,
label: `${displayTitle}${conv.relativeDate}${conv.messageCount} messages • ${formatFileSize(conv.fileSize)}`,
hint: conv.branch ? `branch: ${conv.branch}` : undefined
};
});
if (options.multi) {
const selected = await p.multiselect({
message: `Select conversations from ${selectedProject} (Space to select, Enter to confirm)`,
options: convOptions,
required: false
});
if (p.isCancel(selected)) {
p.cancel('Import cancelled');
return;
}
const selectedIds = selected as string[];
selectedConversations = projectConvs.filter(c => selectedIds.includes(c.sessionId));
} else {
// Single select with continuous import
let continueImporting = true;
const importedInSession = new Set<string>();
while (continueImporting && projectConvs.length > importedInSession.size) {
const availableConvs = projectConvs.filter(c => !importedInSession.has(c.sessionId));
if (availableConvs.length === 0) break;
const titleGenerator = new TitleGenerator();
const convOptions = availableConvs.map(conv => {
const title = titleGenerator.getTitleForSession(conv.sessionId);
const displayTitle = title ? `"${title}" • ` : '';
return {
value: conv.sessionId,
label: `${displayTitle}${conv.relativeDate}${conv.messageCount} messages • ${formatFileSize(conv.fileSize)}`,
hint: conv.branch ? `branch: ${conv.branch}` : undefined
};
});
const selected = await p.select({
message: `Select a conversation (${importedInSession.size}/${projectConvs.length} imported)`,
options: [
...convOptions,
{ value: 'done', label: '✅ Done importing', hint: 'Exit import mode' }
]
});
if (p.isCancel(selected) || selected === 'done') {
continueImporting = false;
break;
}
const conv = availableConvs.find(c => c.sessionId === selected);
if (conv) {
selectedConversations = [conv];
await processImport(selectedConversations, options.verbose);
importedInSession.add(conv.sessionId);
}
}
if (importedInSession.size > 0) {
p.outro(chalk.green(`✅ Imported ${importedInSession.size} conversation${importedInSession.size === 1 ? '' : 's'}`));
} else {
p.outro(chalk.yellow('No conversations imported'));
}
return;
}
} else if (importMode === 'project') {
// Project selection for importing entire project
const projectOptions = Object.entries(projectGroups)
.sort((a, b) => b[1][0].parsedDate.getTime() - a[1][0].parsedDate.getTime())
.map(([project, convs]) => ({
value: project,
label: project,
hint: `${convs.length} conversation${convs.length === 1 ? '' : 's'}, latest: ${convs[0].relativeDate}`
}));
const selectedProject = await p.select({
message: 'Select a project to import all conversations',
options: projectOptions
});
if (p.isCancel(selectedProject)) {
p.cancel('Import cancelled');
return;
}
const projectConvs = projectGroups[selectedProject as string];
// Ask about title generation
const generateTitles = await p.confirm({
message: 'Would you like to generate titles for easier browsing?',
initialValue: false
});
if (p.isCancel(generateTitles)) {
p.cancel('Import cancelled');
return;
}
if (generateTitles) {
await processTitleGeneration(projectConvs, selectedProject as string);
}
const confirm = await p.confirm({
message: `Import all ${projectConvs.length} conversation${projectConvs.length === 1 ? '' : 's'} from ${selectedProject}?`
});
if (p.isCancel(confirm) || !confirm) {
p.cancel('Import cancelled');
return;
}
selectedConversations = projectConvs;
} else if (importMode === 'recent') {
const limit = await p.text({
message: 'How many recent conversations?',
placeholder: '10',
initialValue: '10',
validate: (value) => {
const num = parseInt(value);
if (isNaN(num) || num < 1) return 'Please enter a valid number';
if (num > conversations.length) return `Only ${conversations.length} available`;
}
});
if (p.isCancel(limit)) {
p.cancel('Import cancelled');
return;
}
const count = parseInt(limit as string);
selectedConversations = conversations.slice(0, count);
} else if (importMode === 'search') {
const searchTerm = await p.text({
message: 'Search conversations (project name or session ID)',
placeholder: 'Enter search term'
});
if (p.isCancel(searchTerm)) {
p.cancel('Import cancelled');
return;
}
const term = (searchTerm as string).toLowerCase();
const matches = conversations.filter(c =>
c.projectName.toLowerCase().includes(term) ||
c.sessionId.toLowerCase().includes(term) ||
(c.branch && c.branch.toLowerCase().includes(term))
);
if (matches.length === 0) {
p.outro(chalk.yellow('No matching conversations found'));
return;
}
const titleGenerator = new TitleGenerator();
const matchOptions = matches.map(conv => {
const title = titleGenerator.getTitleForSession(conv.sessionId);
const displayTitle = title ? `"${title}" • ` : '';
return {
value: conv.sessionId,
label: `${displayTitle}${conv.projectName}${conv.relativeDate}${conv.messageCount} msgs`,
hint: formatFileSize(conv.fileSize)
};
});
const selected = await p.multiselect({
message: `Found ${matches.length} matches. Select to import:`,
options: matchOptions,
required: false
});
if (p.isCancel(selected)) {
p.cancel('Import cancelled');
return;
}
const selectedIds = selected as string[];
selectedConversations = matches.filter(c => selectedIds.includes(c.sessionId));
}
// Process the import
if (selectedConversations.length > 0) {
await processImport(selectedConversations, options.verbose);
p.outro(chalk.green(`✅ Successfully imported ${selectedConversations.length} conversation${selectedConversations.length === 1 ? '' : 's'}`));
} else {
p.outro(chalk.yellow('No conversations selected for import'));
}
}
async function processTitleGeneration(conversations: ConversationItem[], projectName: string) {
const titleGenerator = new TitleGenerator();
const existingTitles = titleGenerator.getExistingTitles();
// Filter conversations that don't have titles yet
const conversationsNeedingTitles = conversations.filter(conv => !existingTitles.has(conv.sessionId));
if (conversationsNeedingTitles.length === 0) {
p.note('All conversations already have titles!', 'Title Generation');
return;
}
const s = p.spinner();
s.start(`Generating titles for ${conversationsNeedingTitles.length} conversations...`);
const requests: TitleGenerationRequest[] = conversationsNeedingTitles.map(conv => ({
sessionId: conv.sessionId,
projectName: projectName,
firstMessage: extractFirstUserMessage(conv.filePath)
}));
try {
await titleGenerator.batchGenerateTitles(requests);
s.stop(`✅ Generated ${conversationsNeedingTitles.length} titles`);
} catch (error) {
s.stop(`❌ Failed to generate titles`);
console.error(chalk.red(`Error: ${error}`));
}
}
async function processImport(conversations: ConversationItem[], verbose?: boolean) {
const s = p.spinner();
for (let i = 0; i < conversations.length; i++) {
const conv = conversations[i];
const progress = conversations.length > 1 ? `[${i + 1}/${conversations.length}] ` : '';
s.start(`${progress}Importing ${conv.projectName} (${conv.relativeDate})`);
try {
// Extract project name from the conversation's cwd field
const projectName = path.basename(conv.cwd);
// Use TranscriptCompressor to process
const compressor = new TranscriptCompressor();
await compressor.compress(conv.filePath, conv.sessionId, projectName);
s.stop(`${progress}Imported ${conv.projectName} (${conv.messageCount} messages)`);
if (verbose) {
p.note(`Session: ${conv.sessionId}\nSize: ${formatFileSize(conv.fileSize)}\nBranch: ${conv.branch || 'main'}`, 'Details');
}
} catch (error) {
s.stop(`${progress}Failed to import ${conv.projectName}`);
if (verbose) {
console.error(chalk.red(`Error: ${error}`));
}
}
}
}
+191 -735
View File
File diff suppressed because it is too large Load Diff
+177 -49
View File
@@ -2,7 +2,7 @@ import { OptionValues } from 'commander';
import fs from 'fs';
import { join } from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
import {
import {
createCompletionMessage,
createContextualError,
createUserFriendlyError,
@@ -10,7 +10,10 @@ import {
outputSessionStartContent
} from '../prompts/templates/context/ContextTemplates.js';
import { getStorageProvider, needsMigration } from '../shared/storage.js';
import { MemoryRow, OverviewRow, SessionRow } from '../services/sqlite/types.js';
import { MemoryRow, OverviewRow } from '../services/sqlite/types.js';
import { createStores } from '../services/sqlite/index.js';
import { getRollingSettings } from '../shared/rolling-settings.js';
import { rollingLog } from '../shared/rolling-log.js';
interface TrashStatus {
folderCount: number;
@@ -19,6 +22,45 @@ interface TrashStatus {
isEmpty: boolean;
}
function formatDateHeader(date = new Date()): string {
return date.toLocaleString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
});
}
function wordWrap(text: string, maxWidth: number, prefix: string): string {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = prefix;
const continuationPrefix = ' '.repeat(prefix.length);
for (const word of words) {
const needsSpace = currentLine !== prefix && currentLine !== continuationPrefix;
const testLine = currentLine + (needsSpace ? ' ' : '') + word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine.trim()) {
lines.push(currentLine);
}
currentLine = continuationPrefix + word;
}
}
if (currentLine.trim()) {
lines.push(currentLine);
}
return lines.join('\n');
}
function buildProjectMatcher(projectName: string): (value?: string) => boolean {
const aliases = new Set<string>();
aliases.add(projectName);
@@ -67,6 +109,124 @@ function getTrashStatus(): TrashStatus {
return { folderCount, fileCount, totalSize, isEmpty: false };
}
async function renderRollingSessionStart(projectOverride?: string): Promise<void> {
const settings = getRollingSettings();
if (!settings.sessionStartEnabled) {
console.log('Rolling session-start output disabled in settings.');
rollingLog('info', 'session-start output skipped (disabled)', {
project: projectOverride
});
return;
}
const stores = await createStores();
const projectName = projectOverride || PathDiscovery.getCurrentProjectName();
// Get all overviews for this project (oldest to newest)
const allOverviews = stores.overviews.getAllForProject(projectName);
// Limit to last 10 overviews
const recentOverviews = allOverviews.slice(-10);
// If no data at all, show friendly message
if (recentOverviews.length === 0) {
console.log('===============================================================================');
console.log(`What's new | ${formatDateHeader()}`);
console.log('===============================================================================');
console.log('No previous sessions found for this project.');
console.log('Start working and claude-mem will automatically capture context for future sessions.');
console.log('===============================================================================');
const trashStatus = getTrashStatus();
if (!trashStatus.isEmpty) {
const formattedSize = formatSize(trashStatus.totalSize);
console.log(
`🗑️ Trash ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} use \`claude-mem restore\``
);
console.log('===============================================================================');
}
return;
}
// Output header
console.log('===============================================================================');
console.log(`What's new | ${formatDateHeader()}`);
console.log('===============================================================================');
// Output each overview with timestamp, memory names, and files touched (oldest to newest)
recentOverviews.forEach((overview) => {
const date = new Date(overview.created_at);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, '0');
const ampm = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
console.log(`[${year}-${month}-${day} at ${displayHours}:${minutes} ${ampm}]`);
// Get memories for this session to show titles, subtitles, files, and keywords
const sessionMemories = stores.memories.getBySessionId(overview.session_id);
// Extract memory titles and subtitles
const memories = sessionMemories
.map(m => ({ title: m.title, subtitle: m.subtitle }))
.filter(m => m.title);
// Extract unique files touched across all memories
const allFilesTouched = new Set<string>();
const allKeywords = new Set<string>();
sessionMemories.forEach(m => {
if (m.files_touched) {
try {
const files = JSON.parse(m.files_touched);
if (Array.isArray(files)) {
files.forEach(f => allFilesTouched.add(f));
}
} catch (e) {
// Skip invalid JSON
}
}
if (m.keywords) {
// Keywords are comma-separated
m.keywords.split(',').forEach(k => allKeywords.add(k.trim()));
}
});
console.log('');
// Always show overview content
console.log(wordWrap(overview.content, 80, ''));
// Display files touched if any
if (allFilesTouched.size > 0) {
console.log('');
console.log(wordWrap(`- ${Array.from(allFilesTouched).join(', ')}`, 80, ''));
}
// Display keywords/tags if any
if (allKeywords.size > 0) {
console.log('');
console.log(wordWrap(`Tags: ${Array.from(allKeywords).join(', ')}`, 80, ''));
}
console.log('');
});
console.log('===============================================================================');
const trashStatus = getTrashStatus();
if (!trashStatus.isEmpty) {
const formattedSize = formatSize(trashStatus.totalSize);
console.log(
`🗑️ Trash ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} use \`claude-mem restore\``
);
console.log('===============================================================================');
}
}
export async function loadContext(options: OptionValues = {}): Promise<void> {
try {
// Check if migration is needed and warn the user
@@ -84,7 +244,6 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
// SQLite implementation - fetch data using storage provider
let recentMemories: MemoryRow[] = [];
let recentOverviews: OverviewRow[] = [];
let recentSessions: SessionRow[] = [];
// Auto-detect current project for session-start format if no project specified
let projectToUse = options.project;
@@ -92,14 +251,19 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
projectToUse = PathDiscovery.getCurrentProjectName();
}
if (options.format === 'session-start') {
await renderRollingSessionStart(projectToUse);
return;
}
const overviewLimit = options.format === 'json' ? 5 : 3;
if (projectToUse) {
recentMemories = await storage.getRecentMemoriesForProject(projectToUse, 10);
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, options.format === 'session-start' ? 5 : 3);
recentSessions = await storage.getRecentSessionsForProject(projectToUse, 5);
recentOverviews = await storage.getRecentOverviewsForProject(projectToUse, overviewLimit);
} else {
recentMemories = await storage.getRecentMemories(10);
recentOverviews = await storage.getRecentOverviews(options.format === 'session-start' ? 5 : 3);
recentSessions = await storage.getRecentSessions(5);
recentOverviews = await storage.getRecentOverviews(overviewLimit);
}
// Convert SQLite rows to JSONL format for compatibility with existing output functions
@@ -122,48 +286,12 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
timestamp: row.created_at
}));
const sessionsAsJSON = recentSessions.map(row => ({
type: 'session',
session_id: row.session_id,
project: row.project,
timestamp: row.created_at
}));
// If no data found, show appropriate messages
if (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0 && sessionsAsJSON.length === 0) {
if (options.format === 'session-start') {
console.log(createContextualError('NO_MEMORIES', projectToUse || 'this project'));
}
if (memoriesAsJSON.length === 0 && overviewsAsJSON.length === 0) {
return;
}
// Use the same output logic as the original implementation
if (options.format === 'session-start') {
// Combine them for the display
const recentObjects = [...sessionsAsJSON, ...memoriesAsJSON, ...overviewsAsJSON];
// Find most recent timestamp for last session info
let lastSessionTime = 'recently';
const timestamps = recentObjects
.map(obj => {
return obj.timestamp ? new Date(obj.timestamp) : null;
})
.filter(date => date !== null)
.sort((a, b) => b.getTime() - a.getTime());
if (timestamps.length > 0) {
lastSessionTime = formatTimeAgo(timestamps[0]);
}
// Use dual-stream output for session start formatting
outputSessionStartContent({
projectName: projectToUse || 'your project',
memoryCount: memoriesAsJSON.length,
lastSessionTime,
recentObjects
});
} else if (options.format === 'json') {
if (options.format === 'json') {
// For JSON format, combine last 10 of each type
const recentObjects = [...memoriesAsJSON, ...overviewsAsJSON];
console.log(JSON.stringify(recentObjects));
@@ -189,7 +317,7 @@ export async function loadContext(options: OptionValues = {}): Promise<void> {
const trashStatus = getTrashStatus();
if (!trashStatus.isEmpty) {
const formattedSize = formatSize(trashStatus.totalSize);
console.log(`🗑️ Trash ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} use \`$ claude-mem restore\``);
console.log(`🗑️ Trash ${trashStatus.folderCount} folders | ${trashStatus.fileCount} files | ${formattedSize} use \`claude-mem restore\``);
console.log('');
}
}
@@ -276,10 +404,10 @@ async function loadContextFromJSONL(options: OptionValues = {}): Promise<void> {
}
if (options.format === 'session-start') {
// Get last 10 memories and last 5 overviews for session-start
// Get last 10 memories and last 10 overviews for session-start
const recentMemories = filteredMemories.slice(-10);
const recentOverviews = filteredOverviews.slice(-5);
const recentSessions = filteredSessions.slice(-5);
const recentOverviews = filteredOverviews.slice(-10);
const recentSessions = filteredSessions.slice(-10);
// Combine them for the display
const recentObjects = [...recentSessions, ...recentMemories, ...recentOverviews];
-300
View File
@@ -1,300 +0,0 @@
import { OptionValues } from 'commander';
import fs from 'fs';
import path from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
import {
createStores,
SessionInput,
MemoryInput,
OverviewInput,
DiagnosticInput,
normalizeTimestamp
} from '../services/sqlite/index.js';
interface MigrationStats {
totalLines: number;
skippedLines: number;
invalidJson: number;
sessionsCreated: number;
memoriesCreated: number;
overviewsCreated: number;
diagnosticsCreated: number;
orphanedOverviews: number;
orphanedMemories: number;
}
/**
* Migrate claude-mem index from JSONL to SQLite
*/
export async function migrateIndex(options: OptionValues = {}): Promise<void> {
const pathDiscovery = PathDiscovery.getInstance();
const indexPath = pathDiscovery.getIndexPath();
const backupPath = `${indexPath}.backup-${Date.now()}`;
console.log('🔄 Starting JSONL to SQLite migration...');
console.log(`📁 Index file: ${indexPath}`);
// Check if JSONL file exists
if (!fs.existsSync(indexPath)) {
console.log('️ No JSONL index file found - nothing to migrate');
return;
}
try {
// Initialize SQLite database and stores
console.log('🏗️ Initializing SQLite database...');
const stores = await createStores();
// Check if we already have data in SQLite
const existingSessions = stores.sessions.count();
if (existingSessions > 0 && !options.force) {
console.log(`⚠️ SQLite database already contains ${existingSessions} sessions.`);
console.log(' Use --force to migrate anyway (will skip duplicates)');
return;
}
// Create backup of JSONL file
if (!options.keepJsonl) {
console.log(`💾 Creating backup: ${path.basename(backupPath)}`);
fs.copyFileSync(indexPath, backupPath);
}
// Read and parse JSONL file
console.log('📖 Reading JSONL index file...');
const content = fs.readFileSync(indexPath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
const stats: MigrationStats = {
totalLines: lines.length,
skippedLines: 0,
invalidJson: 0,
sessionsCreated: 0,
memoriesCreated: 0,
overviewsCreated: 0,
diagnosticsCreated: 0,
orphanedOverviews: 0,
orphanedMemories: 0
};
console.log(`📝 Processing ${stats.totalLines} lines...`);
// Parse all lines first
const records: any[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
try {
// Skip lines that don't look like JSON
if (!line.trim().startsWith('{')) {
stats.skippedLines++;
continue;
}
const record = JSON.parse(line);
if (record && typeof record === 'object') {
records.push({ ...record, _lineNumber: i + 1 });
} else {
stats.skippedLines++;
}
} catch (error) {
stats.invalidJson++;
console.warn(`⚠️ Invalid JSON at line ${i + 1}: ${line.substring(0, 50)}...`);
}
}
console.log(`✅ Parsed ${records.length} valid records`);
// Group records by type
const sessions = records.filter(r => r.type === 'session');
const memories = records.filter(r => r.type === 'memory');
const overviews = records.filter(r => r.type === 'overview');
const diagnostics = records.filter(r => r.type === 'diagnostic');
const unknown = records.filter(r => !['session', 'memory', 'overview', 'diagnostic'].includes(r.type));
if (unknown.length > 0) {
console.log(`⚠️ Found ${unknown.length} records with unknown types - will skip`);
stats.skippedLines += unknown.length;
}
// Create session tracking
const sessionIds = new Set(sessions.map(s => s.session_id));
const orphanedSessionIds = new Set();
// Migrate sessions first
console.log('💾 Migrating sessions...');
for (const sessionData of sessions) {
try {
const { isoString } = normalizeTimestamp(sessionData.timestamp);
const sessionInput: SessionInput = {
session_id: sessionData.session_id,
project: sessionData.project || 'unknown',
created_at: isoString,
source: 'legacy-jsonl'
};
// Skip if session already exists (when using --force)
if (!stores.sessions.has(sessionInput.session_id)) {
stores.sessions.create(sessionInput);
stats.sessionsCreated++;
}
} catch (error) {
console.warn(`⚠️ Failed to migrate session ${sessionData.session_id}: ${error}`);
}
}
// Migrate memories
console.log('🧠 Migrating memories...');
for (const memoryData of memories) {
try {
const { isoString } = normalizeTimestamp(memoryData.timestamp);
// Check if session exists, create orphaned session if needed
if (!sessionIds.has(memoryData.session_id)) {
if (!orphanedSessionIds.has(memoryData.session_id)) {
orphanedSessionIds.add(memoryData.session_id);
const orphanedSession: SessionInput = {
session_id: memoryData.session_id,
project: memoryData.project || 'unknown',
created_at: isoString,
source: 'legacy-jsonl'
};
if (!stores.sessions.has(orphanedSession.session_id)) {
stores.sessions.create(orphanedSession);
stats.sessionsCreated++;
stats.orphanedMemories++;
}
}
}
const memoryInput: MemoryInput = {
session_id: memoryData.session_id,
text: memoryData.text || '',
document_id: memoryData.document_id,
keywords: memoryData.keywords,
created_at: isoString,
project: memoryData.project || 'unknown',
archive_basename: memoryData.archive,
origin: 'transcript'
};
// Skip duplicate document_ids
if (!memoryInput.document_id || !stores.memories.hasDocumentId(memoryInput.document_id)) {
stores.memories.create(memoryInput);
stats.memoriesCreated++;
}
} catch (error) {
console.warn(`⚠️ Failed to migrate memory ${memoryData.document_id}: ${error}`);
}
}
// Migrate overviews
console.log('📋 Migrating overviews...');
for (const overviewData of overviews) {
try {
const { isoString } = normalizeTimestamp(overviewData.timestamp);
// Check if session exists, create orphaned session if needed
if (!sessionIds.has(overviewData.session_id)) {
if (!orphanedSessionIds.has(overviewData.session_id)) {
orphanedSessionIds.add(overviewData.session_id);
const orphanedSession: SessionInput = {
session_id: overviewData.session_id,
project: overviewData.project || 'unknown',
created_at: isoString,
source: 'legacy-jsonl'
};
if (!stores.sessions.has(orphanedSession.session_id)) {
stores.sessions.create(orphanedSession);
stats.sessionsCreated++;
stats.orphanedOverviews++;
}
}
}
const overviewInput: OverviewInput = {
session_id: overviewData.session_id,
content: overviewData.content || '',
created_at: isoString,
project: overviewData.project || 'unknown',
origin: 'claude'
};
stores.overviews.upsert(overviewInput);
stats.overviewsCreated++;
} catch (error) {
console.warn(`⚠️ Failed to migrate overview ${overviewData.session_id}: ${error}`);
}
}
// Migrate diagnostics
console.log('🩺 Migrating diagnostics...');
for (const diagnosticData of diagnostics) {
try {
const { isoString } = normalizeTimestamp(diagnosticData.timestamp);
const diagnosticInput: DiagnosticInput = {
session_id: diagnosticData.session_id,
message: diagnosticData.message || '',
severity: 'warn',
created_at: isoString,
project: diagnosticData.project || 'unknown',
origin: 'compressor'
};
stores.diagnostics.create(diagnosticInput);
stats.diagnosticsCreated++;
} catch (error) {
console.warn(`⚠️ Failed to migrate diagnostic: ${error}`);
}
}
// Print migration summary
console.log('\n✅ Migration completed successfully!');
console.log('\n📊 Migration Summary:');
console.log(` Total lines processed: ${stats.totalLines}`);
console.log(` Skipped lines: ${stats.skippedLines}`);
console.log(` Invalid JSON lines: ${stats.invalidJson}`);
console.log(` Sessions created: ${stats.sessionsCreated}`);
console.log(` Memories created: ${stats.memoriesCreated}`);
console.log(` Overviews created: ${stats.overviewsCreated}`);
console.log(` Diagnostics created: ${stats.diagnosticsCreated}`);
if (stats.orphanedOverviews > 0 || stats.orphanedMemories > 0) {
console.log(` Orphaned records (sessions synthesized): ${stats.orphanedOverviews + stats.orphanedMemories}`);
}
// Archive or keep JSONL file
if (options.keepJsonl) {
console.log(`\n💾 Original JSONL file preserved: ${indexPath}`);
console.log(` SQLite database is now the primary index`);
} else {
const archiveDir = path.join(pathDiscovery.getDataDirectory(), 'archive', 'legacy');
fs.mkdirSync(archiveDir, { recursive: true });
const archivedPath = path.join(archiveDir, `claude-mem-index-${Date.now()}.jsonl`);
fs.renameSync(indexPath, archivedPath);
console.log(`\n📦 Original JSONL file archived: ${path.basename(archivedPath)}`);
console.log(` Backup available at: ${path.basename(backupPath)}`);
}
console.log('\n🎉 Migration complete! You can now use claude-mem with SQLite backend.');
console.log(' Run `claude-mem load-context` to verify the migration worked.');
} catch (error) {
console.error('\n❌ Migration failed:', error);
// Restore backup if we created one
if (fs.existsSync(backupPath) && !fs.existsSync(indexPath)) {
console.log('🔄 Restoring backup...');
fs.renameSync(backupPath, indexPath);
}
process.exit(1);
}
}
-90
View File
@@ -1,90 +0,0 @@
import { OptionValues } from 'commander';
import { appendFileSync } from 'fs';
import { PathDiscovery } from '../services/path-discovery.js';
import { getStorageProvider, needsMigration } from '../shared/storage.js';
/**
* Generates a descriptive session ID from the message content
* Takes first few meaningful words and creates a readable identifier
*/
function generateSessionId(message: string): string {
// Remove punctuation and split into words
const words = message
.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 2); // Skip short words like 'a', 'is', 'to'
// Take first 3-4 meaningful words, max 30 chars
const sessionWords = words.slice(0, 4).join('-');
const truncated = sessionWords.length > 30 ? sessionWords.substring(0, 27) + '...' : sessionWords;
// Add timestamp suffix to ensure uniqueness
const timestamp = new Date().toISOString().substring(11, 19).replace(/:/g, '');
return `${truncated}-${timestamp}`;
}
/**
* Save command - stores a message using the configured storage provider
*/
export async function save(message: string, options: OptionValues = {}): Promise<void> {
// Debug: Log what we receive
appendFileSync('/Users/alexnewman/.claude-mem/save-debug.log',
`[${new Date().toISOString()}] Received message: "${message}" (type: ${typeof message}, length: ${message?.length})\n`,
'utf8');
if (!message || message.trim() === '') {
console.error('Error: Message is required');
process.exit(1);
}
const timestamp = new Date().toISOString();
const projectName = PathDiscovery.getCurrentProjectName();
const sessionId = generateSessionId(message);
const documentId = `${projectName}_${sessionId}_overview`;
try {
// Check if migration is needed
if (await needsMigration()) {
console.warn('⚠️ JSONL to SQLite migration recommended. Run: claude-mem migrate-index');
}
// Get storage provider (SQLite preferred, JSONL fallback)
const storage = await getStorageProvider();
// Ensure session exists or create it
if (!await storage.hasSession(sessionId)) {
await storage.createSession({
session_id: sessionId,
project: projectName,
created_at: timestamp,
source: 'save'
});
}
// Upsert the overview
await storage.upsertOverview({
session_id: sessionId,
content: message,
created_at: timestamp,
project: projectName,
origin: 'manual'
});
// Return JSON response for hook compatibility
console.log(JSON.stringify({
success: true,
document_id: documentId,
session_id: sessionId,
project: projectName,
timestamp: timestamp,
backend: storage.backend,
suppressOutput: true
}));
} catch (error) {
console.error('Error saving message:', error);
process.exit(1);
}
}
+65 -28
View File
@@ -3,6 +3,9 @@ import { join, resolve, dirname } from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { PathDiscovery } from '../services/path-discovery.js';
import { DatabaseManager } from '../services/sqlite/Database.js';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import chalk from 'chalk';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -10,13 +13,14 @@ export async function status(): Promise<void> {
console.log('🔍 Claude Memory System Status Check');
console.log('=====================================\n');
console.log('📂 Installed Hook Scripts:');
console.log('📂 Runtime Hook Scripts (installed from hook-templates/):');
const pathDiscovery = PathDiscovery.getInstance();
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
const stopScript = join(runtimeHooksDir, 'stop.js');
const userPromptScript = join(runtimeHooksDir, 'user-prompt-submit.js');
const postToolScript = join(runtimeHooksDir, 'post-tool-use.js');
const checkScript = (path: string, name: string) => {
if (existsSync(path)) {
console.log(`${name}: Found at ${path}`);
@@ -24,10 +28,11 @@ export async function status(): Promise<void> {
console.log(`${name}: Not found at ${path}`);
}
};
checkScript(preCompactScript, 'pre-compact.js');
checkScript(sessionStartScript, 'session-start.js');
checkScript(sessionEndScript, 'session-end.js');
checkScript(stopScript, 'stop.js');
checkScript(userPromptScript, 'user-prompt-submit.js');
checkScript(postToolScript, 'post-tool-use.js');
console.log('');
@@ -43,28 +48,35 @@ export async function status(): Promise<void> {
try {
const settings = JSON.parse(readFileSync(path, 'utf8'));
const hasPreCompact = settings.hooks?.PreCompact?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('pre-compact.js') || hook.command?.includes('claude-mem')
)
);
const hasSessionStart = settings.hooks?.SessionStart?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('session-start.js') || hook.command?.includes('claude-mem')
)
);
const hasSessionEnd = settings.hooks?.SessionEnd?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('session-end.js') || hook.command?.includes('claude-mem')
const hasStop = settings.hooks?.Stop?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('stop.js') || hook.command?.includes('claude-mem')
)
);
console.log(` PreCompact: ${hasPreCompact ? '✅' : '❌'}`);
const hasUserPrompt = settings.hooks?.UserPromptSubmit?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('user-prompt-submit.js') || hook.command?.includes('claude-mem')
)
);
const hasPostTool = settings.hooks?.PostToolUse?.some((matcher: any) =>
matcher.hooks?.some((hook: any) =>
hook.command?.includes('post-tool-use.js') || hook.command?.includes('claude-mem')
)
);
console.log(` SessionStart: ${hasSessionStart ? '✅' : '❌'}`);
console.log(` SessionEnd: ${hasSessionEnd ? '✅' : '❌'}`);
console.log(` Stop: ${hasStop ? '✅' : '❌'}`);
console.log(` UserPromptSubmit: ${hasUserPrompt ? '✅' : '❌'}`);
console.log(` PostToolUse: ${hasPostTool ? '✅' : '❌'}`);
} catch (error: any) {
console.log(` ⚠️ Could not parse settings`);
@@ -136,7 +148,32 @@ export async function status(): Promise<void> {
console.log(' ✅ Storage backend: Chroma MCP');
console.log(` 📍 Data location: ${pathDiscovery.getChromaDirectory()}`);
console.log(' 🔍 Features: Vector search, semantic similarity, document storage');
console.log('');
console.log('🤖 Claude Agent SDK Sessions:');
try {
const dbManager = DatabaseManager.getInstance();
await dbManager.initialize();
const sessionStore = new SessionStore();
const sessions = sessionStore.getAll();
if (sessions.length === 0) {
console.log(chalk.gray(' No active sessions'));
} else {
const activeCount = sessions.filter(s => {
const daysSinceUse = (Date.now() - s.last_used_epoch) / (1000 * 60 * 60 * 24);
return daysSinceUse < 7;
}).length;
console.log(` 📊 Total sessions: ${sessions.length}`);
console.log(` ✅ Active (< 7 days): ${activeCount}`);
console.log(chalk.dim(` 💡 View details: claude-mem sessions list`));
}
} catch (error) {
console.log(chalk.gray(' ⚠️ Could not load session info'));
}
console.log('');
console.log('📊 Summary:');
@@ -149,15 +186,15 @@ export async function status(): Promise<void> {
try {
if (existsSync(globalPath)) {
const settings = JSON.parse(readFileSync(globalPath, 'utf8'));
if (settings.hooks?.PreCompact || settings.hooks?.SessionStart || settings.hooks?.SessionEnd) {
if (settings.hooks?.SessionStart || settings.hooks?.Stop || settings.hooks?.PostToolUse) {
isInstalled = true;
installLocation = 'Global';
}
}
if (existsSync(projectPath)) {
const settings = JSON.parse(readFileSync(projectPath, 'utf8'));
if (settings.hooks?.PreCompact || settings.hooks?.SessionStart || settings.hooks?.SessionEnd) {
if (settings.hooks?.SessionStart || settings.hooks?.Stop || settings.hooks?.PostToolUse) {
isInstalled = true;
installLocation = installLocation === 'Global' ? 'Global + Project' : 'Project';
}
+154
View File
@@ -0,0 +1,154 @@
import { OptionValues } from 'commander';
import { spawnSync } from 'child_process';
import { createStores } from '../services/sqlite/index.js';
/**
* Store a memory to all three storage layers
* Called by SDK via bash during streaming memory capture
*/
export async function storeMemory(options: OptionValues): Promise<void> {
const { id, project, session, date, title, subtitle, facts, concepts, files } = options;
// Validate required fields
if (!id || !project || !session || !date) {
console.error('Error: All fields required: --id, --project, --session, --date');
process.exit(1);
}
// Validate hierarchical fields (required for v2 format)
if (!title || !subtitle || !facts) {
console.error('Error: Hierarchical format required: --title, --subtitle, --facts');
process.exit(1);
}
try {
const stores = await createStores();
const timestamp = new Date().toISOString();
// Ensure session exists
const sessionExists = await stores.sessions.has(session);
if (!sessionExists) {
await stores.sessions.create({
session_id: session,
project,
created_at: timestamp,
source: 'save'
});
}
// Parse JSON arrays if provided as strings
let factsArray: string | undefined;
let conceptsArray: string | undefined;
let filesArray: string | undefined;
try {
factsArray = facts ? JSON.stringify(JSON.parse(facts)) : undefined;
} catch (e) {
factsArray = facts; // Store as-is if not valid JSON
}
try {
conceptsArray = concepts ? JSON.stringify(JSON.parse(concepts)) : undefined;
} catch (e) {
conceptsArray = concepts; // Store as-is if not valid JSON
}
try {
filesArray = files ? JSON.stringify(JSON.parse(files)) : undefined;
} catch (e) {
filesArray = files; // Store as-is if not valid JSON
}
// Layer 1: SQLite Memory Index
const memoryExists = stores.memories.hasDocumentId(id);
if (!memoryExists) {
stores.memories.create({
document_id: id,
text: '', // Deprecated: hierarchical fields replace narrative text
keywords: '',
session_id: session,
project,
created_at: timestamp,
origin: 'streaming-sdk',
// Hierarchical fields (v2)
title: title || undefined,
subtitle: subtitle || undefined,
facts: factsArray,
concepts: conceptsArray,
files_touched: filesArray
});
}
// Layer 2: ChromaDB - Store hierarchical memory
if (factsArray) {
const factsJson = JSON.parse(factsArray);
const conceptsJson = conceptsArray ? JSON.parse(conceptsArray) : [];
const filesJson = filesArray ? JSON.parse(filesArray) : [];
// Store each atomic fact as a separate ChromaDB document
factsJson.forEach((fact: string, idx: number) => {
spawnSync('claude-mem', [
'chroma_add_documents',
'--collection_name', 'claude_memories',
'--documents', JSON.stringify([fact]),
'--ids', JSON.stringify([`${id}_fact_${String(idx).padStart(3, '0')}`]),
'--metadatas', JSON.stringify([{
type: 'fact',
parent_id: id,
fact_index: idx,
title,
subtitle,
project,
session_id: session,
created_at: timestamp,
created_at_epoch: Date.parse(timestamp),
keywords: '',
concepts: JSON.stringify(conceptsJson),
files_touched: JSON.stringify(filesJson),
origin: 'streaming-sdk'
}])
]);
});
// Store full narrative with hierarchical metadata
spawnSync('claude-mem', [
'chroma_add_documents',
'--collection_name', 'claude_memories',
'--documents', JSON.stringify([`${title}\n${subtitle}\n\n${factsJson.join('\n')}`]),
'--ids', JSON.stringify([id]),
'--metadatas', JSON.stringify([{
type: 'narrative',
title,
subtitle,
facts_count: factsJson.length,
project,
session_id: session,
created_at: timestamp,
created_at_epoch: Date.parse(timestamp),
keywords: '',
concepts: JSON.stringify(conceptsJson),
files_touched: JSON.stringify(filesJson),
origin: 'streaming-sdk'
}])
]);
}
// Success output (SDK will see this)
console.log(JSON.stringify({
success: true,
memory_id: id,
project,
session,
date,
timestamp,
hierarchical: !!(title && subtitle && facts)
}));
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error storing memory'
}));
process.exit(1);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { OptionValues } from 'commander';
import { createStores } from '../services/sqlite/index.js';
/**
* Store a session overview
* Called by SDK via bash at session end
*/
export async function storeOverview(options: OptionValues): Promise<void> {
const { project, session, content } = options;
// Validate required fields
if (!project || !session || !content) {
console.error('Error: All fields required: --project, --session, --content');
process.exit(1);
}
try {
const stores = await createStores();
const timestamp = new Date().toISOString();
// Create one overview per session (rolling log architecture)
stores.overviews.upsert({
session_id: session,
content,
created_at: timestamp,
project,
origin: 'streaming-sdk'
});
// Success output (SDK will see this)
console.log(JSON.stringify({
success: true,
project,
session,
timestamp
}));
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error storing overview'
}));
process.exit(1);
}
}
+147 -82
View File
@@ -1,8 +1,69 @@
import { OptionValues } from 'commander';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { PathDiscovery } from '../services/path-discovery.js';
async function removeSmartTrashAlias(): Promise<boolean> {
const homeDir = homedir();
const shellConfigs = [
join(homeDir, '.bashrc'),
join(homeDir, '.zshrc'),
join(homeDir, '.bash_profile')
];
const aliasLine = 'alias rm="claude-mem trash"';
// Handle both variations of the comment line
const commentPatterns = [
'# claude-mem smart trash alias',
'# claude-mem trash bin alias'
];
let removedFromAny = false;
for (const configPath of shellConfigs) {
if (!existsSync(configPath)) continue;
let content = readFileSync(configPath, 'utf8');
// Check if alias exists
if (!content.includes(aliasLine)) {
continue; // Not configured in this file
}
// Remove the alias and its comment
const lines = content.split('\n');
const filteredLines = lines.filter((line, index) => {
// Skip the alias line
if (line.trim() === aliasLine) return false;
// Skip any claude-mem comment line if it's right before the alias
for (const commentPattern of commentPatterns) {
if (line.trim() === commentPattern &&
index + 1 < lines.length &&
lines[index + 1].trim() === aliasLine) {
return false;
}
}
return true;
});
const newContent = filteredLines.join('\n');
// Only write if content actually changed
if (newContent !== content) {
// Create backup
const backupPath = configPath + '.backup.' + Date.now();
writeFileSync(backupPath, content);
// Write updated content
writeFileSync(configPath, newContent);
console.log(`✅ Removed Smart Trash alias from ${configPath.replace(homeDir, '~')}`);
removedFromAny = true;
}
}
return removedFromAny;
}
export async function uninstall(options: OptionValues = {}): Promise<void> {
console.log('🔄 Uninstalling Claude Memory System hooks...');
@@ -26,10 +87,10 @@ export async function uninstall(options: OptionValues = {}): Promise<void> {
}
const pathDiscovery = PathDiscovery.getInstance();
const claudeMemHooksDir = pathDiscovery.getHooksDirectory();
const preCompactScript = join(claudeMemHooksDir, 'pre-compact.js');
const sessionStartScript = join(claudeMemHooksDir, 'session-start.js');
const sessionEndScript = join(claudeMemHooksDir, 'session-end.js');
const runtimeHooksDir = pathDiscovery.getHooksDirectory();
const preCompactScript = join(runtimeHooksDir, 'pre-compact.js');
const sessionStartScript = join(runtimeHooksDir, 'session-start.js');
const sessionEndScript = join(runtimeHooksDir, 'session-end.js');
let removedCount = 0;
@@ -39,95 +100,99 @@ export async function uninstall(options: OptionValues = {}): Promise<void> {
continue;
}
try {
const content = readFileSync(location.path, 'utf8');
const settings = JSON.parse(content);
if (!settings.hooks) {
console.log(`⏭️ No hooks configured in ${location.name} settings`);
continue;
const content = readFileSync(location.path, 'utf8');
const settings = JSON.parse(content);
if (!settings.hooks) {
console.log(`⏭️ No hooks configured in ${location.name} settings`);
continue;
}
let modified = false;
if (settings.hooks.PreCompact) {
const filteredPreCompact = settings.hooks.PreCompact.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === preCompactScript ||
hook.command?.includes('pre-compact.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredPreCompact.length !== settings.hooks.PreCompact.length) {
settings.hooks.PreCompact = filteredPreCompact.length ? filteredPreCompact : undefined;
modified = true;
console.log(`✅ Removed PreCompact hook from ${location.name} settings`);
}
let modified = false;
if (settings.hooks.PreCompact) {
const filteredPreCompact = settings.hooks.PreCompact.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === preCompactScript ||
hook.command?.includes('pre-compact.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredPreCompact.length !== settings.hooks.PreCompact.length) {
settings.hooks.PreCompact = filteredPreCompact.length ? filteredPreCompact : undefined;
modified = true;
console.log(`✅ Removed PreCompact hook from ${location.name} settings`);
}
}
if (settings.hooks.SessionStart) {
const filteredSessionStart = settings.hooks.SessionStart.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === sessionStartScript ||
hook.command?.includes('session-start.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredSessionStart.length !== settings.hooks.SessionStart.length) {
settings.hooks.SessionStart = filteredSessionStart.length ? filteredSessionStart : undefined;
modified = true;
console.log(`✅ Removed SessionStart hook from ${location.name} settings`);
}
if (settings.hooks.SessionStart) {
const filteredSessionStart = settings.hooks.SessionStart.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === sessionStartScript ||
hook.command?.includes('session-start.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredSessionStart.length !== settings.hooks.SessionStart.length) {
settings.hooks.SessionStart = filteredSessionStart.length ? filteredSessionStart : undefined;
modified = true;
console.log(`✅ Removed SessionStart hook from ${location.name} settings`);
}
}
if (settings.hooks.SessionEnd) {
const filteredSessionEnd = settings.hooks.SessionEnd.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === sessionEndScript ||
hook.command?.includes('session-end.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredSessionEnd.length !== settings.hooks.SessionEnd.length) {
settings.hooks.SessionEnd = filteredSessionEnd.length ? filteredSessionEnd : undefined;
modified = true;
console.log(`✅ Removed SessionEnd hook from ${location.name} settings`);
}
if (settings.hooks.SessionEnd) {
const filteredSessionEnd = settings.hooks.SessionEnd.filter((matcher: any) =>
!matcher.hooks?.some((hook: any) =>
hook.command === sessionEndScript ||
hook.command?.includes('session-end.js') ||
hook.command?.includes('claude-mem')
)
);
if (filteredSessionEnd.length !== settings.hooks.SessionEnd.length) {
settings.hooks.SessionEnd = filteredSessionEnd.length ? filteredSessionEnd : undefined;
modified = true;
console.log(`✅ Removed SessionEnd hook from ${location.name} settings`);
}
}
if (settings.hooks.PreCompact === undefined) delete settings.hooks.PreCompact;
if (settings.hooks.SessionStart === undefined) delete settings.hooks.SessionStart;
if (settings.hooks.SessionEnd === undefined) delete settings.hooks.SessionEnd;
if (!Object.keys(settings.hooks).length) delete settings.hooks;
if (modified) {
const backupPath = location.path + '.backup.' + Date.now();
writeFileSync(backupPath, content);
console.log(`📋 Created backup: ${backupPath}`);
writeFileSync(location.path, JSON.stringify(settings, null, 2));
removedCount++;
console.log(`✅ Updated ${location.name} settings: ${location.path}`);
} else {
console.log(`️ No Claude Memory System hooks found in ${location.name} settings`);
}
} catch (error: any) {
console.log(`⚠️ Could not process ${location.name} settings: ${error.message}`);
}
if (settings.hooks.PreCompact === undefined) delete settings.hooks.PreCompact;
if (settings.hooks.SessionStart === undefined) delete settings.hooks.SessionStart;
if (settings.hooks.SessionEnd === undefined) delete settings.hooks.SessionEnd;
if (!Object.keys(settings.hooks).length) delete settings.hooks;
if (modified) {
const backupPath = location.path + '.backup.' + Date.now();
writeFileSync(backupPath, content);
console.log(`📋 Created backup: ${backupPath}`);
writeFileSync(location.path, JSON.stringify(settings, null, 2));
removedCount++;
console.log(`✅ Updated ${location.name} settings: ${location.path}`);
} else {
console.log(`️ No Claude Memory System hooks found in ${location.name} settings`);
}
}
// Remove Smart Trash alias from shell configs
const removedAlias = await removeSmartTrashAlias();
console.log('');
if (removedCount > 0) {
if (removedCount > 0 || removedAlias) {
console.log('✨ Uninstallation complete!');
console.log('The Claude Memory System hooks have been removed from your settings.');
if (removedCount > 0) {
console.log('The Claude Memory System hooks have been removed from your settings.');
}
if (removedAlias) {
console.log('The Smart Trash alias has been removed from your shell configuration.');
console.log('⚠️ Restart your terminal for the alias removal to take effect.');
}
console.log('');
console.log('Note: Your compressed transcripts and archives are preserved.');
console.log('To reinstall: claude-mem install');
} else {
console.log('️ No Claude Memory System hooks were found to remove.');
console.log('️ No Claude Memory System hooks or aliases were found to remove.');
}
}
+80
View File
@@ -0,0 +1,80 @@
import { OptionValues } from 'commander';
import path from 'path';
import fs from 'fs';
const SESSION_DIR = path.join(process.env.HOME || '', '.claude-mem', 'sessions');
/**
* Update session metadata (title/subtitle) in the streaming session JSON file
* Called by SDK when generating session title at the start
*/
export async function updateSessionMetadata(options: OptionValues): Promise<void> {
const { project, session, title, subtitle } = options;
// Validate required fields
if (!project || !session) {
console.error(JSON.stringify({
success: false,
error: 'Missing required fields: --project, --session'
}));
process.exit(1);
}
if (!title) {
console.error(JSON.stringify({
success: false,
error: 'Missing required field: --title'
}));
process.exit(1);
}
try {
// Load existing session file
const sessionFile = path.join(SESSION_DIR, `${project}_streaming.json`);
if (!fs.existsSync(sessionFile)) {
console.error(JSON.stringify({
success: false,
error: `Session file not found: ${sessionFile}`
}));
process.exit(1);
}
let sessionData: any = {};
try {
sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
} catch (e) {
console.error(JSON.stringify({
success: false,
error: 'Failed to parse session file'
}));
process.exit(1);
}
// Update metadata
sessionData.promptTitle = title;
if (subtitle) {
sessionData.promptSubtitle = subtitle;
}
sessionData.updatedAt = new Date().toISOString();
// Write back to file
fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2));
// Output success
console.log(JSON.stringify({
success: true,
title,
subtitle: subtitle || null,
project,
session
}));
} catch (error: any) {
console.error(JSON.stringify({
success: false,
error: error.message || 'Unknown error updating session metadata'
}));
process.exit(1);
}
}
+3 -184
View File
@@ -1,92 +1,9 @@
/**
* Claude Memory System - Core Constants
*
* This file contains core application constants, CLI messages,
* configuration templates, and infrastructure-related constants.
*
* This file contains debug logging templates used throughout the application.
*/
// =============================================================================
// CONFIGURATION TEMPLATES
// =============================================================================
/**
* Hook configuration templates for Claude settings
*/
export const HOOK_CONFIG_TEMPLATES = {
PRE_COMPACT: (scriptPath: string) => ({
pattern: "*",
hooks: [{
type: "command",
command: scriptPath,
timeout: 180
}]
}),
SESSION_START: (scriptPath: string) => ({
pattern: "*",
hooks: [{
type: "command",
command: scriptPath,
timeout: 30
}]
}),
SESSION_END: (scriptPath: string) => ({
pattern: "*",
hooks: [{
type: "command",
command: scriptPath,
timeout: 180
}]
})
} as const;
// =============================================================================
// CLI MESSAGES AND STATUS TEMPLATES
// =============================================================================
/**
* Command-line interface messages
*/
export const CLI_MESSAGES = {
INSTALLATION: {
STARTING: '🚀 Installing Claude Memory System with Chroma...',
SUCCESS: '🎉 Installation complete! Vector database ready.',
HOOKS_INSTALLED: '✅ Installed hooks to ~/.claude-mem/hooks/',
MCP_CONFIGURED: (path: string) => `✅ Configured MCP memory server in ${path}`,
EMBEDDED_READY: '🧠 Chroma initialized for persistent semantic memory',
ALREADY_INSTALLED: '⚠️ Claude Memory hooks are already installed.',
USE_FORCE: ' Use --force to overwrite existing installation.',
SETTINGS_WRITTEN: (type: string, path: string) =>
`✅ Installed hooks in ${type} settings\n Settings file: ${path}`
},
NEXT_STEPS: [
'1. Restart Claude Code to load the new hooks',
'2. Use `/clear` and `/compact` in Claude Code to save and compress session memories',
'3. New sessions will automatically load relevant context'
],
ERRORS: {
HOOKS_NOT_FOUND: '❌ Hook source files not found',
SETTINGS_WRITE_FAILED: (path: string, error: string) =>
`❌ Failed to write settings file: ${error}\n Path: ${path}`,
MCP_CONFIG_PARSE_FAILED: (error: string) =>
`⚠️ Warning: Could not parse existing MCP config: ${error}`,
MCP_CONFIG_WRITE_FAILED: (error: string) =>
`⚠️ Warning: Could not write MCP config: ${error}`,
COMPRESSION_FAILED: (error: string) => `❌ Compression failed: ${error}`,
CONTEXT_LOAD_FAILED: (error: string) => `❌ Failed to load context: ${error}`
},
STATUS: {
NO_INDEX: '📚 No memory index found. Starting fresh session.',
RECENT_MEMORIES: '🧠 Recent memories from previous sessions:',
MEMORY_COUNT: (count: number) => `📚 Showing ${count} most recent memories`,
FULL_CONTEXT_AVAILABLE: '💡 Full context available via MCP memory tools'
}
} as const;
// =============================================================================
// DEBUG AND LOGGING TEMPLATES
// =============================================================================
@@ -100,107 +17,9 @@ export const DEBUG_MESSAGES = {
SESSION_ID: (id: string) => `🔍 Session ID: ${id}`,
PROJECT_NAME: (name: string) => `📝 PROJECT NAME: ${name}`,
CLAUDE_SDK_CALL: '🤖 Calling Claude SDK to analyze and populate memory database...',
TRANSCRIPT_STATS: (size: number, count: number) =>
TRANSCRIPT_STATS: (size: number, count: number) =>
`📊 Transcript size: ${size} characters, ${count} messages`,
COMPRESSION_COMPLETE: (count: number) => `✅ COMPRESSION COMPLETE\n Total summaries extracted: ${count}`,
CLAUDE_PATH_FOUND: (path: string) => `🎯 Found Claude Code at: ${path}`,
MCP_CONFIG_USED: (path: string) => `📋 Using MCP config: ${path}`
} as const;
// =============================================================================
// SEARCH AND QUERY TEMPLATES
// =============================================================================
/**
* Memory database search templates
*/
export const SEARCH_TEMPLATES = {
SEARCH_SCRIPT: (query: string) => `
import { query } from "@anthropic-ai/claude-code";
const searchQuery = process.env.SEARCH_QUERY || '';
const result = await query({
prompt: "Search for: " + searchQuery,
options: {
mcpConfig: "~/.claude/.mcp.json",
allowedTools: ["mcp__claude-mem__chroma_query_documents"],
outputFormat: "json"
}
});
`,
SEARCH_PREFIX: "Search for: "
} as const;
// =============================================================================
// CHROMA INTEGRATION CONSTANTS
// =============================================================================
/**
* Chroma collection names for documents
*/
export const CHROMA_COLLECTIONS = {
DOCUMENTS: 'claude_mem_documents',
MEMORIES: 'claude_mem_memories'
} as const;
/**
* Default Chroma configuration values
*/
export const CHROMA_DEFAULTS = {
HOST: 'localhost:8000',
COLLECTION: 'claude_mem_documents'
} as const;
/**
* Chroma-specific CLI messages
*/
export const CHROMA_MESSAGES = {
CONNECTION: {
CONNECTING: '🔗 Connecting to Chroma server...',
CONNECTED: '✅ Connected to Chroma successfully',
FAILED: (error: string) => `❌ Failed to connect to Chroma: ${error}`,
DISCONNECTED: '👋 Disconnected from Chroma'
},
SEARCH: {
SEMANTIC_SEARCH: '🧠 Using semantic search with Chroma...',
KEYWORD_SEARCH: '🔍 Using keyword search with Chroma...',
HYBRID_SEARCH: '🔬 Using hybrid search with Chroma...',
RESULTS_FOUND: (count: number) => `📊 Found ${count} results in Chroma`
},
SETUP: {
STARTING_CHROMA: '🚀 Starting Chroma instance...',
CHROMA_READY: '✅ Chroma is ready and accepting connections',
INITIALIZING_COLLECTIONS: '📋 Initializing document collections...'
}
} as const;
/**
* Chroma error messages
*/
export const CHROMA_ERRORS = {
CONNECTION_FAILED: 'Could not establish connection to Chroma server',
MCP_SERVER_NOT_FOUND: 'Chroma MCP server not found',
INVALID_COLLECTION: (collection: string) => `Invalid Chroma collection: ${collection}`,
QUERY_FAILED: (query: string, error: string) => `Query failed for '${query}': ${error}`,
DOCUMENT_CREATION_FAILED: (id: string) => `Failed to create document '${id}' in Chroma`,
COLLECTION_CREATION_FAILED: (name: string) => `Failed to create collection '${name}' in Chroma`
} as const;
/**
* Export all core constants for easy importing
*/
export const CONSTANTS = {
HOOK_CONFIG_TEMPLATES,
CLI_MESSAGES,
DEBUG_MESSAGES,
SEARCH_TEMPLATES,
// Chroma constants
CHROMA_COLLECTIONS,
CHROMA_DEFAULTS,
CHROMA_MESSAGES,
CHROMA_ERRORS
} as const;
-238
View File
@@ -1,238 +0,0 @@
/**
* ChunkManager - Handles intelligent chunking of large transcripts
*
* This class manages the splitting of large filtered transcripts into chunks
* that fit within Claude's 32k token limit while preserving conversation context
* and maintaining message integrity.
*/
export interface ChunkMetadata {
chunkNumber: number;
totalChunks: number;
startIndex: number;
endIndex: number;
messageCount: number;
estimatedTokens: number;
sizeBytes: number;
hasOverlap: boolean;
overlapMessages?: number;
firstTimestamp?: string;
lastTimestamp?: string;
}
export interface ChunkingOptions {
maxTokensPerChunk?: number; // default: 28000 (leaving 4k buffer)
maxBytesPerChunk?: number; // default: 98000 (98KB)
preserveContext?: boolean; // keep context overlap between chunks
contextOverlap?: number; // messages to repeat (default: 2)
parallel?: boolean; // process chunks in parallel
}
export interface ChunkedMessage {
content: string;
estimatedTokens: number;
}
export class ChunkManager {
private static readonly DEFAULT_MAX_TOKENS = 22400; // Reduced by 20% from 28000
private static readonly DEFAULT_MAX_BYTES = 78400; // Reduced by 20% from 98000
private static readonly DEFAULT_CONTEXT_OVERLAP = 2;
private static readonly CHARS_PER_TOKEN_ESTIMATE = 3.5;
private options: Required<ChunkingOptions>;
constructor(options: ChunkingOptions = {}) {
this.options = {
maxTokensPerChunk: options.maxTokensPerChunk ?? ChunkManager.DEFAULT_MAX_TOKENS,
maxBytesPerChunk: options.maxBytesPerChunk ?? ChunkManager.DEFAULT_MAX_BYTES,
preserveContext: options.preserveContext ?? true,
contextOverlap: options.contextOverlap ?? ChunkManager.DEFAULT_CONTEXT_OVERLAP,
parallel: options.parallel ?? false
};
}
/**
* Estimates token count for a given text
* Uses rough approximation of 3.5 characters per token
*/
public estimateTokenCount(text: string): number {
return Math.ceil(text.length / ChunkManager.CHARS_PER_TOKEN_ESTIMATE);
}
/**
* Parses the filtered output format into structured messages
* Format: "- content"
*/
public parseFilteredOutput(filteredContent: string): ChunkedMessage[] {
const lines = filteredContent.split('\n').filter(line => line.trim());
const messages: ChunkedMessage[] = [];
for (const line of lines) {
// Parse format: "- content"
if (line.startsWith('- ')) {
const content = line.substring(2); // Remove "- " prefix
messages.push({
content,
estimatedTokens: this.estimateTokenCount(content)
});
}
}
return messages;
}
/**
* Chunks the filtered transcript into manageable pieces
*/
public chunkTranscript(filteredContent: string): Array<{ content: string; metadata: ChunkMetadata }> {
const messages = this.parseFilteredOutput(filteredContent);
const chunks: Array<{ content: string; metadata: ChunkMetadata }> = [];
let currentChunk: ChunkedMessage[] = [];
let currentTokens = 0;
let currentBytes = 0;
let chunkStartIndex = 0;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const messageText = this.formatMessage(message);
const messageBytes = Buffer.byteLength(messageText, 'utf8');
const messageTokens = message.estimatedTokens;
// Check if adding this message would exceed limits
if (currentChunk.length > 0 &&
(currentTokens + messageTokens > this.options.maxTokensPerChunk ||
currentBytes + messageBytes > this.options.maxBytesPerChunk)) {
// Save current chunk
const chunkContent = this.formatChunk(currentChunk);
chunks.push({
content: chunkContent,
metadata: {
chunkNumber: chunks.length + 1,
totalChunks: 0, // Will be updated after all chunks are created
startIndex: chunkStartIndex,
endIndex: i - 1,
messageCount: currentChunk.length,
estimatedTokens: currentTokens,
sizeBytes: currentBytes,
hasOverlap: false
}
});
// Start new chunk with optional context overlap
currentChunk = [];
currentTokens = 0;
currentBytes = 0;
chunkStartIndex = i;
// Add overlap messages from previous chunk if enabled
if (this.options.preserveContext && chunks.length > 0) {
const overlapStart = Math.max(0, i - this.options.contextOverlap);
for (let j = overlapStart; j < i; j++) {
const overlapMessage = messages[j];
const overlapText = this.formatMessage(overlapMessage);
currentChunk.push(overlapMessage);
currentTokens += overlapMessage.estimatedTokens;
currentBytes += Buffer.byteLength(overlapText, 'utf8');
}
if (currentChunk.length > 0) {
// Mark that this chunk has overlap
chunkStartIndex = overlapStart;
}
}
}
// Add message to current chunk
currentChunk.push(message);
currentTokens += messageTokens;
currentBytes += messageBytes;
}
// Save final chunk if it has content
if (currentChunk.length > 0) {
const chunkContent = this.formatChunk(currentChunk);
chunks.push({
content: chunkContent,
metadata: {
chunkNumber: chunks.length + 1,
totalChunks: 0,
startIndex: chunkStartIndex,
endIndex: messages.length - 1,
messageCount: currentChunk.length,
estimatedTokens: currentTokens,
sizeBytes: currentBytes,
hasOverlap: this.options.preserveContext && chunks.length > 0
}
});
}
// Update total chunks count in metadata
chunks.forEach(chunk => {
chunk.metadata.totalChunks = chunks.length;
});
return chunks;
}
/**
* Formats a single message back to the filtered output format
*/
private formatMessage(message: ChunkedMessage): string {
return `- ${message.content}`;
}
/**
* Formats a chunk of messages
*/
private formatChunk(messages: ChunkedMessage[]): string {
return messages.map(m => this.formatMessage(m)).join('\n');
}
/**
* Creates a header for a chunk file with metadata
*/
public createChunkHeader(metadata: ChunkMetadata): string {
const lines = [];
// Add timestamp range if available, otherwise chunk number
if (metadata.firstTimestamp && metadata.lastTimestamp) {
lines.push(`# ${metadata.firstTimestamp} to ${metadata.lastTimestamp} (chunk ${metadata.chunkNumber}/${metadata.totalChunks})`);
} else {
lines.push(`# Chunk ${metadata.chunkNumber} of ${metadata.totalChunks}`);
}
return lines.join('\n') + '\n';
}
/**
* Checks if content needs chunking based on size
*/
public needsChunking(content: string): boolean {
const estimatedTokens = this.estimateTokenCount(content);
const sizeBytes = Buffer.byteLength(content, 'utf8');
return estimatedTokens > this.options.maxTokensPerChunk ||
sizeBytes > this.options.maxBytesPerChunk;
}
/**
* Gets chunking statistics for logging
*/
public getChunkingStats(chunks: Array<{ metadata: ChunkMetadata }>): string {
const totalMessages = chunks.reduce((sum, c) => sum + c.metadata.messageCount, 0);
const totalTokens = chunks.reduce((sum, c) => sum + c.metadata.estimatedTokens, 0);
const totalBytes = chunks.reduce((sum, c) => sum + c.metadata.sizeBytes, 0);
return [
`📊 Chunking Statistics:`,
` • Total chunks: ${chunks.length}`,
` • Total messages: ${totalMessages}`,
` • Total estimated tokens: ${totalTokens.toLocaleString()}`,
` • Total size: ${(totalBytes / 1024).toFixed(1)} KB`,
` • Average tokens per chunk: ${Math.round(totalTokens / chunks.length).toLocaleString()}`,
` • Average size per chunk: ${(totalBytes / chunks.length / 1024).toFixed(1)} KB`
].join('\n');
}
}
File diff suppressed because it is too large Load Diff
@@ -1,366 +0,0 @@
/**
* PromptOrchestrator - Single source of truth for all prompt generation
*
* This class serves as the central orchestrator for generating different types of prompts
* used throughout the claude-mem system. It provides clear, well-typed interfaces and
* methods for creating prompts for LLM analysis, human context, and system integration.
*/
import { createAnalysisPrompt } from '../../prompts/templates/analysis/AnalysisTemplates.js';
// =============================================================================
// CORE INTERFACES
// =============================================================================
/**
* Context data for LLM analysis prompts
*/
export interface AnalysisContext {
/** The transcript content to analyze */
transcriptContent: string;
/** Session identifier */
sessionId: string;
/** Project name for context */
projectName?: string;
/** Custom analysis instructions */
customInstructions?: string;
/** Compression trigger type */
trigger?: 'manual' | 'auto';
/** Original token count */
originalTokens?: number;
/** Target compression ratio */
targetCompressionRatio?: number;
}
/**
* Context data for human-facing session prompts
*/
export interface SessionContext {
/** Session identifier */
sessionId: string;
/** Source of the session start */
source: 'startup' | 'compact' | 'vscode' | 'web';
/** Project name */
projectName?: string;
/** Additional context to provide to the human */
additionalContext?: string;
/** Path to the transcript file */
transcriptPath?: string;
/** Working directory */
cwd?: string;
}
/**
* Context data for hook response generation
*/
export interface HookContext {
/** The hook event name */
hookEventName: string;
/** Session identifier */
sessionId: string;
/** Success status */
success: boolean;
/** Optional message */
message?: string;
/** Additional data specific to the hook */
data?: Record<string, unknown>;
/** Whether to continue processing */
shouldContinue?: boolean;
/** Reason for stopping if applicable */
stopReason?: string;
}
/**
* Generated analysis prompt for LLM consumption
*/
export interface AnalysisPrompt {
/** The formatted prompt text */
prompt: string;
/** Context used to generate the prompt */
context: AnalysisContext;
/** Prompt type identifier */
type: 'analysis';
/** Generated timestamp */
timestamp: string;
}
/**
* Generated session prompt for human context
*/
export interface SessionPrompt {
/** The formatted message text */
message: string;
/** Context used to generate the prompt */
context: SessionContext;
/** Prompt type identifier */
type: 'session';
/** Generated timestamp */
timestamp: string;
}
/**
* Generated hook response
*/
export interface HookResponse {
/** Whether to continue processing */
continue: boolean;
/** Reason for stopping if continue is false */
stopReason?: string;
/** Whether to suppress output */
suppressOutput?: boolean;
/** Hook-specific output data */
hookSpecificOutput?: Record<string, unknown>;
/** Context used to generate the response */
context: HookContext;
/** Response type identifier */
type: 'hook';
/** Generated timestamp */
timestamp: string;
}
// =============================================================================
// PROMPT ORCHESTRATOR CLASS
// =============================================================================
/**
* Central orchestrator for all prompt generation in the claude-mem system
*/
export class PromptOrchestrator {
private projectName: string;
constructor(projectName = 'claude-mem') {
this.projectName = projectName;
}
/**
* Creates an analysis prompt for LLM processing of transcript content
*/
public createAnalysisPrompt(context: AnalysisContext): AnalysisPrompt {
const timestamp = new Date().toISOString();
const prompt = this.buildAnalysisPrompt(context);
return {
prompt,
context,
type: 'analysis',
timestamp,
};
}
/**
* Creates a session start prompt for human context
*/
public createSessionStartPrompt(context: SessionContext): SessionPrompt {
const timestamp = new Date().toISOString();
const message = this.buildSessionStartMessage(context);
return {
message,
context,
type: 'session',
timestamp,
};
}
/**
* Creates a hook response for system integration
*/
public createHookResponse(context: HookContext): HookResponse {
const timestamp = new Date().toISOString();
const response = this.buildHookResponse(context);
return {
...response,
context,
type: 'hook',
timestamp,
};
}
// =============================================================================
// PRIVATE PROMPT BUILDERS
// =============================================================================
private buildAnalysisPrompt(context: AnalysisContext): string {
const {
transcriptContent,
sessionId,
projectName = this.projectName,
} = context;
// Use project name as-is for consistency with directory names
const projectPrefix = projectName;
// Use the simple prompt with the transcript included
return createAnalysisPrompt(
transcriptContent,
sessionId,
projectPrefix
);
}
private buildSessionStartMessage(context: SessionContext): string {
const {
sessionId,
source,
projectName = this.projectName,
additionalContext,
transcriptPath,
cwd,
} = context;
let message = `## Session Started (${source})
**Project**: ${projectName}
**Session ID**: ${sessionId} `;
if (transcriptPath) {
message += `**Transcript**: ${transcriptPath} `;
}
if (cwd) {
message += `**Working Directory**: ${cwd} `;
}
if (additionalContext) {
message += `\n### Additional Context\n${additionalContext}`;
}
message += `\n\nMemory system is active and ready to preserve context across sessions.`;
return message;
}
private buildHookResponse(context: HookContext): Omit<HookResponse, 'context' | 'type' | 'timestamp'> {
const {
hookEventName,
success,
message,
data,
shouldContinue = success,
stopReason,
} = context;
const response: Omit<HookResponse, 'context' | 'type' | 'timestamp'> = {
continue: shouldContinue,
suppressOutput: false,
};
if (!shouldContinue && stopReason) {
response.stopReason = stopReason;
}
// Add hook-specific output based on event type
if (hookEventName === 'SessionStart') {
response.hookSpecificOutput = {
hookEventName: 'SessionStart',
additionalContext: message,
...data,
};
} else if (data) {
response.hookSpecificOutput = data;
}
return response;
}
// =============================================================================
// UTILITY METHODS
// =============================================================================
/**
* Validates that an AnalysisContext has required fields
*/
public validateAnalysisContext(context: Partial<AnalysisContext>): context is AnalysisContext {
return !!(context.transcriptContent && context.sessionId);
}
/**
* Validates that a SessionContext has required fields
*/
public validateSessionContext(context: Partial<SessionContext>): context is SessionContext {
return !!(context.sessionId && context.source);
}
/**
* Validates that a HookContext has required fields
*/
public validateHookContext(context: Partial<HookContext>): context is HookContext {
return !!(context.hookEventName && context.sessionId && typeof context.success === 'boolean');
}
/**
* Gets the project name for this orchestrator instance
*/
public getProjectName(): string {
return this.projectName;
}
/**
* Sets a new project name for this orchestrator instance
*/
public setProjectName(projectName: string): void {
this.projectName = projectName;
}
}
// =============================================================================
// FACTORY FUNCTIONS
// =============================================================================
/**
* Creates a new PromptOrchestrator instance
*/
export function createPromptOrchestrator(projectName?: string): PromptOrchestrator {
return new PromptOrchestrator(projectName);
}
/**
* Creates an analysis context from basic parameters
*/
export function createAnalysisContext(
transcriptContent: string,
sessionId: string,
options: Partial<Omit<AnalysisContext, 'transcriptContent' | 'sessionId'>> = {}
): AnalysisContext {
return {
transcriptContent,
sessionId,
...options,
};
}
/**
* Creates a session context from basic parameters
*/
export function createSessionContext(
sessionId: string,
source: SessionContext['source'],
options: Partial<Omit<SessionContext, 'sessionId' | 'source'>> = {}
): SessionContext {
return {
sessionId,
source,
...options,
};
}
/**
* Creates a hook context from basic parameters
*/
export function createHookContext(
hookEventName: string,
sessionId: string,
success: boolean,
options: Partial<Omit<HookContext, 'hookEventName' | 'sessionId' | 'success'>> = {}
): HookContext {
return {
hookEventName,
sessionId,
success,
...options,
};
}
-128
View File
@@ -1,128 +0,0 @@
import { query } from '@anthropic-ai/claude-code';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getClaudePath } from '../../shared/settings.js';
export interface TitleGenerationRequest {
sessionId: string;
projectName: string;
firstMessage: string;
}
export interface GeneratedTitle {
session_id: string;
generated_title: string;
timestamp: string;
project_name: string;
}
export class TitleGenerator {
private titlesIndexPath: string;
constructor() {
this.titlesIndexPath = path.join(os.homedir(), '.claude-mem', 'conversation-titles.jsonl');
this.ensureTitlesIndex();
}
private ensureTitlesIndex(): void {
const dir = path.dirname(this.titlesIndexPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (!fs.existsSync(this.titlesIndexPath)) {
fs.writeFileSync(this.titlesIndexPath, '', 'utf-8');
}
}
async generateTitle(firstMessage: string): Promise<string> {
const prompt = `Generate a 3-7 word descriptive title for this conversation based on the first message.
The title should:
- Capture the main topic or intent
- Be concise and descriptive
- Use proper capitalization
- Not include "Help with" or "Question about" prefixes
First message: "${firstMessage.substring(0, 500)}"
Respond with just the title, nothing else.`;
const response = await query({
prompt,
options: {
model: 'claude-3-5-haiku-20241022',
pathToClaudeCodeExecutable: getClaudePath(),
},
});
let title = '';
if (response && typeof response === 'object' && Symbol.asyncIterator in response) {
for await (const message of response) {
if (message?.content) title += message.content;
if (message?.text) title += message.text;
}
} else if (typeof response === 'string') {
title = response;
}
return title.trim().replace(/^["']|["']$/g, '');
}
async batchGenerateTitles(requests: TitleGenerationRequest[]): Promise<GeneratedTitle[]> {
const results: GeneratedTitle[] = [];
for (const request of requests) {
try {
const title = await this.generateTitle(request.firstMessage);
const generatedTitle: GeneratedTitle = {
session_id: request.sessionId,
generated_title: title,
timestamp: new Date().toISOString(),
project_name: request.projectName
};
results.push(generatedTitle);
this.storeTitleInIndex(generatedTitle);
} catch (error) {
console.error(`Failed to generate title for ${request.sessionId}:`, error);
}
}
return results;
}
private storeTitleInIndex(title: GeneratedTitle): void {
const line = JSON.stringify(title) + '\n';
fs.appendFileSync(this.titlesIndexPath, line, 'utf-8');
}
getExistingTitles(): Map<string, GeneratedTitle> {
const titles = new Map<string, GeneratedTitle>();
if (!fs.existsSync(this.titlesIndexPath)) {
return titles;
}
const content = fs.readFileSync(this.titlesIndexPath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
for (const line of lines) {
try {
const title = JSON.parse(line) as GeneratedTitle;
titles.set(title.session_id, title);
} catch (error) {
// Skip invalid lines
}
}
return titles;
}
getTitleForSession(sessionId: string): string | null {
const titles = this.getExistingTitles();
const title = titles.get(sessionId);
return title ? title.generated_title : null;
}
}
+224
View File
@@ -0,0 +1,224 @@
# Hook Prompts System
This directory contains the centralized prompt configuration for all streaming hooks.
## Quick Edit Guide
**Want to change hook prompts?** Edit this file:
```
hook-prompts.config.ts
```
Then rebuild and reinstall:
```bash
bun run build
bun run dev:install
```
## Files in This Directory
### hook-prompts.config.ts
**EDIT THIS FILE** to change prompt content.
Contains:
- `SYSTEM_PROMPT` - Initial instructions for SDK (190 lines)
- `TOOL_MESSAGE` - Format for tool responses (10 lines)
- `END_MESSAGE` - Session completion request (10 lines)
- `HOOK_CONFIG` - Shared settings (truncation limits, SDK options)
Uses `{{variableName}}` template syntax.
### hook-prompt-renderer.ts
**DON'T EDIT** unless adding new variables or changing rendering logic.
Contains:
- `renderSystemPrompt()` - Processes system prompt template
- `renderToolMessage()` - Processes tool message template
- `renderEndMessage()` - Processes end message template
- Template substitution and auto-truncation logic
### templates/context/ContextTemplates.ts
Session start message formatting (separate from hook prompts).
## Template Variables Reference
### SYSTEM_PROMPT Variables
```typescript
{
project: string; // Project name (e.g., "claude-mem-source")
sessionId: string; // Claude Code session ID
date: string; // YYYY-MM-DD format
userPrompt: string; // Auto-truncated to 200 chars
}
```
### TOOL_MESSAGE Variables
```typescript
{
toolName: string; // Tool name (e.g., "Read", "Bash")
toolResponse: string; // Auto-truncated to 20000 chars
userPrompt: string; // Auto-truncated to 200 chars
timestamp: string; // Full ISO timestamp
timeFormatted: string; // HH:MM:SS format (auto-generated)
}
```
### END_MESSAGE Variables
```typescript
{
project: string; // Project name
sessionId: string; // Claude Code session ID
}
```
## Usage in Hooks
### user-prompt-submit-streaming.js
```javascript
import { renderSystemPrompt, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
const prompt = renderSystemPrompt({
project,
sessionId: session_id,
date,
userPrompt: prompt || ''
});
query({
prompt,
options: {
model: HOOK_CONFIG.sdk.model,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensSystem
}
});
```
### post-tool-use-streaming.js
```javascript
import { renderToolMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
const message = renderToolMessage({
toolName: tool_name,
toolResponse: toolResponseStr,
userPrompt: prompt || '',
timestamp: timestamp || new Date().toISOString()
});
query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
maxTokens: HOOK_CONFIG.sdk.maxTokensTool
}
});
```
### stop-streaming.js
```javascript
import { renderEndMessage, HOOK_CONFIG } from '../src/prompts/hook-prompt-renderer.js';
const message = renderEndMessage({
project,
sessionId: claudeSessionId
});
query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
maxTokens: HOOK_CONFIG.sdk.maxTokensEnd
}
});
```
## Configuration Options
Edit `HOOK_CONFIG` in `hook-prompts.config.ts`:
```typescript
export const HOOK_CONFIG = {
// Truncation limits for template variables
maxUserPromptLength: 200, // Increase to show more context
maxToolResponseLength: 20000, // Increase for larger outputs
// SDK configuration
sdk: {
model: 'claude-sonnet-4-5', // Change model version
allowedTools: ['Bash'], // Add more tools if needed
maxTokensSystem: 8192, // Token limit for system prompt
maxTokensTool: 8192, // Token limit for tool messages
maxTokensEnd: 2048, // Token limit for end message
},
};
```
## Example: Editing a Prompt
### Before
```typescript
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
```
### After
```typescript
export const TOOL_MESSAGE = `# Analysis Request {{timeFormatted}}
Executed: {{toolName}}
Context: "{{userPrompt}}"
Priority: High
Output:
\`\`\`
{{toolResponse}}
\`\`\`
IMPORTANT: Only store if this contains:
- New code patterns or logic
- Architecture decisions
- Error messages with solutions
- Configuration changes
Skip trivial operations.`;
```
### Apply Changes
```bash
bun run build && bun run dev:install
```
## Benefits
### DRY Compliance
- **Before**: 3 files with 188 lines of hardcoded prompts
- **After**: 1 config file with all prompts centralized
### Maintainability
- Change prompts without touching hook implementation
- Type-safe template variables
- Consistent formatting across all hooks
- Version-controlled prompt history
### Flexibility
- Easy A/B testing of different instructions
- Simple to adjust truncation limits
- Quick model/token configuration changes
- Template variables prevent copy-paste errors
## Full Documentation
See `/Users/alexnewman/Scripts/claude-mem-source/docs/HOOK_PROMPTS.md` for:
- Detailed editing guide
- Troubleshooting common issues
- Adding new template variables
- Advanced customization
- Migration notes
-191
View File
@@ -1,191 +0,0 @@
/**
* Claude Memory System - Prompt-Related Constants and Templates
*
* This file contains all prompts, instructions, and output templates
* for the analysis and context priming system.
*/
import * as HookTemplates from './templates/hooks/HookTemplates.js';
// =============================================================================
// ANALYSIS PROMPTS AND TEMPLATES
// =============================================================================
/**
* Entity naming patterns for the knowledge graph
*/
export const ENTITY_NAMING_PATTERNS = {
component: "Component_Name",
decision: "Decision_Name",
pattern: "Pattern_Name",
tool: "Tool_Name",
fix: "Fix_Name",
workflow: "Workflow_Name"
} as const;
/**
* Available entity types for classification
*/
export const ENTITY_TYPES = {
component: "component", // UI components, modules, services
pattern: "pattern", // Architectural or design patterns
workflow: "workflow", // Processes, pipelines, sequences
integration: "integration", // APIs, external services, data sources
concept: "concept", // Abstract ideas, methodologies, principles
decision: "decision", // Design choices, trade-offs, solutions
tool: "tool", // Utilities, libraries, development tools
fix: "fix" // Bug fixes, patches, workarounds
} as const;
/**
* Standard observation fields for entities
*/
export const OBSERVATION_FIELDS = [
"Core purpose: [what it fundamentally does]",
"Brief description: [one-line summary for session-start display]",
"Implementation: [key technical details, code patterns]",
"Dependencies: [what it requires or builds upon]",
"Usage context: [when/why it's used]",
"Performance characteristics: [speed, reliability, constraints]",
"Integration points: [how it connects to other systems]",
"Keywords: [searchable terms for this concept]",
"Decision rationale: [why this approach was chosen]",
"Next steps: [what needs to be done next with this component]",
"Files modified: [list of files changed]",
"Tools used: [development tools/commands used]"
] as const;
/**
* Relationship types for creating meaningful entity connections
*/
export const RELATIONSHIP_TYPES = [
"executes_via", "orchestrates_through", "validates_using",
"provides_auth_to", "manages_state_for", "processes_events_from",
"caches_data_from", "routes_requests_to", "transforms_data_for",
"extends", "enhances_performance_of", "builds_upon",
"fixes_issue_in", "replaces", "optimizes",
"triggers_tool", "receives_result_from"
] as const;
// =============================================================================
// CONTEXT PRIMING TEMPLATES
// =============================================================================
/**
* System message templates for context priming
*/
export const CONTEXT_TEMPLATES = {
PRIMARY_CONTEXT: (projectName: string) =>
`Context primed for project: ${projectName}. Access memories with chroma_query_documents(["${projectName}*"]) or chroma_get_documents(["document_id"]).`,
RECENT_SESSIONS: (sessionList: string) =>
`Recent sessions available: ${sessionList}`,
AVAILABLE_ENTITIES: (type: string, entities: string[], hasMore: boolean, moreCount: number) =>
`Available ${type} entities: ${entities.join(', ')}${hasMore ? ` (+${moreCount} more)` : ''}`,
SESSION_START_HEADER: '🧠 Active Working Context from Previous Sessions:',
SESSION_START_SEPARATOR: '═'.repeat(70),
RESUME_INSTRUCTIONS: `💡 TO RESUME: Load active components with chroma_get_documents(["<exact_document_ids>"])
📊 TO EXPLORE: Search related work with chroma_query_documents(["<keywords>"])`
} as const;
// =============================================================================
// SESSION START OUTPUT TEMPLATES
// =============================================================================
/**
* Session start formatting templates
*/
export const SESSION_START_TEMPLATES = {
FOCUS_LINE: (focus: string) => `📌 CURRENT FOCUS: ${focus}`,
LAST_WORKED: (timeAgo: string, projectName: string) => `Last worked: ${timeAgo} | Project: ${projectName}`,
SECTIONS: {
COMPONENTS: '🎯 ACTIVE COMPONENTS (load these for context):',
DECISIONS: '🔄 RECENT DECISIONS & PATTERNS:',
TOOLS: '🛠️ TOOLS & INFRASTRUCTURE:',
FIXES: '🐛 RECENT FIXES:',
ACTIONS: '⚡ NEXT ACTIONS:'
},
ACTION_PREFIX: '□ ',
ENTITY_BULLET: '• '
} as const;
/**
* Time formatting for "time ago" displays
*/
export const TIME_FORMATS = {
JUST_NOW: 'just now',
HOURS_AGO: (hours: number) => `${hours} hour${hours > 1 ? 's' : ''} ago`,
DAYS_AGO: (days: number) => `${days} day${days > 1 ? 's' : ''} ago`,
RECENTLY: 'recently'
} as const;
// =============================================================================
// HOOK RESPONSE TEMPLATES
// =============================================================================
/**
* Standard hook response structures for Claude Code integration
*/
export const HOOK_RESPONSES = {
SUCCESS: (hookEventName: string, message: string) => ({
hookSpecificOutput: {
hookEventName,
status: "success",
message
},
suppressOutput: true
}),
SKIPPED: (hookEventName: string, message: string) => ({
hookSpecificOutput: {
hookEventName,
status: "skipped",
message
},
suppressOutput: true
}),
BLOCKED: (reason: string) => ({
decision: "block",
reason
}),
CONTINUE: (hookEventName: string, additionalContext?: string) => ({
continue: true,
...(additionalContext && {
hookSpecificOutput: {
hookEventName,
additionalContext
}
})
}),
ERROR: (reason: string) => ({
decision: "block",
reason
})
} as const;
/**
* Pre-defined hook messages
*/
export const HOOK_MESSAGES = {
COMPRESSION_SUCCESS: "Memory compression completed successfully",
COMPRESSION_FAILED: (stderr: string) => `Compression failed: ${stderr}`,
CONTEXT_LOADED: "Project context loaded successfully",
CONTEXT_SKIPPED: "Continuing session - context loading skipped",
NO_TRANSCRIPT: "No transcript path provided",
HOOK_ERROR: (error: string) => `Hook error: ${error}`
} as const;
/**
* Export hook templates for direct usage
*/
export { HookTemplates };
+159
View File
@@ -0,0 +1,159 @@
/**
* Hook Prompt Renderer
*
* Simple template rendering for hook prompts.
* Handles variable substitution and auto-truncation.
*/
import {
PROMPTS,
HOOK_CONFIG,
type SystemPromptVariables,
type ToolMessageVariables,
type EndMessageVariables,
} from './hook-prompts.config.js';
// =============================================================================
// TEMPLATE RENDERING
// =============================================================================
/**
* Simple template variable substitution
* Replaces {{variableName}} with actual values
*/
function substituteVariables(
template: string,
variables: Record<string, string>
): string {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
// Replace all occurrences of this placeholder
result = result.split(placeholder).join(value);
}
return result;
}
/**
* Truncate text with ellipsis if it exceeds maxLength
*/
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + (text.length > maxLength ? '...' : '');
}
/**
* Format timestamp for tool message header
* Extracts HH:MM:SS from ISO timestamp
*/
function formatTime(timestamp: string): string {
const timePart = timestamp.split('T')[1];
if (!timePart) return '';
return timePart.slice(0, 8); // HH:MM:SS
}
// =============================================================================
// PUBLIC RENDERING FUNCTIONS
// =============================================================================
/**
* Render system prompt for SDK session initialization
*/
export function renderSystemPrompt(
variables: SystemPromptVariables
): string {
// Auto-truncate userPrompt
const userPromptTruncated = truncate(
variables.userPrompt,
HOOK_CONFIG.maxUserPromptLength
);
return substituteVariables(PROMPTS.system, {
project: variables.project,
sessionId: variables.sessionId,
date: variables.date,
userPrompt: userPromptTruncated,
});
}
/**
* Render tool message for SDK processing
*/
export function renderToolMessage(
variables: ToolMessageVariables
): string {
// Auto-truncate userPrompt and toolResponse
const userPromptTruncated = truncate(
variables.userPrompt,
HOOK_CONFIG.maxUserPromptLength
);
const toolResponseTruncated = truncate(
variables.toolResponse,
HOOK_CONFIG.maxToolResponseLength
);
// Format timestamp
const timeFormatted = formatTime(variables.timestamp);
return substituteVariables(PROMPTS.tool, {
toolName: variables.toolName,
toolResponse: toolResponseTruncated,
userPrompt: userPromptTruncated,
timestamp: variables.timestamp,
timeFormatted,
});
}
/**
* Render end message for session completion
*/
export function renderEndMessage(
variables: EndMessageVariables
): string {
return substituteVariables(PROMPTS.end, {
project: variables.project,
sessionId: variables.sessionId,
});
}
// =============================================================================
// GENERIC RENDERER (for convenience)
// =============================================================================
export type PromptType = 'system' | 'tool' | 'end';
export type PromptVariables<T extends PromptType> = T extends 'system'
? SystemPromptVariables
: T extends 'tool'
? ToolMessageVariables
: T extends 'end'
? EndMessageVariables
: never;
/**
* Generic prompt renderer - dispatches to specific renderer based on type
*/
export function renderPrompt<T extends PromptType>(
type: T,
variables: PromptVariables<T>
): string {
switch (type) {
case 'system':
return renderSystemPrompt(variables as SystemPromptVariables);
case 'tool':
return renderToolMessage(variables as ToolMessageVariables);
case 'end':
return renderEndMessage(variables as EndMessageVariables);
default:
throw new Error(`Unknown prompt type: ${type}`);
}
}
// =============================================================================
// EXPORTS
// =============================================================================
export { HOOK_CONFIG, PROMPTS };
+306
View File
@@ -0,0 +1,306 @@
/**
* Hook Prompts Configuration
*
* Centralized configuration for all streaming hook prompts.
* This is the SINGLE SOURCE OF TRUTH for hook prompt content.
*
* EDITING GUIDE:
* - Use {{variableName}} for template variables
* - Available variables are listed in each prompt's interface
* - All prompts are processed through renderPrompt() function
* - Changes here apply to all hooks automatically after rebuild
*
* LIFECYCLE FLOW:
* 1. user-prompt-submit: Initializes SDK session with systemPrompt
* 2. post-tool-use: Feeds tool responses using toolMessage (repeats N times)
* 3. stop: Ends session and requests overview using endMessage
*/
// =============================================================================
// TYPE DEFINITIONS
// =============================================================================
export interface SystemPromptVariables {
project: string;
sessionId: string;
date: string;
userPrompt: string; // Auto-truncated to maxUserPromptLength
}
export interface ToolMessageVariables {
toolName: string;
toolResponse: string; // Auto-truncated to maxToolResponseLength
userPrompt: string; // Auto-truncated to maxUserPromptLength
timestamp: string; // Full ISO timestamp
timeFormatted: string; // HH:MM:SS format
}
export interface EndMessageVariables {
project: string;
sessionId: string;
}
// =============================================================================
// SHARED CONFIGURATION
// =============================================================================
export const HOOK_CONFIG = {
// Truncation limits for template variables
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
// SDK configuration (used by hooks)
sdk: {
model: 'claude-sonnet-4-5',
allowedTools: ['Bash'],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048,
},
} as const;
// =============================================================================
// PHASE 1: SYSTEM PROMPT (user-prompt-submit-streaming.js)
// =============================================================================
/**
* System prompt that initializes the SDK session.
* Instructs the SDK on how to process tool responses and store memories.
*
* Variables:
* - {{project}}: Project name (from cwd basename)
* - {{sessionId}}: Claude Code session ID
* - {{date}}: Current date (YYYY-MM-DD)
* - {{userPrompt}}: User's initial prompt (truncated to 200 chars)
*/
export const SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
// =============================================================================
// PHASE 2: TOOL MESSAGE (post-tool-use-streaming.js)
// =============================================================================
/**
* Message format for each tool response sent to the SDK.
* The SDK analyzes this and decides whether to store a memory.
*
* Variables:
* - {{timeFormatted}}: Time portion of timestamp (HH:MM:SS)
* - {{toolName}}: Name of the tool that was used
* - {{userPrompt}}: User's original prompt (truncated to 200 chars)
* - {{toolResponse}}: Full tool response (truncated to 20000 chars)
*/
export const TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
// =============================================================================
// PHASE 3: END MESSAGE (stop-streaming.js)
// =============================================================================
/**
* Message sent to SDK when session ends.
* Requests the SDK to generate and store a session overview.
*
* Variables:
* - {{project}}: Project name
* - {{sessionId}}: Claude Code session ID
*/
export const END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
// =============================================================================
// EXPORTS
// =============================================================================
export const PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE,
} as const;
-30
View File
@@ -1,30 +0,0 @@
/**
* Prompts Module - Single source of truth for all prompt generation
*
* This module provides a centralized system for generating prompts across
* the claude-mem system. It includes the core PromptOrchestrator class
* and all related TypeScript interfaces.
*/
// Export all interfaces
export type {
AnalysisContext,
SessionContext,
HookContext,
AnalysisPrompt,
SessionPrompt,
HookResponse,
} from '../core/orchestration/PromptOrchestrator.js';
// Export the main class
export {
PromptOrchestrator,
} from '../core/orchestration/PromptOrchestrator.js';
// Export factory functions
export {
createPromptOrchestrator,
createAnalysisContext,
createSessionContext,
createHookContext,
} from '../core/orchestration/PromptOrchestrator.js';
-190
View File
@@ -1,190 +0,0 @@
# Claude Memory Templates
This directory contains modular templates for the Claude Memory System, including LLM analysis prompts and system integration responses.
## Files
### AnalysisTemplates.ts
The main template system for LLM analysis prompts. Contains clean, separated template functions for:
- **Entity extraction instructions** - Guidelines for identifying and categorizing technical entities
- **Relationship mapping instructions** - Rules for creating meaningful connections between entities
- **Output format specifications** - Exact format requirements for pipe-separated summaries
- **Example outputs** - Sample outputs to guide the LLM
- **MCP tool usage instructions** - Step-by-step MCP tool usage workflow
- **Dynamic content injection helpers** - Functions for injecting project/session context
### HookTemplates.ts
System integration templates for Claude Code hook responses. Provides standardized templates for:
- **Pre-compact hook responses** - Approve/block compression operations with proper formatting
- **Session-start hook responses** - Load and format context with rich memory information
- **Pre-tool use hook responses** - Security policies and permission controls
- **Error handling templates** - User-friendly error messages with troubleshooting guidance
- **Progress indicators** - Status updates for long-running operations
- **Response validation** - Ensures compliance with Claude Code hook specifications
### ContextTemplates.ts
Human-readable formatting templates for user-facing messages during memory operations.
### Legacy Templates
- `analysis-template.txt` - Legacy mustache-style template (deprecated)
- `session-start-template.txt` - Legacy mustache-style template (deprecated)
## Architecture
The new template system follows these principles:
1. **Pure Functions** - Each template function takes context and returns formatted strings
2. **Modular Design** - Complex prompts are broken into focused, reusable components
3. **Type Safety** - Full TypeScript support with proper interfaces
4. **Context Injection** - Dynamic content injection through helper functions
5. **Composable Templates** - Build complex prompts by combining template sections
## Usage
### Hook Templates Usage
```typescript
import {
createPreCompactSuccessResponse,
createSessionStartMemoryResponse,
createPreToolUseAllowResponse,
validateHookResponse
} from './HookTemplates.js';
// Pre-compact hook: approve compression
const preCompactResponse = createPreCompactSuccessResponse();
console.log(JSON.stringify(preCompactResponse));
// Output: {"continue": true, "suppressOutput": true}
// Session start hook: load context with memories
const sessionResponse = createSessionStartMemoryResponse({
projectName: 'claude-mem',
memoryCount: 15,
lastSessionTime: '2 hours ago',
recentComponents: ['HookTemplates', 'PromptOrchestrator'],
recentDecisions: ['Use TypeScript for type safety']
});
console.log(JSON.stringify(sessionResponse));
// Pre-tool use: allow memory tools
const toolResponse = createPreToolUseAllowResponse('Memory operations are always permitted');
console.log(JSON.stringify(toolResponse));
// Validate responses before sending
const validation = validateHookResponse(preCompactResponse, 'PreCompact');
if (!validation.isValid) {
console.error('Invalid response:', validation.errors);
}
```
### Analysis Templates Usage
```typescript
import { buildCompleteAnalysisPrompt } from './AnalysisTemplates.js';
const prompt = buildCompleteAnalysisPrompt(
'myproject', // projectPrefix
'session123', // sessionId
[], // toolUseChains
'2024-01-01', // timestamp (optional)
'archive.jsonl' // archiveFilename (optional)
);
```
### Individual Template Components
```typescript
import {
createEntityExtractionInstructions,
createOutputFormatSpecification,
createExampleOutput
} from './AnalysisTemplates.js';
// Get just the entity extraction guidelines
const entityInstructions = createEntityExtractionInstructions('myproject');
// Get output format specification
const outputFormat = createOutputFormatSpecification('2024-01-01', 'archive.jsonl');
// Get example output
const examples = createExampleOutput('myproject', 'session123');
```
### Context Injection
```typescript
import {
injectProjectContext,
injectSessionContext,
validateTemplateContext
} from './AnalysisTemplates.js';
// Validate context before using templates
const context = { projectPrefix: 'myproject', sessionId: 'session123' };
const errors = validateTemplateContext(context);
if (errors.length > 0) {
console.error('Invalid context:', errors);
}
// Inject dynamic content into template strings
let template = "Working on {{projectPrefix}} session {{sessionId}}";
template = injectProjectContext(template, 'myproject');
template = injectSessionContext(template, 'session123');
```
## Template Sections
### Entity Extraction Instructions
- Categories of entities to extract (components, patterns, decisions, etc.)
- Naming conventions with project prefixes
- Entity type classifications
- Observation field templates
### Relationship Mapping
- Available relationship types
- Active-voice relationship guidelines
- Graph connection strategies
### Output Format
- Pipe-separated format specification
- Required fields and exact values
- Summary writing guidelines
### MCP Tool Usage
- Step-by-step MCP tool workflow
- Entity creation instructions
- Relationship creation guidelines
### Critical Requirements
- Entity count requirements (3-15 entities)
- Relationship count requirements (5-20 relationships)
- Output line requirements (3-10 summaries)
- Format validation rules
## Benefits Over Legacy System
1. **Maintainability** - Separated concerns make individual sections easy to update
2. **Testability** - Pure functions can be unit tested independently
3. **Reusability** - Template components can be reused across different contexts
4. **Debugging** - Easy to isolate issues to specific template sections
5. **Type Safety** - Full TypeScript support prevents runtime template errors
6. **Performance** - No string parsing overhead, direct function composition
## Migration from constants.ts
The massive `createAnalysisPrompt` function in `constants.ts` has been refactored into this modular system:
**Before** (130+ lines in single function):
```typescript
export function createAnalysisPrompt(...) {
// Massive template string with embedded logic
return `You are analyzing...${incrementalSection}${toolChains}...`;
}
```
**After** (clean delegation):
```typescript
export function createAnalysisPrompt(...) {
return buildCompleteAnalysisPrompt(...);
}
```
This maintains backward compatibility while providing a much cleaner, more maintainable internal structure.
@@ -1,118 +0,0 @@
/**
* Analysis Templates for LLM Instructions
*
* Generates prompts for extracting memories from conversations and storing in Chroma
*/
import Handlebars from 'handlebars';
// =============================================================================
// MAIN ANALYSIS PROMPT TEMPLATE
// =============================================================================
const ANALYSIS_PROMPT = `You are analyzing a Claude Code conversation transcript to create memories using the Chroma MCP memory system.
YOUR TASK:
1. Extract key learnings and accomplishments as natural language memories
2. Store memories using mcp__claude-mem__chroma_add_documents
3. Return a structured JSON response with the extracted summaries
WHAT TO EXTRACT:
- Technical implementations (functions, classes, APIs, databases)
- Design patterns and architectural decisions
- Bug fixes and problem solutions
- Workflows, processes, and integrations
- Performance optimizations and improvements
STORAGE INSTRUCTIONS:
Call mcp__claude-mem__chroma_add_documents with:
- collection_name: "claude_memories"
- documents: Array of natural language descriptions
- ids: ["{{projectPrefix}}_{{sessionId}}_1", "{{projectPrefix}}_{{sessionId}}_2", ...]
- metadatas: Array with fields:
* type: component/pattern/workflow/integration/concept/decision/tool/fix
* keywords: Comma-separated search terms
* context: Brief situation description
* timestamp: "{{timestamp}}"
* session_id: "{{sessionId}}"
ERROR HANDLING:
If you get "IDs already exist" errors, use mcp__claude-mem__chroma_update_documents instead.
If any tool calls fail, continue and return the JSON response anyway.
Project: {{projectPrefix}}
Session ID: {{sessionId}}
Conversation to compress:`;
// Compile template once
const compiledAnalysisPrompt = Handlebars.compile(ANALYSIS_PROMPT, { noEscape: true });
// =============================================================================
// MAIN API FUNCTIONS
// =============================================================================
/**
* Creates the comprehensive analysis prompt for memory extraction
*/
export function buildComprehensiveAnalysisPrompt(
projectPrefix: string,
sessionId: string,
timestamp?: string,
archiveFilename?: string
): string {
const context = {
projectPrefix,
sessionId,
timestamp: timestamp || new Date().toISOString(),
archiveFilename: archiveFilename || `${sessionId}.jsonl.archive`
};
return compiledAnalysisPrompt(context);
}
/**
* Creates the analysis prompt
*/
export function createAnalysisPrompt(
transcript: string,
sessionId: string,
projectPrefix: string,
timestamp?: string
): string {
const prompt = buildComprehensiveAnalysisPrompt(
projectPrefix,
sessionId,
timestamp
);
const responseFormat = `
RESPONSE FORMAT:
After storing memories in Chroma, return EXACTLY this JSON structure wrapped in tags:
<JSONResponse>
{
"overview": "2-3 sentence summary of session themes and accomplishments. Write for any developer to understand by organically defining jargon.",
"summaries": [
{
"text": "What was accomplished (start with action verb)",
"document_id": "${projectPrefix}_${sessionId}_1",
"keywords": "comma, separated, terms",
"timestamp": "${timestamp || new Date().toISOString()}",
"archive": "${sessionId}.jsonl.archive"
}
]
}
</JSONResponse>
IMPORTANT:
- Return 3-10 summaries based on conversation complexity
- Each summary should correspond to a memory you attempted to store
- If tool calls fail, still return the JSON response with summaries
- The JSON must be valid and complete
- Place NOTHING outside the <JSONResponse> tags
- Do not include any explanatory text before or after the JSON`;
return prompt + '\n\n' + transcript + responseFormat;
}
@@ -103,61 +103,6 @@ function makeLine(char: string = '─', width: number = getWrapWidth()): string
// SESSION START MESSAGES
// =============================================================================
/**
* Creates a welcoming session start message explaining what memories were loaded
*/
export function createSessionStartMessage(
projectName: string,
memoryCount: number,
lastSessionTime?: string
): string {
const width = getWrapWidth();
const timeInfo = lastSessionTime ? ` (last worked: ${lastSessionTime})` : '';
if (memoryCount === 0) {
return wrapText(
`🧠 Loading memories from previous sessions for ${projectName}${timeInfo}
No relevant memories found - this appears to be your first session or a new project area.
💡 Getting Started:
Start working and memories will be automatically created
At the end of your session, ask to compress and store the conversation
Next time you return, relevant context will be loaded automatically`,
width
);
}
const memoryText =
memoryCount === 1 ? 'relevant memory' : 'relevant memories';
return wrapText(
`🧠 Loading memories from previous sessions for ${projectName}${timeInfo}
Found ${memoryCount} ${memoryText} to help continue your work.`,
width
);
}
// =============================================================================
// OPERATION MESSAGES
// =============================================================================
/**
* Creates a loading message during context retrieval
*/
export function createLoadingMessage(operation: string): string {
const operations: Record<string, string> = {
searching: '🔍 Searching previous memories...',
loading: '📚 Loading relevant context...',
formatting: '✨ Organizing memories for display...',
compressing: '🗜️ Compressing session transcript...',
archiving: '📦 Archiving conversation...',
};
const width = getWrapWidth();
return wrapText(operations[operation] || `${operation}...`, width);
}
/**
* Creates a completion message after context operations
*/
@@ -263,28 +208,6 @@ export function formatTimeAgo(timestamp: string | Date): string {
return date.toLocaleDateString();
}
/**
* Creates summary text for memory operations
*/
export function createOperationSummary(
operation: 'compress' | 'load' | 'search' | 'archive',
results: { count: number; duration?: number; details?: string }
): string {
const { count, duration, details } = results;
const durationText = duration ? ` in ${duration}ms` : '';
const detailsText = details ? ` - ${details}` : '';
const templates = {
compress: `Compressed ${count} conversation turns${durationText}${detailsText}`,
load: `Loaded ${count} relevant memories${durationText}${detailsText}`,
search: `Found ${count} matching memories${durationText}${detailsText}`,
archive: `Archived ${count} conversation segments${durationText}${detailsText}`,
};
const width = getWrapWidth();
return wrapText(`📊 ${templates[operation]}`, width);
}
// =============================================================================
// SESSION START TEMPLATE SYSTEM (data processing only)
// =============================================================================
@@ -308,55 +231,6 @@ interface SessionGroup {
memories: MemoryEntry[];
}
/**
* Formats current date and time for session start
*/
export function formatCurrentDateTime(): string {
const now = new Date();
const currentDateTime = now.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
return `Current Date / Time: ${currentDateTime}\n`;
}
/**
* Extracts overview section from JSON objects
* Looks for objects with type "overview" and matching project
*/
export function extractOverview(
recentObjects: any[],
projectName?: string
): string | null {
// Find overview objects
const overviewObjects = recentObjects.filter(
(obj) => obj.type === 'overview'
);
if (overviewObjects.length === 0) {
return null;
}
// If project is specified, find overview for that project
if (projectName) {
const projectOverview = overviewObjects.find(
(obj) => obj.project === projectName
);
if (projectOverview) {
return projectOverview.content;
}
}
// Return the most recent overview if no project match
return overviewObjects[overviewObjects.length - 1]?.content || null;
}
/**
* Interface for overview with timestamp
*/
@@ -377,66 +251,11 @@ interface SessionOverviewGroup {
timeAgo?: string;
}
/**
* Extracts multiple overviews with timestamps
* Returns up to 'count' most recent overviews
*/
export function extractOverviews(
recentObjects: any[],
count: number = 3,
projectName?: string
): OverviewEntry[] {
// Find overview objects
const overviewObjects = recentObjects.filter(
(obj) => obj.type === 'overview'
);
if (overviewObjects.length === 0) {
return [];
}
// Filter by project if specified
let filteredOverviews = overviewObjects;
if (projectName) {
filteredOverviews = overviewObjects.filter(
(obj) => obj.project === projectName
);
// Fall back to all overviews if no project match
if (filteredOverviews.length === 0) {
filteredOverviews = overviewObjects;
}
}
// Take the last 'count' overviews
const recentOverviews = filteredOverviews.slice(-count);
// Process each overview with timestamp and session ID
return recentOverviews.map((obj) => {
const entry: OverviewEntry = {
content: obj.content || '',
sessionId: obj.sessionId || obj.session_id || 'unknown',
};
// Try to parse timestamp
const timestamp = parseTimestamp(obj);
if (timestamp) {
entry.timestamp = timestamp;
entry.timeAgo = formatRelativeTime(timestamp);
} else {
// Fallback if no timestamp
entry.timeAgo = 'Recently';
}
return entry;
}); // Show in original order (oldest to newest)
}
/**
* Pure data processing function - converts JSON objects into structured memory entries
* No formatting is done here, only data parsing and cleaning
*/
export function processMemoryEntries(recentObjects: any[]): MemoryEntry[] {
function processMemoryEntries(recentObjects: any[]): MemoryEntry[] {
if (recentObjects.length === 0) {
return [];
}
@@ -1,185 +0,0 @@
/**
* Hook Templates Test
*
* Basic validation tests for hook response templates to ensure they
* generate valid responses that conform to Claude Code's hook system.
*/
import {
createPreCompactSuccessResponse,
createPreCompactBlockedResponse,
createPreCompactApprovalResponse,
createSessionStartSuccessResponse,
createSessionStartEmptyResponse,
createSessionStartErrorResponse,
createSessionStartMemoryResponse,
createPreToolUseAllowResponse,
createPreToolUseDenyResponse,
createPreToolUseAskResponse,
createHookSuccessResponse,
createHookErrorResponse,
validateHookResponse,
createContextualHookResponse,
formatDuration,
createOperationSummary,
OPERATION_STATUS_TEMPLATES,
ERROR_RESPONSE_TEMPLATES
} from './HookTemplates.js';
// =============================================================================
// PRE-COMPACT HOOK TESTS
// =============================================================================
console.log('Testing Pre-Compact Hook Templates...');
// Test successful pre-compact response
const preCompactSuccess = createPreCompactSuccessResponse();
console.log('✓ Pre-compact success:', JSON.stringify(preCompactSuccess, null, 2));
// Test blocked pre-compact response
const preCompactBlocked = createPreCompactBlockedResponse('User requested to skip compression');
console.log('✓ Pre-compact blocked:', JSON.stringify(preCompactBlocked, null, 2));
// Test approval response
const preCompactApproval = createPreCompactApprovalResponse('approve', 'Compression approved by policy');
console.log('✓ Pre-compact approval:', JSON.stringify(preCompactApproval, null, 2));
// =============================================================================
// SESSION START HOOK TESTS
// =============================================================================
console.log('\nTesting Session Start Hook Templates...');
// Test successful session start with context
const sessionStartSuccess = createSessionStartSuccessResponse('Loaded 5 memories from previous sessions');
console.log('✓ Session start success:', JSON.stringify(sessionStartSuccess, null, 2));
// Test empty session start
const sessionStartEmpty = createSessionStartEmptyResponse();
console.log('✓ Session start empty:', JSON.stringify(sessionStartEmpty, null, 2));
// Test error session start
const sessionStartError = createSessionStartErrorResponse('Memory index corrupted');
console.log('✓ Session start error:', JSON.stringify(sessionStartError, null, 2));
// Test rich memory response
const sessionStartMemory = createSessionStartMemoryResponse({
projectName: 'claude-mem',
memoryCount: 12,
lastSessionTime: '2 hours ago',
recentComponents: ['PromptOrchestrator', 'HookTemplates', 'MCPClient'],
recentDecisions: ['Use TypeScript for type safety', 'Implement embedded Weaviate']
});
console.log('✓ Session start memory:', JSON.stringify(sessionStartMemory, null, 2));
// =============================================================================
// PRE-TOOL USE HOOK TESTS
// =============================================================================
console.log('\nTesting Pre-Tool Use Hook Templates...');
// Test allow response
const preToolAllow = createPreToolUseAllowResponse('Tool execution approved by security policy');
console.log('✓ Pre-tool allow:', JSON.stringify(preToolAllow, null, 2));
// Test deny response
const preToolDeny = createPreToolUseDenyResponse('Bash commands disabled in restricted mode');
console.log('✓ Pre-tool deny:', JSON.stringify(preToolDeny, null, 2));
// Test ask response
const preToolAsk = createPreToolUseAskResponse('File operation requires user confirmation');
console.log('✓ Pre-tool ask:', JSON.stringify(preToolAsk, null, 2));
// =============================================================================
// GENERIC HOOK TESTS
// =============================================================================
console.log('\nTesting Generic Hook Templates...');
// Test basic success
const genericSuccess = createHookSuccessResponse(false);
console.log('✓ Generic success:', JSON.stringify(genericSuccess, null, 2));
// Test basic error
const genericError = createHookErrorResponse('Operation failed due to network timeout', true);
console.log('✓ Generic error:', JSON.stringify(genericError, null, 2));
// =============================================================================
// VALIDATION TESTS
// =============================================================================
console.log('\nTesting Hook Response Validation...');
// Test valid PreCompact response
const preCompactValidation = validateHookResponse(preCompactSuccess, 'PreCompact');
console.log('✓ PreCompact validation:', preCompactValidation);
// Test invalid PreCompact response (with hookSpecificOutput)
const invalidPreCompact = {
continue: true,
hookSpecificOutput: { hookEventName: 'PreCompact' }
};
const preCompactInvalidValidation = validateHookResponse(invalidPreCompact, 'PreCompact');
console.log('✓ PreCompact invalid validation:', preCompactInvalidValidation);
// Test valid SessionStart response
const sessionStartValidation = validateHookResponse(sessionStartSuccess, 'SessionStart');
console.log('✓ SessionStart validation:', sessionStartValidation);
// =============================================================================
// CONTEXTUAL HOOK RESPONSE TESTS
// =============================================================================
console.log('\nTesting Contextual Hook Responses...');
// Test successful session start context
const contextualSessionStart = createContextualHookResponse({
hookEventName: 'SessionStart',
sessionId: 'test-123',
success: true,
message: 'Successfully loaded 8 memories from previous claude-mem sessions'
});
console.log('✓ Contextual SessionStart:', JSON.stringify(contextualSessionStart, null, 2));
// Test failed PreCompact context
const contextualPreCompactFail = createContextualHookResponse({
hookEventName: 'PreCompact',
sessionId: 'test-123',
success: false,
message: 'Compression blocked: insufficient disk space'
});
console.log('✓ Contextual PreCompact fail:', JSON.stringify(contextualPreCompactFail, null, 2));
// =============================================================================
// UTILITY FUNCTION TESTS
// =============================================================================
console.log('\nTesting Utility Functions...');
// Test duration formatting
console.log('✓ Duration 500ms:', formatDuration(500));
console.log('✓ Duration 5s:', formatDuration(5000));
console.log('✓ Duration 90s:', formatDuration(90000));
console.log('✓ Duration 2m30s:', formatDuration(150000));
// Test operation summary
console.log('✓ Operation summary success:', createOperationSummary('Memory compression', true, 5000, 15, 'entities extracted'));
console.log('✓ Operation summary failure:', createOperationSummary('Context loading', false, 2000, 0, 'connection timeout'));
// =============================================================================
// TEMPLATE CONSTANT TESTS
// =============================================================================
console.log('\nTesting Template Constants...');
// Test operation status templates
console.log('✓ Compression complete:', OPERATION_STATUS_TEMPLATES.COMPRESSION_COMPLETE(25, 5000));
console.log('✓ Context loaded:', OPERATION_STATUS_TEMPLATES.CONTEXT_LOADED(8));
console.log('✓ Tool allowed:', OPERATION_STATUS_TEMPLATES.TOOL_ALLOWED('Bash'));
// Test error response templates
console.log('✓ File not found:', ERROR_RESPONSE_TEMPLATES.FILE_NOT_FOUND('/path/to/transcript.txt'));
console.log('✓ Connection failed:', ERROR_RESPONSE_TEMPLATES.CONNECTION_FAILED('MCP memory server'));
console.log('✓ Operation timeout:', ERROR_RESPONSE_TEMPLATES.OPERATION_TIMEOUT('compression', 30000));
console.log('\n✅ All hook template tests completed successfully!');
@@ -1,546 +0,0 @@
/**
* Hook Templates for System Integration
*
* This module provides standardized templates for hook responses that integrate
* with Claude Code's hook system. These templates ensure consistent formatting
* and proper JSON structure for different hook events.
*
* Based on Claude Code Hook Documentation v2025
*/
import {
BaseHookResponse,
PreCompactResponse,
SessionStartResponse,
PreToolUseResponse,
HookPayload,
PreCompactPayload,
SessionStartPayload
} from '../../../shared/types.js';
// =============================================================================
// HOOK RESPONSE INTERFACES
// =============================================================================
/**
* Context data for generating hook responses
*/
export interface HookResponseContext {
/** The hook event name */
hookEventName: string;
/** Session identifier */
sessionId: string;
/** Whether the operation was successful */
success: boolean;
/** Optional message for the response */
message?: string;
/** Additional data specific to the hook type */
additionalData?: Record<string, unknown>;
/** Duration of the operation in milliseconds */
duration?: number;
/** Number of items processed */
itemCount?: number;
}
/**
* Progress information for long-running operations
*/
export interface OperationProgress {
/** Current step number */
current: number;
/** Total number of steps */
total: number;
/** Description of current step */
currentStep?: string;
/** Estimated time remaining in milliseconds */
estimatedRemaining?: number;
}
// =============================================================================
// PRE-COMPACT HOOK TEMPLATES
// =============================================================================
/**
* Creates a successful pre-compact response that allows compression to proceed
* PreCompact hooks do NOT support hookSpecificOutput according to documentation
*/
export function createPreCompactSuccessResponse(): PreCompactResponse {
return {
continue: true,
suppressOutput: true
};
}
/**
* Creates a blocked pre-compact response that prevents compression
*/
export function createPreCompactBlockedResponse(reason: string): PreCompactResponse {
return {
continue: false,
stopReason: reason,
suppressOutput: true
};
}
/**
* Creates a pre-compact response with approval decision
*/
export function createPreCompactApprovalResponse(
decision: 'approve' | 'block',
reason?: string
): PreCompactResponse {
return {
decision,
reason,
continue: decision === 'approve',
suppressOutput: true
};
}
// =============================================================================
// SESSION START HOOK TEMPLATES
// =============================================================================
/**
* Creates a successful session start response with loaded context
* SessionStart hooks DO support hookSpecificOutput
*/
export function createSessionStartSuccessResponse(
additionalContext?: string
): SessionStartResponse {
return {
continue: true,
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
};
}
/**
* Creates a session start response when no context is available
*/
export function createSessionStartEmptyResponse(): SessionStartResponse {
return {
continue: true,
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: 'Starting fresh session - no previous context available'
}
};
}
/**
* Creates a session start response with error information
*/
export function createSessionStartErrorResponse(error: string): SessionStartResponse {
return {
continue: true, // Continue even if context loading fails
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: `Context loading encountered an issue: ${error}. Starting without previous context.`
}
};
}
/**
* Creates a rich session start response with memory summary
*/
export function createSessionStartMemoryResponse(memoryData: {
projectName: string;
memoryCount: number;
lastSessionTime?: string;
recentComponents?: string[];
recentDecisions?: string[];
}): SessionStartResponse {
const { projectName, memoryCount, lastSessionTime, recentComponents = [], recentDecisions = [] } = memoryData;
const timeInfo = lastSessionTime ? ` (last worked: ${lastSessionTime})` : '';
const contextParts: string[] = [];
contextParts.push(`🧠 Loaded ${memoryCount} memories from previous sessions for ${projectName}${timeInfo}`);
if (recentComponents.length > 0) {
contextParts.push(`\n🎯 Recent components: ${recentComponents.slice(0, 3).join(', ')}`);
}
if (recentDecisions.length > 0) {
contextParts.push(`\n🔄 Recent decisions: ${recentDecisions.slice(0, 2).join(', ')}`);
}
contextParts.push('\n💡 Use chroma_query_documents(["keywords"]) to find related work or chroma_get_documents(["document_id"]) to load specific content');
return {
continue: true,
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: contextParts.join('')
}
};
}
// =============================================================================
// PRE-TOOL USE HOOK TEMPLATES
// =============================================================================
/**
* Creates a pre-tool use response that allows the tool to execute
*/
export function createPreToolUseAllowResponse(reason?: string): PreToolUseResponse {
return {
continue: true,
suppressOutput: true,
permissionDecision: 'allow',
permissionDecisionReason: reason
};
}
/**
* Creates a pre-tool use response that blocks the tool execution
*/
export function createPreToolUseDenyResponse(reason: string): PreToolUseResponse {
return {
continue: false,
stopReason: reason,
suppressOutput: true,
permissionDecision: 'deny',
permissionDecisionReason: reason
};
}
/**
* Creates a pre-tool use response that asks for user confirmation
*/
export function createPreToolUseAskResponse(reason: string): PreToolUseResponse {
return {
continue: true,
suppressOutput: false, // Show output so user can see the question
permissionDecision: 'ask',
permissionDecisionReason: reason
};
}
// =============================================================================
// GENERIC HOOK RESPONSE TEMPLATES
// =============================================================================
/**
* Creates a basic success response for any hook type
*/
export function createHookSuccessResponse(suppressOutput = true): BaseHookResponse {
return {
continue: true,
suppressOutput
};
}
/**
* Creates a basic error response for any hook type
*/
export function createHookErrorResponse(
reason: string,
suppressOutput = true
): BaseHookResponse {
return {
continue: false,
stopReason: reason,
suppressOutput
};
}
/**
* Creates a response with system message (warning/info for user)
*/
export function createHookSystemMessageResponse(
message: string,
continueProcessing = true
): BaseHookResponse & { systemMessage: string } {
return {
continue: continueProcessing,
suppressOutput: true,
systemMessage: message
};
}
// =============================================================================
// OPERATION STATUS TEMPLATES
// =============================================================================
/**
* Templates for different types of operation status messages
*/
export const OPERATION_STATUS_TEMPLATES = {
// Compression operations
COMPRESSION_STARTED: 'Starting memory compression...',
COMPRESSION_ANALYZING: 'Analyzing transcript content...',
COMPRESSION_EXTRACTING: 'Extracting memories and connections...',
COMPRESSION_SAVING: 'Saving compressed memories...',
COMPRESSION_COMPLETE: (count: number, duration?: number) =>
`Memory compression complete. Extracted ${count} memories${duration ? ` in ${Math.round(duration/1000)}s` : ''}`,
// Context loading operations
CONTEXT_LOADING: 'Loading previous session context...',
CONTEXT_SEARCHING: 'Searching for relevant memories...',
CONTEXT_FORMATTING: 'Organizing context for display...',
CONTEXT_LOADED: (count: number) => `Context loaded successfully. Found ${count} relevant memories`,
CONTEXT_EMPTY: 'No previous context found. Starting fresh session',
// Tool operations
TOOL_CHECKING: (toolName: string) => `Checking permissions for ${toolName}...`,
TOOL_ALLOWED: (toolName: string) => `${toolName} execution approved`,
TOOL_BLOCKED: (toolName: string, reason: string) => `${toolName} blocked: ${reason}`,
// General operations
OPERATION_STARTING: (operation: string) => `Starting ${operation}...`,
OPERATION_PROGRESS: (operation: string, current: number, total: number) =>
`${operation}: ${current}/${total} (${Math.round((current/total)*100)}%)`,
OPERATION_COMPLETE: (operation: string) => `${operation} completed successfully`,
OPERATION_FAILED: (operation: string, error: string) => `${operation} failed: ${error}`
} as const;
/**
* Creates a progress message for long-running operations
*/
export function createProgressMessage(
operation: string,
progress: OperationProgress
): string {
const { current, total, currentStep, estimatedRemaining } = progress;
const percentage = Math.round((current / total) * 100);
let message = `${operation}: ${current}/${total} (${percentage}%)`;
if (currentStep) {
message += ` - ${currentStep}`;
}
if (estimatedRemaining && estimatedRemaining > 1000) {
const seconds = Math.round(estimatedRemaining / 1000);
message += ` (${seconds}s remaining)`;
}
return message;
}
// =============================================================================
// ERROR RESPONSE TEMPLATES
// =============================================================================
/**
* Standard error messages for different failure scenarios
*/
export const ERROR_RESPONSE_TEMPLATES = {
// File system errors
FILE_NOT_FOUND: (path: string) => `File not found: ${path}`,
FILE_READ_ERROR: (path: string, error: string) => `Failed to read ${path}: ${error}`,
FILE_WRITE_ERROR: (path: string, error: string) => `Failed to write ${path}: ${error}`,
// Network/connection errors
CONNECTION_FAILED: (service: string) => `Failed to connect to ${service}`,
CONNECTION_TIMEOUT: (service: string) => `Connection to ${service} timed out`,
// Validation errors
INVALID_PAYLOAD: (field: string) => `Invalid or missing field: ${field}`,
INVALID_FORMAT: (expected: string, received: string) => `Expected ${expected}, received ${received}`,
// Operation errors
OPERATION_TIMEOUT: (operation: string, timeout: number) =>
`${operation} timed out after ${timeout}ms`,
OPERATION_CANCELLED: (operation: string) => `${operation} was cancelled`,
INSUFFICIENT_PERMISSIONS: (operation: string) =>
`Insufficient permissions for ${operation}`,
// Memory system errors
MEMORY_SYSTEM_UNAVAILABLE: 'Memory system is not available',
MEMORY_CORRUPTION: 'Memory index appears to be corrupted',
MEMORY_SEARCH_FAILED: (query: string) => `Memory search failed for query: "${query}"`,
// Compression errors
COMPRESSION_FAILED: (stage: string) => `Compression failed during ${stage}`,
INVALID_TRANSCRIPT: 'Transcript file is invalid or corrupted',
// General errors
UNKNOWN_ERROR: (context: string) => `An unexpected error occurred during ${context}`,
SYSTEM_ERROR: (error: string) => `System error: ${error}`
} as const;
/**
* Creates a standardized error response with troubleshooting guidance
*/
export function createDetailedErrorResponse(
operation: string,
error: string,
troubleshootingSteps: string[] = []
): BaseHookResponse {
const baseMessage = `${operation} failed: ${error}`;
const fullMessage = troubleshootingSteps.length > 0
? `${baseMessage}\n\nTroubleshooting steps:\n${troubleshootingSteps.map(step => `${step}`).join('\n')}`
: baseMessage;
return {
continue: false,
stopReason: fullMessage,
suppressOutput: false // Show error details to user
};
}
// =============================================================================
// HOOK RESPONSE VALIDATION
// =============================================================================
/**
* Validates that a hook response conforms to Claude Code expectations
*/
export function validateHookResponse(
response: any,
hookType: string
): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
// Check required fields
if (typeof response !== 'object' || response === null) {
errors.push('Response must be a valid JSON object');
return { isValid: false, errors };
}
// Validate continue field
if (response.continue !== undefined && typeof response.continue !== 'boolean') {
errors.push('continue field must be a boolean');
}
// Validate suppressOutput field
if (response.suppressOutput !== undefined && typeof response.suppressOutput !== 'boolean') {
errors.push('suppressOutput field must be a boolean');
}
// Validate stopReason field
if (response.stopReason !== undefined && typeof response.stopReason !== 'string') {
errors.push('stopReason field must be a string');
}
// Hook-specific validations
if (hookType === 'PreCompact') {
// PreCompact should not have hookSpecificOutput
if (response.hookSpecificOutput !== undefined) {
errors.push('PreCompact hooks do not support hookSpecificOutput');
}
// Validate decision field if present
if (response.decision !== undefined && !['approve', 'block'].includes(response.decision)) {
errors.push('decision field must be "approve" or "block"');
}
}
if (hookType === 'SessionStart') {
// Validate hookSpecificOutput structure
if (response.hookSpecificOutput) {
const hso = response.hookSpecificOutput;
if (hso.hookEventName !== 'SessionStart') {
errors.push('hookSpecificOutput.hookEventName must be "SessionStart"');
}
if (hso.additionalContext !== undefined && typeof hso.additionalContext !== 'string') {
errors.push('hookSpecificOutput.additionalContext must be a string');
}
}
}
if (hookType === 'PreToolUse') {
// Validate permissionDecision field
if (response.permissionDecision !== undefined) {
if (!['allow', 'deny', 'ask'].includes(response.permissionDecision)) {
errors.push('permissionDecision must be "allow", "deny", or "ask"');
}
}
}
return {
isValid: errors.length === 0,
errors
};
}
// =============================================================================
// UTILITY FUNCTIONS
// =============================================================================
/**
* Creates a hook response based on context and automatically handles hook-specific formatting
*/
export function createContextualHookResponse(context: HookResponseContext): BaseHookResponse {
const { hookEventName, success, message, additionalData, duration, itemCount } = context;
// Base response
const response: BaseHookResponse = {
continue: success,
suppressOutput: true
};
// Add failure reason if not successful
if (!success && message) {
response.stopReason = message;
response.suppressOutput = false; // Show error to user
}
// Handle hook-specific output
if (success && hookEventName === 'SessionStart' && message) {
return {
...response,
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: message
}
} as SessionStartResponse;
}
// Handle PreCompact approval
if (hookEventName === 'PreCompact') {
return {
...response,
decision: success ? 'approve' : 'block',
reason: message
} as PreCompactResponse;
}
return response;
}
/**
* Formats duration in milliseconds to human-readable format
*/
export function formatDuration(milliseconds: number): string {
if (milliseconds < 1000) {
return `${milliseconds}ms`;
}
const seconds = Math.round(milliseconds / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
}
/**
* Creates a summary line for operation completion
*/
export function createOperationSummary(
operation: string,
success: boolean,
duration?: number,
itemCount?: number,
details?: string
): string {
const status = success ? '✅' : '❌';
const durationText = duration ? ` in ${formatDuration(duration)}` : '';
const itemText = itemCount ? ` (${itemCount} items)` : '';
const detailText = details ? ` - ${details}` : '';
return `${status} ${operation}${itemText}${durationText}${detailText}`;
}
-364
View File
@@ -1,364 +0,0 @@
import * as p from '@clack/prompts';
import { TranscriptParser } from './transcript-parser.js';
import path from 'path';
import fs from 'fs';
/**
* Conversation item for selection UI
*/
export interface ConversationItem {
filePath: string;
sessionId: string;
timestamp: string;
messageCount: number;
gitBranch?: string;
cwd: string;
fileSize: number;
displayName: string;
projectName: string;
parsedDate: Date;
relativeDate: string;
dateGroup: string;
}
/**
* Selection result
*/
export interface SelectionResult {
selectedFiles: string[];
cancelled: boolean;
}
/**
* Interactive conversation selector service
*/
export class ConversationSelector {
private parser: TranscriptParser;
constructor() {
this.parser = new TranscriptParser();
}
/**
* Show interactive selection UI for conversations with improved flow
*/
async selectConversations(): Promise<SelectionResult> {
p.intro('🧠 Claude History Import');
const s = p.spinner();
s.start('Scanning for conversation files...');
const conversationFiles = await this.parser.scanConversationFiles();
if (conversationFiles.length === 0) {
s.stop('❌ No conversation files found');
p.outro('No conversation files found in Claude projects directory');
return { selectedFiles: [], cancelled: true };
}
// Get metadata for each file
const conversations: ConversationItem[] = [];
for (const filePath of conversationFiles) {
try {
const metadata = await this.parser.getConversationMetadata(filePath);
const projectName = this.extractProjectName(filePath);
const parsedDate = this.parseTimestamp(metadata.timestamp, filePath);
const relativeDate = this.formatRelativeDate(parsedDate);
const dateGroup = this.getDateGroup(parsedDate);
conversations.push({
filePath,
...metadata,
projectName,
parsedDate,
relativeDate,
dateGroup,
displayName: this.createDisplayName(filePath, metadata)
});
} catch (e) {
// Skip invalid files silently
}
}
if (conversations.length === 0) {
s.stop('❌ No valid conversation files found');
p.outro('No valid conversation files found');
return { selectedFiles: [], cancelled: true };
}
s.stop(`Found ${conversations.length} conversation files`);
// Sort by timestamp (newest first)
conversations.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
// If there are too many conversations, offer filtering options first
let filteredConversations = conversations;
if (conversations.length > 100) {
const filterChoice = await p.select({
message: `Found ${conversations.length} conversations. How would you like to proceed?`,
options: [
{ value: 'recent', label: 'Show recent (last 50)', hint: 'Most recent conversations' },
{ value: 'project', label: 'Filter by project', hint: 'Select specific project first' },
{ value: 'all', label: 'Show all', hint: `Display all ${conversations.length} conversations` }
]
});
if (p.isCancel(filterChoice)) {
p.cancel('Selection cancelled');
return { selectedFiles: [], cancelled: true };
}
if (filterChoice === 'recent') {
filteredConversations = conversations.slice(0, 50);
} else if (filterChoice === 'project') {
const projectNames = [...new Set(conversations.map(c => c.projectName))].sort();
const selectedProject = await p.select({
message: 'Select project:',
options: projectNames.map(project => {
const count = conversations.filter(c => c.projectName === project).length;
return {
value: project,
label: project,
hint: `${count} conversation${count === 1 ? '' : 's'}`
};
})
});
if (p.isCancel(selectedProject)) {
p.cancel('Selection cancelled');
return { selectedFiles: [], cancelled: true };
}
filteredConversations = conversations.filter(c => c.projectName === selectedProject);
}
}
// Conversation selection
const selectedConversations = await this.selectConversationsFromList(filteredConversations);
if (!selectedConversations || selectedConversations.length === 0) {
p.cancel('No conversations selected');
return { selectedFiles: [], cancelled: true };
}
// Confirmation
const confirmed = await this.confirmSelection(selectedConversations);
if (!confirmed) {
p.cancel('Import cancelled');
return { selectedFiles: [], cancelled: true };
}
p.outro(`Ready to import ${selectedConversations.length} conversations`);
return { selectedFiles: selectedConversations.map(c => c.filePath), cancelled: false };
}
/**
* Extract project name from file path
*/
private extractProjectName(filePath: string): string {
return path.basename(path.dirname(filePath));
}
/**
* Safely parse timestamp with fallback to file modification time
*/
private parseTimestamp(timestamp: string | undefined, filePath: string): Date {
// Try parsing the provided timestamp
if (timestamp) {
const date = new Date(timestamp);
if (!isNaN(date.getTime())) {
return date;
}
}
// Fallback to file modification time
try {
const stats = fs.statSync(filePath);
return stats.mtime;
} catch (e) {
// Last resort: current time
return new Date();
}
}
/**
* Format date as relative time (e.g., "2 days ago", "3 weeks ago")
*/
private formatRelativeDate(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 4) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
const diffYears = Math.floor(diffMonths / 12);
return `${diffYears}y ago`;
}
/**
* Get date group for grouping conversations
*/
private getDateGroup(date: Date): string {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const thisWeekStart = new Date(today.getTime() - today.getDay() * 24 * 60 * 60 * 1000);
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const conversationDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (conversationDate.getTime() >= today.getTime()) {
return 'Today';
} else if (conversationDate.getTime() >= yesterday.getTime()) {
return 'Yesterday';
} else if (conversationDate.getTime() >= thisWeekStart.getTime()) {
return 'This Week';
} else if (conversationDate.getTime() >= lastWeekStart.getTime()) {
return 'Last Week';
} else if (conversationDate.getTime() >= thisMonthStart.getTime()) {
return 'This Month';
} else {
return 'Older';
}
}
/**
* Create display name for conversation
*/
private createDisplayName(filePath: string, metadata: any): string {
const parsedDate = this.parseTimestamp(metadata.timestamp, filePath);
const relativeDate = this.formatRelativeDate(parsedDate);
const sizeKB = Math.round(metadata.fileSize / 1024);
const branchInfo = metadata.gitBranch ? `${metadata.gitBranch}` : '';
return `${relativeDate}${metadata.messageCount} msgs • ${sizeKB}KB${branchInfo ? `${branchInfo}` : ''}`;
}
/**
* Select specific conversations from list
*/
private async selectConversationsFromList(
conversations: ConversationItem[]
): Promise<ConversationItem[] | null> {
// Group conversations by date for better organization
const groupedConversations = this.groupConversationsByDate(conversations);
const options = this.createGroupedOptions(groupedConversations, conversations);
// Multi-select with select all/none shortcuts
const selectedIndices = await p.multiselect({
message: `Select conversations to import (${conversations.length} available, Space=toggle, Enter=confirm):`,
options,
required: false
});
if (p.isCancel(selectedIndices)) return null;
// Return selected conversations
const selected = selectedIndices as number[];
if (selected.length === 0) {
return [];
}
return selected.map(i => conversations[i]);
}
/**
* Confirm selection before processing
*/
private async confirmSelection(conversations: ConversationItem[]): Promise<boolean> {
const totalSize = conversations.reduce((sum, c) => sum + c.fileSize, 0);
const sizeKB = Math.round(totalSize / 1024);
const projects = [...new Set(conversations.map(c => c.projectName))];
const details = [
`${conversations.length} conversation${conversations.length === 1 ? '' : 's'}`,
`${projects.length} project${projects.length === 1 ? '' : 's'}: ${projects.join(', ')}`,
`Total size: ${sizeKB}KB`
].join('\n');
const confirmed = await p.confirm({
message: `Ready to import:\n\n${details}\n\nContinue?`,
initialValue: true
});
return !p.isCancel(confirmed) && confirmed;
}
/**
* Group conversations by date sections
*/
private groupConversationsByDate(conversations: ConversationItem[]): Map<string, ConversationItem[]> {
const groups = new Map<string, ConversationItem[]>();
for (const conv of conversations) {
const group = conv.dateGroup;
if (!groups.has(group)) {
groups.set(group, []);
}
groups.get(group)!.push(conv);
}
return groups;
}
/**
* Create options with date group headers
*/
private createGroupedOptions(groupedConversations: Map<string, ConversationItem[]>, allConversations: ConversationItem[]) {
const options: any[] = [];
// Add hint at top about selecting all/none
options.push({
value: 'hint',
label: '💡 Use Space to toggle, A to select all, I to invert',
disabled: true
});
options.push({ value: 'separator-hint', label: '─'.repeat(60), disabled: true });
// Define order of groups
const groupOrder = ['Today', 'Yesterday', 'This Week', 'Last Week', 'This Month', 'Older'];
for (const groupName of groupOrder) {
const conversations = groupedConversations.get(groupName);
if (!conversations || conversations.length === 0) continue;
// Add group header (disabled option for visual separation)
if (options.length > 2) { // Account for hint and separator
options.push({ value: `separator-${groupName}`, label: '─'.repeat(50), disabled: true });
}
options.push({
value: `header-${groupName}`,
label: `${groupName} (${conversations.length})`,
disabled: true
});
// Add conversations in this group
for (const conv of conversations) {
const index = allConversations.indexOf(conv);
const projectInfo = conv.projectName ? `[${conv.projectName}]` : '';
const workingDir = conv.cwd && conv.cwd !== 'undefined' ? path.basename(conv.cwd) : '';
const hint = `${projectInfo} ${workingDir}`.trim() || (conv.gitBranch ? `Branch: ${conv.gitBranch}` : '');
options.push({
value: index,
label: ` ${conv.displayName}`,
hint: hint
});
}
}
return options;
}
}
+47 -88
View File
@@ -181,7 +181,9 @@ export class PathDiscovery {
const packageJsonPath = require.resolve('claude-mem/package.json');
this._packageRoot = dirname(packageJsonPath);
return this._packageRoot;
} catch {}
} catch {
// Continue to next method
}
// Method 2: Walk up from current module location
const currentFile = fileURLToPath(import.meta.url);
@@ -190,15 +192,13 @@ export class PathDiscovery {
for (let i = 0; i < 10; i++) {
const packageJsonPath = join(currentDir, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const packageJson = require(packageJsonPath);
if (packageJson.name === 'claude-mem') {
this._packageRoot = currentDir;
return this._packageRoot;
}
} catch {}
const packageJson = require(packageJsonPath);
if (packageJson.name === 'claude-mem') {
this._packageRoot = currentDir;
return this._packageRoot;
}
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
@@ -206,36 +206,46 @@ export class PathDiscovery {
// Method 3: Try npm list command
try {
const npmOutput = execSync('npm list -g claude-mem --json 2>/dev/null || npm list claude-mem --json 2>/dev/null', {
encoding: 'utf8'
const npmOutput = execSync('npm list -g claude-mem --json 2>/dev/null || npm list claude-mem --json 2>/dev/null', {
encoding: 'utf8'
});
const npmData = JSON.parse(npmOutput);
if (npmData.dependencies?.['claude-mem']?.resolved) {
this._packageRoot = dirname(npmData.dependencies['claude-mem'].resolved);
return this._packageRoot;
}
} catch {}
} catch {
// Continue to error
}
throw new Error('Cannot locate claude-mem package root. Ensure claude-mem is properly installed.');
}
/**
* Find hooks directory in the installed package
* Find hook templates directory in the installed package
*
* This returns the SOURCE templates directory that gets copied during installation
* to the runtime hooks directory (~/.claude-mem/hooks/)
*/
findPackageHooksDirectory(): string {
findPackageHookTemplatesDirectory(): string {
const packageRoot = this.getPackageRoot();
const hooksDir = join(packageRoot, 'hooks');
// Verify it contains expected hook files
const requiredHooks = ['pre-compact.js', 'session-start.js'];
for (const hookFile of requiredHooks) {
if (!existsSync(join(hooksDir, hookFile))) {
throw new Error(`Package hooks directory missing required file: ${hookFile}`);
const hookTemplatesDir = join(packageRoot, 'hook-templates');
// Verify it contains expected hook template files
const requiredHookTemplates = [
'session-start.js',
'stop.js',
'user-prompt-submit.js',
'post-tool-use.js'
];
for (const hookTemplateFile of requiredHookTemplates) {
if (!existsSync(join(hookTemplatesDir, hookTemplateFile))) {
throw new Error(`Package hook-templates directory missing required template file: ${hookTemplateFile}`);
}
}
return hooksDir;
return hookTemplatesDir;
}
/**
@@ -325,9 +335,19 @@ export class PathDiscovery {
/**
* Get current project directory name
* Uses git repository root's basename if in a git repo, otherwise falls back to cwd basename
*/
static getCurrentProjectName(): string {
return require('path').basename(process.cwd());
try {
const gitRoot = execSync('git rev-parse --show-toplevel', {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
return require('path').basename(gitRoot);
} catch {
return require('path').basename(process.cwd());
}
}
/**
@@ -347,68 +367,7 @@ export class PathDiscovery {
* Check if a path exists and is accessible
*/
static isPathAccessible(path: string): boolean {
try {
return existsSync(path) && statSync(path).isDirectory();
} catch {
return false;
}
return existsSync(path) && statSync(path).isDirectory();
}
// =============================================================================
// STATIC CONVENIENCE METHODS
// =============================================================================
/**
* Quick access to singleton instance methods
*/
static getDataDirectory(): string {
return PathDiscovery.getInstance().getDataDirectory();
}
static getArchivesDirectory(): string {
return PathDiscovery.getInstance().getArchivesDirectory();
}
static getHooksDirectory(): string {
return PathDiscovery.getInstance().getHooksDirectory();
}
static getLogsDirectory(): string {
return PathDiscovery.getInstance().getLogsDirectory();
}
static getClaudeSettingsPath(): string {
return PathDiscovery.getInstance().getClaudeSettingsPath();
}
static getClaudeMdPath(): string {
return PathDiscovery.getInstance().getClaudeMdPath();
}
static findPackageHooksDirectory(): string {
return PathDiscovery.getInstance().findPackageHooksDirectory();
}
static findPackageCommandsDirectory(): string {
return PathDiscovery.getInstance().findPackageCommandsDirectory();
}
}
// Export singleton instance for immediate use
export const pathDiscovery = PathDiscovery.getInstance();
// Export static methods for convenience
export const {
getDataDirectory,
getArchivesDirectory,
getHooksDirectory,
getLogsDirectory,
getClaudeSettingsPath,
getClaudeMdPath,
findPackageHooksDirectory,
findPackageCommandsDirectory,
extractProjectName,
getCurrentProjectName,
createBackupFilename,
isPathAccessible
} = PathDiscovery;
}
+49 -9
View File
@@ -17,12 +17,12 @@ export class MemoryStore {
*/
create(input: MemoryInput): MemoryRow {
const { isoString, epoch } = normalizeTimestamp(input.created_at);
const stmt = this.db.prepare(`
INSERT INTO memories (
session_id, text, document_id, keywords, created_at, created_at_epoch,
project, archive_basename, origin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
session_id, text, document_id, keywords, created_at, created_at_epoch,
project, archive_basename, origin, title, subtitle, facts, concepts, files_touched
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
@@ -34,7 +34,12 @@ export class MemoryStore {
epoch,
input.project,
input.archive_basename || null,
input.origin || 'transcript'
input.origin || 'transcript',
input.title || null,
input.subtitle || null,
input.facts || null,
input.concepts || null,
input.files_touched || null
);
return this.getById(info.lastInsertRowid as number)!;
@@ -158,15 +163,44 @@ export class MemoryStore {
* Get memories by origin type
*/
getByOrigin(origin: string, limit?: number): MemoryRow[] {
const query = limit
const query = limit
? 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC LIMIT ?'
: 'SELECT * FROM memories WHERE origin = ? ORDER BY created_at_epoch DESC';
const stmt = this.db.prepare(query);
const params = limit ? [origin, limit] : [origin];
return stmt.all(...params) as MemoryRow[];
}
/**
* Get recent memories for a project filtered by origin
*/
getRecentForProjectByOrigin(project: string, origin: string, limit = 10): MemoryRow[] {
const stmt = this.db.prepare(`
SELECT * FROM memories
WHERE project = ? AND origin = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, origin, limit) as MemoryRow[];
}
/**
* Get last N memories for a project, sorted oldest to newest
*/
getLastNForProject(project: string, limit = 10): MemoryRow[] {
const stmt = this.db.prepare(`
SELECT * FROM (
SELECT * FROM memories
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
)
ORDER BY created_at_epoch ASC
`);
return stmt.all(project, limit) as MemoryRow[];
}
/**
* Count total memories
*/
@@ -195,11 +229,12 @@ export class MemoryStore {
}
const { isoString, epoch } = normalizeTimestamp(input.created_at || existing.created_at);
const stmt = this.db.prepare(`
UPDATE memories SET
text = ?, document_id = ?, keywords = ?, created_at = ?, created_at_epoch = ?,
project = ?, archive_basename = ?, origin = ?
project = ?, archive_basename = ?, origin = ?, title = ?, subtitle = ?, facts = ?,
concepts = ?, files_touched = ?
WHERE id = ?
`);
@@ -212,6 +247,11 @@ export class MemoryStore {
input.project || existing.project,
input.archive_basename !== undefined ? input.archive_basename : existing.archive_basename,
input.origin || existing.origin,
input.title !== undefined ? input.title : existing.title,
input.subtitle !== undefined ? input.subtitle : existing.subtitle,
input.facts !== undefined ? input.facts : existing.facts,
input.concepts !== undefined ? input.concepts : existing.concepts,
input.files_touched !== undefined ? input.files_touched : existing.files_touched,
id
);
+47 -2
View File
@@ -68,14 +68,26 @@ export class OverviewStore {
*/
getRecentForProject(project: string, limit = 5): OverviewRow[] {
const stmt = this.db.prepare(`
SELECT * FROM overviews
SELECT * FROM overviews
WHERE project = ?
ORDER BY created_at_epoch DESC
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, limit) as OverviewRow[];
}
/**
* Get all overviews for a project (oldest to newest)
*/
getAllForProject(project: string): OverviewRow[] {
const stmt = this.db.prepare(`
SELECT * FROM overviews
WHERE project = ?
ORDER BY created_at_epoch ASC
`);
return stmt.all(project) as OverviewRow[];
}
/**
* Get recent overviews across all projects
*/
@@ -193,4 +205,37 @@ export class OverviewStore {
const rows = stmt.all() as { project: string }[];
return rows.map(row => row.project);
}
/**
* Get most recent overview for a specific project
*/
getByProject(project: string): OverviewRow | null {
const stmt = this.db.prepare(`
SELECT * FROM overviews
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT 1
`);
return stmt.get(project) as OverviewRow || null;
}
/**
* Create or update overview for a project (keeps only most recent)
*/
upsertByProject(input: OverviewInput): OverviewRow {
const existing = this.getByProject(input.project);
if (existing) {
return this.update(existing.id, input);
}
return this.create(input);
}
/**
* Delete overview by project name
*/
deleteByProject(project: string): boolean {
const stmt = this.db.prepare('DELETE FROM overviews WHERE project = ?');
const info = stmt.run(project);
return info.changes > 0;
}
}
@@ -0,0 +1,266 @@
import { Database } from 'better-sqlite3';
import { getDatabase } from './Database.js';
import { normalizeTimestamp } from './types.js';
/**
* Represents a streaming session row in the database
*/
export interface StreamingSessionRow {
id: number;
claude_session_id: string;
sdk_session_id?: string;
project: string;
title?: string;
subtitle?: string;
user_prompt?: string;
started_at: string;
started_at_epoch: number;
updated_at?: string;
updated_at_epoch?: number;
completed_at?: string;
completed_at_epoch?: number;
status: 'active' | 'completed' | 'failed';
}
/**
* Input type for creating a new streaming session
*/
export interface StreamingSessionInput {
claude_session_id: string;
project: string;
user_prompt?: string;
started_at?: string | Date | number;
}
/**
* Input type for updating a streaming session
*/
export interface StreamingSessionUpdate {
sdk_session_id?: string;
title?: string;
subtitle?: string;
status?: 'active' | 'completed' | 'failed';
completed_at?: string | Date | number;
}
/**
* Data Access Object for streaming session records
* Handles real-time session tracking during SDK compression
*/
export class StreamingSessionStore {
private db: Database.Database;
constructor(db?: Database.Database) {
this.db = db || getDatabase();
}
/**
* Create a new streaming session record
* This should be called immediately when the hook receives a user prompt
*/
create(input: StreamingSessionInput): StreamingSessionRow {
const { isoString, epoch } = normalizeTimestamp(input.started_at);
const stmt = this.db.prepare(`
INSERT INTO streaming_sessions (
claude_session_id, project, user_prompt, started_at, started_at_epoch, status
) VALUES (?, ?, ?, ?, ?, 'active')
`);
const info = stmt.run(
input.claude_session_id,
input.project,
input.user_prompt || null,
isoString,
epoch
);
return this.getById(info.lastInsertRowid as number)!;
}
/**
* Update a streaming session by internal ID
* Uses atomic transaction to prevent race conditions
*/
update(id: number, updates: StreamingSessionUpdate): StreamingSessionRow {
const { isoString: updatedAt, epoch: updatedEpoch } = normalizeTimestamp(new Date());
const existing = this.getById(id);
if (!existing) {
throw new Error(`Streaming session with id ${id} not found`);
}
const parts: string[] = [];
const values: any[] = [];
if (updates.sdk_session_id !== undefined) {
parts.push('sdk_session_id = ?');
values.push(updates.sdk_session_id);
}
if (updates.title !== undefined) {
parts.push('title = ?');
values.push(updates.title);
}
if (updates.subtitle !== undefined) {
parts.push('subtitle = ?');
values.push(updates.subtitle);
}
if (updates.status !== undefined) {
parts.push('status = ?');
values.push(updates.status);
}
if (updates.completed_at !== undefined) {
const { isoString, epoch } = normalizeTimestamp(updates.completed_at);
parts.push('completed_at = ?', 'completed_at_epoch = ?');
values.push(isoString, epoch);
}
// Always update the updated_at timestamp
parts.push('updated_at = ?', 'updated_at_epoch = ?');
values.push(updatedAt, updatedEpoch);
values.push(id);
const stmt = this.db.prepare(`
UPDATE streaming_sessions
SET ${parts.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return this.getById(id)!;
}
/**
* Update a streaming session by Claude session ID
* Convenience method for hooks that only have the Claude session ID
*/
updateByClaudeSessionId(claudeSessionId: string, updates: StreamingSessionUpdate): StreamingSessionRow | null {
const session = this.getByClaudeSessionId(claudeSessionId);
if (!session) {
return null;
}
return this.update(session.id, updates);
}
/**
* Get streaming session by internal ID
*/
getById(id: number): StreamingSessionRow | null {
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE id = ?');
return stmt.get(id) as StreamingSessionRow || null;
}
/**
* Get streaming session by Claude session ID
*/
getByClaudeSessionId(claudeSessionId: string): StreamingSessionRow | null {
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE claude_session_id = ?');
return stmt.get(claudeSessionId) as StreamingSessionRow || null;
}
/**
* Get streaming session by SDK session ID
*/
getBySdkSessionId(sdkSessionId: string): StreamingSessionRow | null {
const stmt = this.db.prepare('SELECT * FROM streaming_sessions WHERE sdk_session_id = ?');
return stmt.get(sdkSessionId) as StreamingSessionRow || null;
}
/**
* Check if a streaming session exists by Claude session ID
*/
has(claudeSessionId: string): boolean {
const stmt = this.db.prepare('SELECT 1 FROM streaming_sessions WHERE claude_session_id = ? LIMIT 1');
return Boolean(stmt.get(claudeSessionId));
}
/**
* Get active streaming sessions for a project
*/
getActiveForProject(project: string): StreamingSessionRow[] {
const stmt = this.db.prepare(`
SELECT * FROM streaming_sessions
WHERE project = ? AND status = 'active'
ORDER BY started_at_epoch DESC
`);
return stmt.all(project) as StreamingSessionRow[];
}
/**
* Get all active streaming sessions
*/
getAllActive(): StreamingSessionRow[] {
const stmt = this.db.prepare(`
SELECT * FROM streaming_sessions
WHERE status = 'active'
ORDER BY started_at_epoch DESC
`);
return stmt.all() as StreamingSessionRow[];
}
/**
* Get recent streaming sessions (completed or failed)
*/
getRecent(limit = 10): StreamingSessionRow[] {
const stmt = this.db.prepare(`
SELECT * FROM streaming_sessions
ORDER BY started_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as StreamingSessionRow[];
}
/**
* Mark a session as completed
*/
markCompleted(id: number): StreamingSessionRow {
return this.update(id, {
status: 'completed',
completed_at: new Date()
});
}
/**
* Mark a session as failed
*/
markFailed(id: number): StreamingSessionRow {
return this.update(id, {
status: 'failed',
completed_at: new Date()
});
}
/**
* Delete a streaming session by ID
*/
deleteById(id: number): boolean {
const stmt = this.db.prepare('DELETE FROM streaming_sessions WHERE id = ?');
const info = stmt.run(id);
return info.changes > 0;
}
/**
* Delete a streaming session by Claude session ID
*/
deleteByClaudeSessionId(claudeSessionId: string): boolean {
const stmt = this.db.prepare('DELETE FROM streaming_sessions WHERE claude_session_id = ?');
const info = stmt.run(claudeSessionId);
return info.changes > 0;
}
/**
* Clean up old completed/failed sessions (older than N days)
*/
cleanupOldSessions(daysOld = 30): number {
const cutoffEpoch = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
const stmt = this.db.prepare(`
DELETE FROM streaming_sessions
WHERE status IN ('completed', 'failed')
AND completed_at_epoch < ?
`);
const info = stmt.run(cutoffEpoch);
return info.changes;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Database } from 'better-sqlite3';
import { getDatabase } from './Database.js';
import {
TranscriptEventInput,
TranscriptEventRow,
normalizeTimestamp
} from './types.js';
/**
* Data access for transcript_events table
*/
export class TranscriptEventStore {
private db: Database.Database;
constructor(db?: Database.Database) {
this.db = db || getDatabase();
}
/**
* Insert or update a transcript event
*/
upsert(event: TranscriptEventInput): TranscriptEventRow {
const { isoString, epoch } = normalizeTimestamp(event.captured_at);
const stmt = this.db.prepare(`
INSERT INTO transcript_events (
session_id,
project,
event_index,
event_type,
raw_json,
captured_at,
captured_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, event_index) DO UPDATE SET
project = excluded.project,
event_type = excluded.event_type,
raw_json = excluded.raw_json,
captured_at = excluded.captured_at,
captured_at_epoch = excluded.captured_at_epoch
`);
stmt.run(
event.session_id,
event.project || null,
event.event_index,
event.event_type || null,
event.raw_json,
isoString,
epoch
);
return this.getBySessionAndIndex(event.session_id, event.event_index)!;
}
/**
* Bulk upsert events in a single transaction
*/
upsertMany(events: TranscriptEventInput[]): TranscriptEventRow[] {
const transaction = this.db.transaction((rows: TranscriptEventInput[]) => {
const results: TranscriptEventRow[] = [];
for (const row of rows) {
results.push(this.upsert(row));
}
return results;
});
return transaction(events);
}
/**
* Get event by session and index
*/
getBySessionAndIndex(sessionId: string, eventIndex: number): TranscriptEventRow | null {
const stmt = this.db.prepare(`
SELECT * FROM transcript_events
WHERE session_id = ? AND event_index = ?
`);
return stmt.get(sessionId, eventIndex) as TranscriptEventRow | null;
}
/**
* Get highest event_index stored for a session
*/
getMaxEventIndex(sessionId: string): number {
const stmt = this.db.prepare(`
SELECT MAX(event_index) as max_event_index
FROM transcript_events
WHERE session_id = ?
`);
const row = stmt.get(sessionId) as { max_event_index: number | null } | undefined;
return row?.max_event_index ?? -1;
}
/**
* List recent events for a session
*/
listBySession(sessionId: string, limit = 200, offset = 0): TranscriptEventRow[] {
const stmt = this.db.prepare(`
SELECT * FROM transcript_events
WHERE session_id = ?
ORDER BY event_index ASC
LIMIT ? OFFSET ?
`);
return stmt.all(sessionId, limit, offset) as TranscriptEventRow[];
}
}
+21 -7
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,41 @@ 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 { StreamingSessionStore } from './StreamingSessionStore.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');
const { StreamingSessionStore } = await import('./StreamingSessionStore.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),
streamingSessions: new StreamingSessionStore(db)
};
}
+212
View File
@@ -0,0 +1,212 @@
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');
}
};
/**
* Migration 003 - Add streaming_sessions table for real-time session tracking
*/
export const migration003: Migration = {
version: 3,
up: (db: Database.Database) => {
// Streaming sessions table - tracks active SDK compression sessions
db.exec(`
CREATE TABLE IF NOT EXISTS streaming_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
sdk_session_id TEXT,
project TEXT NOT NULL,
title TEXT,
subtitle TEXT,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
updated_at TEXT,
updated_at_epoch INTEGER,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_claude_id ON streaming_sessions(claude_session_id);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_sdk_id ON streaming_sessions(sdk_session_id);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_project ON streaming_sessions(project);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_status ON streaming_sessions(status);
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_started ON streaming_sessions(started_at_epoch DESC);
`);
console.log('✅ Created streaming_sessions table for real-time session tracking');
},
down: (db: Database.Database) => {
db.exec(`
DROP TABLE IF EXISTS streaming_sessions;
`);
}
};
/**
* All migrations in order
*/
export const migrations: Migration[] = [
migration001,
migration002,
migration003
];
@@ -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');
}
};
-17
View File
@@ -1,17 +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);
console.log('📋 Registered all database migrations');
}
// Auto-register migrations when this module is imported
registerMigrations();
+33 -1
View File
@@ -37,6 +37,12 @@ export interface MemoryRow {
project: string;
archive_basename?: string;
origin: string;
// Hierarchical memory fields (v2)
title?: string;
subtitle?: string;
facts?: string; // JSON array of fact strings
concepts?: string; // JSON array of concept strings
files_touched?: string; // JSON array of file paths
}
export interface DiagnosticRow {
@@ -50,6 +56,17 @@ export interface DiagnosticRow {
origin: string;
}
export interface TranscriptEventRow {
id: number;
session_id: string;
project?: string;
event_index: number;
event_type?: string;
raw_json: string;
captured_at: string;
captured_at_epoch: number;
}
export interface ArchiveRow {
id: number;
session_id: string;
@@ -100,6 +117,12 @@ export interface MemoryInput {
project: string;
archive_basename?: string;
origin?: string;
// Hierarchical memory fields (v2)
title?: string;
subtitle?: string;
facts?: string; // JSON array of fact strings
concepts?: string; // JSON array of concept strings
files_touched?: string; // JSON array of file paths
}
export interface DiagnosticInput {
@@ -111,6 +134,15 @@ export interface DiagnosticInput {
origin?: string;
}
export interface TranscriptEventInput {
session_id: string;
project?: string;
event_index: number;
event_type?: string;
raw_json: string;
captured_at?: string | Date | number;
}
/**
* Helper function to normalize timestamps from various formats
*/
@@ -149,4 +181,4 @@ export function normalizeTimestamp(timestamp: string | Date | number | undefined
isoString: date.toISOString(),
epoch: date.getTime()
};
}
}
-218
View File
@@ -1,218 +0,0 @@
import fs from 'fs';
import path from 'path';
import { log } from '../shared/logger.js';
import { PathDiscovery } from './path-discovery.js';
/**
* Interface for Claude Code JSONL conversation entries
*/
export interface ClaudeCodeMessage {
sessionId: string;
timestamp: string;
gitBranch?: string;
cwd: string;
type: 'user' | 'assistant' | 'system' | 'result';
message: {
role: string;
content: Array<{
type: string;
text?: string;
thinking?: string;
}> | string;
};
uuid: string;
version?: string;
isSidechain?: boolean;
userType?: string;
parentUuid?: string;
subtype?: string;
model?: string;
stop_reason?: string;
usage?: any;
}
/**
* Interface matching TranscriptCompressor's expected format
*/
export interface TranscriptMessage {
type: string;
message?: {
content?: string | Array<{
text?: string;
content?: string;
}>;
role?: string;
timestamp?: string;
created_at?: string;
};
content?: string | Array<{
text?: string;
content?: string;
}>;
role?: string;
uuid?: string;
session_id?: string;
timestamp?: string;
created_at?: string;
subtype?: string;
}
/**
* Parsed conversation with metadata
*/
export interface ParsedConversation {
sessionId: string;
filePath: string;
messageCount: number;
timestamp: string;
gitBranch?: string;
cwd: string;
messages: TranscriptMessage[];
}
/**
* Service for parsing Claude Code JSONL conversation files
*/
export class TranscriptParser {
/**
* Parse a single JSONL conversation file
*/
async parseConversation(filePath: string): Promise<ParsedConversation> {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
const claudeMessages: ClaudeCodeMessage[] = [];
let parseErrors = 0;
for (let i = 0; i < lines.length; i++) {
try {
const parsed = JSON.parse(lines[i]);
claudeMessages.push(parsed);
} catch (e) {
parseErrors++;
log.debug(`Parse error on line ${i + 1}: ${(e as Error).message}`);
}
}
if (claudeMessages.length === 0) {
throw new Error(`No valid messages found in ${filePath}`);
}
// Get metadata from first message
const firstMessage = claudeMessages[0];
const sessionId = firstMessage.sessionId;
const timestamp = firstMessage.timestamp;
const gitBranch = firstMessage.gitBranch;
const cwd = firstMessage.cwd;
// Convert to TranscriptMessage format
const messages = claudeMessages.map(msg => this.convertMessage(msg));
log.debug(`Parsed ${filePath}: ${messages.length} messages, ${parseErrors} errors`);
return {
sessionId,
filePath,
messageCount: messages.length,
timestamp,
gitBranch,
cwd,
messages
};
}
/**
* Convert ClaudeCodeMessage to TranscriptMessage format
*/
private convertMessage(claudeMsg: ClaudeCodeMessage): TranscriptMessage {
const converted: TranscriptMessage = {
type: claudeMsg.type,
uuid: claudeMsg.uuid,
session_id: claudeMsg.sessionId,
timestamp: claudeMsg.timestamp,
subtype: claudeMsg.subtype
};
// Handle message content
if (claudeMsg.message) {
converted.message = {
role: claudeMsg.message.role,
timestamp: claudeMsg.timestamp
};
if (Array.isArray(claudeMsg.message.content)) {
// Convert content array to expected format
converted.message.content = claudeMsg.message.content.map(item => ({
text: item.text || item.thinking || '',
content: item.text || item.thinking || ''
}));
} else if (typeof claudeMsg.message.content === 'string') {
converted.message.content = claudeMsg.message.content;
}
}
return converted;
}
/**
* Scan Claude projects directory for conversation files
*/
async scanConversationFiles(): Promise<string[]> {
const pathDiscovery = PathDiscovery.getInstance();
const claudeDir = path.join(pathDiscovery.getClaudeConfigDirectory(), 'projects');
if (!fs.existsSync(claudeDir)) {
return [];
}
const projectDirs = fs.readdirSync(claudeDir);
const conversationFiles: string[] = [];
for (const projectDir of projectDirs) {
const projectPath = path.join(claudeDir, projectDir);
if (!fs.statSync(projectPath).isDirectory()) continue;
const files = fs.readdirSync(projectPath);
for (const file of files) {
if (file.endsWith('.jsonl')) {
conversationFiles.push(path.join(projectPath, file));
}
}
}
return conversationFiles;
}
/**
* Get conversation metadata without fully parsing
*/
async getConversationMetadata(filePath: string): Promise<{
sessionId: string;
timestamp: string;
messageCount: number;
gitBranch?: string;
cwd: string;
fileSize: number;
}> {
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
let firstMessage;
try {
firstMessage = JSON.parse(lines[0]);
} catch (e) {
throw new Error(`Invalid JSONL format in ${filePath}`);
}
return {
sessionId: firstMessage.sessionId,
timestamp: firstMessage.timestamp,
messageCount: lines.length,
gitBranch: firstMessage.gitBranch,
cwd: firstMessage.cwd,
fileSize: stats.size
};
}
}
-200
View File
@@ -1,200 +0,0 @@
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { HookError, CompressionError, Logger, FileLogger } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export class ErrorHandler {
private logger: Logger;
private logDir: string;
// <Block> 7.1 ====================================
constructor(enableDebug = false) {
this.logDir = join(__dirname, '..', 'logs');
this.ensureLogDirectory();
const logFile = join(
this.logDir,
`claude-mem-${new Date().toISOString().slice(0, 10)}.log`
);
this.logger = new FileLogger(logFile, enableDebug);
}
// </Block> =======================================
// <Block> 7.2 ====================================
private ensureLogDirectory(): void {
if (!existsSync(this.logDir)) {
mkdirSync(this.logDir, { recursive: true });
}
}
// </Block> =======================================
// <Block> 7.3 ====================================
handleHookError(error: Error, hookType: string, payload?: unknown): never {
// <Block> 7.3a ====================================
const hookError =
error instanceof HookError
? error
: new HookError(
error.message,
hookType,
payload as any,
'HOOK_EXECUTION_ERROR'
);
// </Block> =======================================
this.logger.error(`Hook execution failed in ${hookType}`, hookError, {
hookType,
payload: payload ? JSON.stringify(payload) : undefined,
});
console.log(
JSON.stringify({
continue: false,
stopReason: `Hook error: ${hookError.message}`,
error: {
type: hookError.name,
message: hookError.message,
code: hookError.code,
},
})
);
process.exit(1);
}
// </Block> =======================================
// <Block> 7.4 ====================================
handleCompressionError(
error: Error,
transcriptPath: string,
stage: string
): never {
// <Block> 7.4a ====================================
const compressionError =
error instanceof CompressionError
? error
: new CompressionError(error.message, transcriptPath, stage as any);
// </Block> =======================================
this.logger.error(`Compression failed during ${stage}`, compressionError, {
transcriptPath,
stage,
});
console.error(`Compression error: ${compressionError.message}`);
console.error(`Stage: ${stage}`);
console.error(`Transcript: ${transcriptPath}`);
process.exit(1);
}
// </Block> =======================================
// <Block> 7.5 ====================================
handleValidationError(
message: string,
context?: Record<string, unknown>
): never {
this.logger.error('Validation error', undefined, { message, context });
console.error(`Validation error: ${message}`);
// <Block> 7.5a ====================================
if (context) {
console.error('Context:', JSON.stringify(context, null, 2));
}
// </Block> =======================================
process.exit(1);
}
// </Block> =======================================
// <Block> 7.6 ====================================
logSuccess(operation: string, details?: Record<string, unknown>): void {
this.logger.info(`Operation successful: ${operation}`, details);
}
// </Block> =======================================
// <Block> 7.7 ====================================
logWarning(message: string, details?: Record<string, unknown>): void {
this.logger.warn(message, details);
}
// </Block> =======================================
// <Block> 7.8 ====================================
logDebug(message: string, details?: Record<string, unknown>): void {
this.logger.debug(message, details);
}
// </Block> =======================================
}
// <Block> 7.9 ====================================
export function parseStdinJson<T = unknown>(input: string): T {
try {
return JSON.parse(input) as T;
} catch (error) {
throw new Error(
`Failed to parse JSON input: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// </Block> =======================================
// <Block> 7.10 ===================================
export async function safeExecute<T>(
operation: () => Promise<T>,
errorHandler: ErrorHandler,
context: string
): Promise<T> {
try {
return await operation();
} catch (error) {
const message = `Safe execution failed in ${context}: ${error instanceof Error ? error.message : String(error)}`;
errorHandler.handleValidationError(message, { context, error });
throw error;
}
}
// </Block> =======================================
// <Block> 7.11 ===================================
export function validateFileExists(
filePath: string,
errorHandler: ErrorHandler
): void {
if (!existsSync(filePath)) {
errorHandler.handleValidationError(`File not found: ${filePath}`, {
filePath,
});
}
}
// </Block> =======================================
// <Block> 7.12 ===================================
/**
* Creates a standardized hook response using HookTemplates
* @deprecated Use HookTemplates.createHookSuccessResponse or createHookErrorResponse instead
* This function is maintained for backward compatibility but should be replaced with HookTemplates.
*/
export function createHookResponse(
success: boolean,
data?: Record<string, unknown>
): string {
// Log deprecation warning in development mode
if (process.env.NODE_ENV === 'development') {
console.warn('createHookResponse in error-handler.ts is deprecated. Use HookTemplates.createHookSuccessResponse or createHookErrorResponse instead.');
}
const response = {
continue: success,
suppressOutput: true, // Add standard suppressOutput field for Claude Code compatibility
...data,
};
return JSON.stringify(response);
}
// </Block> =======================================
export const globalErrorHandler = new ErrorHandler(
process.env.DEBUG === 'true'
);
+42
View File
@@ -0,0 +1,42 @@
import { appendFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { PathDiscovery } from '../services/path-discovery.js';
let logPath: string | null = null;
function ensureLogPath(): string {
if (logPath) {
return logPath;
}
const discovery = PathDiscovery.getInstance();
const logsDir = discovery.getLogsDirectory();
if (!existsSync(logsDir)) {
mkdirSync(logsDir, { recursive: true });
}
logPath = join(logsDir, 'rolling-memory.log');
return logPath;
}
export type RollingLogLevel = 'debug' | 'info' | 'warn' | 'error';
export function rollingLog(
level: RollingLogLevel,
message: string,
payload: Record<string, unknown> = {}
): void {
try {
const file = ensureLogPath();
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...payload
};
appendFileSync(file, `${JSON.stringify(entry)}\n`, 'utf8');
} catch {
// Logging should never throw user-facing errors
}
}
+87
View File
@@ -0,0 +1,87 @@
import { readSettings } from './settings.js';
export interface RollingSettings {
captureEnabled: boolean;
summaryEnabled: boolean;
sessionStartEnabled: boolean;
chunkTokenLimit: number;
chunkOverlapTokens: number;
summaryTurnLimit: number;
}
const DEFAULTS: RollingSettings = {
captureEnabled: true,
summaryEnabled: true,
sessionStartEnabled: true,
chunkTokenLimit: 600,
chunkOverlapTokens: 200,
summaryTurnLimit: 20
};
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const lowered = value.toLowerCase();
if (lowered === 'true') return true;
if (lowered === 'false') return false;
}
return fallback;
}
function normalizeNumber(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
}
export function getRollingSettings(): RollingSettings {
const settings = readSettings();
return {
captureEnabled: normalizeBoolean(
settings.rollingCaptureEnabled,
DEFAULTS.captureEnabled
),
summaryEnabled: normalizeBoolean(
settings.rollingSummaryEnabled,
DEFAULTS.summaryEnabled
),
sessionStartEnabled: normalizeBoolean(
settings.rollingSessionStartEnabled,
DEFAULTS.sessionStartEnabled
),
chunkTokenLimit: normalizeNumber(
settings.rollingChunkTokens,
DEFAULTS.chunkTokenLimit
),
chunkOverlapTokens: normalizeNumber(
settings.rollingChunkOverlapTokens,
DEFAULTS.chunkOverlapTokens
),
summaryTurnLimit: normalizeNumber(
settings.rollingSummaryTurnLimit,
DEFAULTS.summaryTurnLimit
)
};
}
export function isRollingCaptureEnabled(): boolean {
return getRollingSettings().captureEnabled;
}
export function isRollingSummaryEnabled(): boolean {
return getRollingSettings().summaryEnabled;
}
export function isRollingSessionStartEnabled(): boolean {
return getRollingSettings().sessionStartEnabled;
}
+19 -234
View File
@@ -1,103 +1,17 @@
export interface HookPayload {
session_id: string;
transcript_path: string;
hook_event_name: string;
}
/**
* Core Type Definitions
*
* Minimal type definitions for the claude-mem system.
* Only includes types that are actively imported and used.
*/
export interface PreCompactPayload extends HookPayload {
hook_event_name: 'PreCompact';
trigger: 'manual' | 'auto';
custom_instructions?: string;
}
export interface SessionStartPayload extends HookPayload {
hook_event_name: 'SessionStart';
source: 'startup' | 'compact' | 'vscode' | 'web';
}
export interface UserPromptSubmitPayload extends HookPayload {
hook_event_name: 'UserPromptSubmit';
prompt: string;
cwd: string;
}
export interface PreToolUsePayload extends HookPayload {
hook_event_name: 'PreToolUse';
tool_name: string;
tool_input: Record<string, unknown>;
}
export interface PostToolUsePayload extends HookPayload {
hook_event_name: 'PostToolUse';
tool_name: string;
tool_input: Record<string, unknown>;
tool_response: Record<string, unknown> & {
success?: boolean;
};
}
export interface NotificationPayload extends HookPayload {
hook_event_name: 'Notification';
message: string;
title?: string;
}
export interface StopPayload extends HookPayload {
hook_event_name: 'Stop';
stop_hook_active: boolean;
}
export interface BaseHookResponse {
continue?: boolean;
stopReason?: string;
suppressOutput?: boolean;
}
export interface PreCompactResponse extends BaseHookResponse {
decision?: 'approve' | 'block';
reason?: string;
}
export interface SessionStartResponse extends BaseHookResponse {
hookSpecificOutput?: {
hookEventName: 'SessionStart';
additionalContext?: string;
};
}
export interface PreToolUseResponse extends BaseHookResponse {
permissionDecision?: 'allow' | 'deny' | 'ask';
permissionDecisionReason?: string;
}
export interface CompressionResult {
compressedLines: string[];
originalTokens: number;
compressedTokens: number;
compressionRatio: number;
memoryNodes: string[];
}
export interface MemoryNode {
id: string;
type: 'document';
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export class HookError extends Error {
constructor(
message: string,
public hookType: string,
public payload?: HookPayload,
public code?: string
) {
super(message);
this.name = 'HookError';
}
}
// =============================================================================
// ERROR CLASSES
// =============================================================================
/**
* Custom error class for compression failures
*/
export class CompressionError extends Error {
constructor(
message: string,
@@ -109,108 +23,8 @@ export class CompressionError extends Error {
}
}
export interface Logger {
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
error(message: string, error?: Error, meta?: Record<string, unknown>): void;
debug(message: string, meta?: Record<string, unknown>): void;
}
export class FileLogger implements Logger {
constructor(
private logFile: string,
private enableDebug = false
) {}
info(message: string, meta?: Record<string, unknown>): void {
this.log('INFO', message, meta);
}
warn(message: string, meta?: Record<string, unknown>): void {
this.log('WARN', message, meta);
}
error(message: string, error?: Error, meta?: Record<string, unknown>): void {
const errorMeta = error ? { error: error.message, stack: error.stack } : {};
this.log('ERROR', message, { ...meta, ...errorMeta });
}
debug(message: string, meta?: Record<string, unknown>): void {
if (this.enableDebug) {
this.log('DEBUG', message, meta);
}
}
private log(
level: string,
message: string,
meta?: Record<string, unknown>
): void {
const timestamp = new Date().toISOString();
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
const logLine = `[${timestamp}] ${level}: ${message}${metaStr}\n`;
console.error(logLine);
}
}
export function validateHookPayload(
payload: unknown,
expectedType: string
): HookPayload {
if (!payload || typeof payload !== 'object') {
throw new HookError(
`Invalid payload: expected object, got ${typeof payload}`,
expectedType
);
}
const hookPayload = payload as Record<string, unknown>;
if (!hookPayload.session_id || typeof hookPayload.session_id !== 'string') {
throw new HookError(
'Missing or invalid session_id',
expectedType,
hookPayload as unknown as HookPayload
);
}
if (
!hookPayload.transcript_path ||
typeof hookPayload.transcript_path !== 'string'
) {
throw new HookError(
'Missing or invalid transcript_path',
expectedType,
hookPayload as unknown as HookPayload
);
}
return hookPayload as unknown as HookPayload;
}
export function createSuccessResponse(
additionalData?: Record<string, unknown>
): BaseHookResponse {
return {
continue: true,
...additionalData,
};
}
export function createErrorResponse(
reason: string,
additionalData?: Record<string, unknown>
): BaseHookResponse {
return {
continue: false,
stopReason: reason,
...additionalData,
};
}
// =============================================================================
// SETTINGS AND CONFIGURATION TYPES
// CONFIGURATION TYPES
// =============================================================================
/**
@@ -224,40 +38,11 @@ export interface Settings {
embedded?: boolean;
saveMemoriesOnClear?: boolean;
claudePath?: string;
rollingCaptureEnabled?: boolean;
rollingSummaryEnabled?: boolean;
rollingSessionStartEnabled?: boolean;
rollingChunkTokens?: number;
rollingChunkOverlapTokens?: number;
rollingSummaryTurnLimit?: number;
[key: string]: unknown; // Allow additional properties
}
// =============================================================================
// MCP CLIENT INTERFACE TYPES
// =============================================================================
/**
* Document structure for MCP operations
*/
export interface MCPDocument {
id: string;
content: string;
metadata?: Record<string, unknown>;
}
/**
* Search result structure from MCP operations
*/
export interface MCPSearchResult {
documents?: MCPDocument[];
ids?: string[];
metadatas?: Record<string, unknown>[];
distances?: number[];
[key: string]: unknown;
}
/**
* Interface for MCP client implementations (Chroma-based)
*/
export interface IMCPClient {
connect(): Promise<void>;
disconnect(): Promise<void>;
addDocuments(documents: MCPDocument[]): Promise<void>;
queryDocuments(query: string, limit?: number): Promise<MCPSearchResult>;
getDocuments(ids?: string[]): Promise<MCPSearchResult>;
}
+112
View File
@@ -0,0 +1,112 @@
import { platform, homedir } from 'os';
import { execSync } from 'child_process';
import { chmodSync } from 'fs';
import { join } from 'path';
const isWindows = platform() === 'win32';
/**
* Platform-specific utilities for cross-platform compatibility
* Handles differences between Windows and Unix-like systems
*/
export const Platform = {
/**
* Returns the appropriate shell for the current platform
*/
getShell: (): string => {
return isWindows ? 'powershell' : '/bin/sh';
},
/**
* Returns the file extension for hook scripts
*/
getHookExtension: (): string => {
return '.js'; // Both platforms can execute Node.js scripts
},
/**
* Finds the path to an executable command
* @param name - Name of the executable to find
* @returns Full path to the executable
*/
findExecutable: (name: string): string => {
const cmd = isWindows ? `where ${name}` : `which ${name}`;
return execSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim();
},
/**
* Makes a file executable (Unix only - no-op on Windows)
* @param path - Path to the file to make executable
*/
makeExecutable: (path: string): void => {
if (!isWindows) {
chmodSync(path, 0o755);
}
},
/**
* Installs uv package manager using platform-specific method
*/
installUv: (): void => {
if (isWindows) {
execSync('powershell -Command "irm https://astral.sh/uv/install.ps1 | iex"', {
stdio: 'pipe'
});
} else {
execSync('curl -LsSf https://astral.sh/uv/install.sh | sh', {
stdio: 'pipe',
shell: '/bin/sh'
});
}
},
/**
* Returns shell configuration file paths for the current platform
* @returns Array of shell config file paths
*/
getShellConfigPaths: (): string[] => {
const home = homedir();
if (isWindows) {
return [
join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1')
];
}
return [
join(home, '.bashrc'),
join(home, '.zshrc'),
join(home, '.bash_profile')
];
},
/**
* Gets the appropriate alias syntax for the current platform's shell
* @param aliasName - Name of the alias
* @param command - Command to alias
* @returns Alias definition string
*/
getAliasDefinition: (aliasName: string, command: string): string => {
if (isWindows) {
// PowerShell function syntax
return `function ${aliasName} { ${command} $args }`;
}
// Bash/Zsh alias syntax
return `alias ${aliasName}='${command}'`;
},
/**
* Returns whether the current platform is Windows
*/
isWindows: (): boolean => isWindows,
/**
* Returns whether the current platform is Unix-like (macOS/Linux)
*/
isUnix: (): boolean => !isWindows
};