@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
460 lines (389 loc) • 11.7 kB
JavaScript
/**
* ChromaDB Hook for Claude
* Automatically preserves and retrieves context using ChromaDB vector storage
*
* This hook runs automatically during Claude operations to:
* - Store context on saves/clears
* - Retrieve relevant context on queries
* - Maintain semantic search history
*/
import { CloudClient } from 'chromadb';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load environment variables
dotenv.config({
path: path.join(__dirname, '..', '.env'),
override: true,
silent: true
});
class ChromaDBHook {
constructor() {
this.config = {
apiKey: process.env.CHROMADB_API_KEY,
tenant: process.env.CHROMADB_TENANT,
database: process.env.CHROMADB_DATABASE || 'stackmemory',
};
this.userId = process.env.USER || 'claude';
this.teamId = process.env.CHROMADB_TEAM_ID;
this.sessionId = process.env.CLAUDE_SESSION_ID || `session_${Date.now()}`;
this.projectName = path.basename(process.cwd());
this.client = null;
this.collection = null;
this.initialized = false;
// Hook context from Claude
this.hookType = process.argv[2] || 'unknown';
this.hookData = process.argv[3] ? JSON.parse(process.argv[3]) : {};
}
async initialize() {
if (this.initialized) return;
try {
if (!this.config.apiKey || !this.config.tenant) {
this.log('ChromaDB not configured, skipping hook');
return false;
}
this.client = new CloudClient({
apiKey: this.config.apiKey,
tenant: this.config.tenant,
database: this.config.database,
});
this.collection = await this.client.getOrCreateCollection({
name: 'claude_contexts',
metadata: {
description: 'Claude Code context storage',
version: '1.0.0',
},
});
this.initialized = true;
return true;
} catch (error) {
this.logError('Failed to initialize ChromaDB', error);
return false;
}
}
/**
* Store context in ChromaDB
*/
async storeContext(type, content, metadata = {}) {
if (!await this.initialize()) return;
try {
const contextId = `${type}_${this.sessionId}_${Date.now()}`;
await this.collection.add({
ids: [contextId],
documents: [content],
metadatas: [{
user_id: this.userId,
team_id: this.teamId,
session_id: this.sessionId,
project_name: this.projectName,
type: type,
timestamp: Date.now(),
hook_type: this.hookType,
...metadata,
}],
});
this.log(`Stored ${type} context`);
} catch (error) {
this.logError(`Failed to store ${type}`, error);
}
}
/**
* Query contexts by semantic similarity
*/
async queryContexts(query, limit = 5) {
if (!await this.initialize()) return [];
try {
const results = await this.collection.query({
queryTexts: [query],
nResults: limit,
where: {
user_id: this.userId,
project_name: this.projectName,
},
include: ['documents', 'metadatas', 'distances'],
});
if (results.documents && results.documents[0]) {
return results.documents[0].map((doc, i) => ({
content: doc,
metadata: results.metadatas[0][i],
distance: results.distances[0][i],
}));
}
return [];
} catch (error) {
this.logError('Failed to query contexts', error);
return [];
}
}
/**
* Get recent contexts
*/
async getRecentContexts(limit = 10, type = null) {
if (!await this.initialize()) return [];
try {
const where = {
user_id: this.userId,
project_name: this.projectName,
};
if (type) {
where.type = type;
}
const results = await this.collection.get({
where: where,
include: ['documents', 'metadatas'],
limit: limit * 2, // Get more to sort by timestamp
});
if (results.documents) {
const contexts = results.documents.map((doc, i) => ({
content: doc,
metadata: results.metadatas[i],
}));
// Sort by timestamp and limit
return contexts
.sort((a, b) => (b.metadata.timestamp || 0) - (a.metadata.timestamp || 0))
.slice(0, limit);
}
return [];
} catch (error) {
this.logError('Failed to get recent contexts', error);
return [];
}
}
/**
* Handle different hook types
*/
async handleHook() {
switch (this.hookType) {
case 'on-save':
case 'on-context-save':
await this.handleSave();
break;
case 'on-clear':
case 'on-session-end':
await this.handleClear();
break;
case 'on-query':
case 'on-search':
await this.handleQuery();
break;
case 'on-task-complete':
await this.handleTaskComplete();
break;
case 'on-decision':
await this.handleDecision();
break;
case 'on-error':
await this.handleError();
break;
case 'on-file-change':
await this.handleFileChange();
break;
case 'periodic':
case 'on-checkpoint':
await this.handleCheckpoint();
break;
default:
this.log(`Unknown hook type: ${this.hookType}`);
}
}
async handleSave() {
const content = this.hookData.content || this.getCurrentContext();
await this.storeContext('save', content, {
files: this.hookData.files,
tags: this.hookData.tags,
});
}
async handleClear() {
// Save context before clear
const content = this.hookData.content || this.getCurrentContext();
await this.storeContext('clear', content, {
reason: this.hookData.reason || 'manual',
preserved: true,
});
// Generate summary
const recentContexts = await this.getRecentContexts(20);
if (recentContexts.length > 0) {
const summary = this.generateSummary(recentContexts);
await this.storeContext('summary', summary, {
contexts_count: recentContexts.length,
});
}
}
async handleQuery() {
const query = this.hookData.query || this.hookData.search;
if (!query) return;
// Find relevant contexts
const contexts = await this.queryContexts(query, 5);
if (contexts.length > 0) {
// Store the query and results
await this.storeContext('query', query, {
results_count: contexts.length,
top_distance: contexts[0].distance,
});
// Output relevant contexts for Claude
this.outputContextsForClaude(contexts);
}
}
async handleTaskComplete() {
const task = this.hookData.task || {};
await this.storeContext('task_complete', JSON.stringify(task), {
task_id: task.id,
task_title: task.title,
duration: task.duration,
});
}
async handleDecision() {
const decision = this.hookData.decision || this.hookData.content;
await this.storeContext('decision', decision, {
importance: this.hookData.importance || 'normal',
});
}
async handleError() {
const error = this.hookData.error || this.hookData.message;
await this.storeContext('error', error, {
severity: this.hookData.severity || 'error',
stack: this.hookData.stack,
});
}
async handleFileChange() {
const file = this.hookData.file || this.hookData.path;
await this.storeContext('file_change', `Modified: ${file}`, {
file: file,
change_type: this.hookData.type || 'modify',
});
}
async handleCheckpoint() {
// Periodic checkpoint
const content = this.getCurrentContext();
await this.storeContext('checkpoint', content, {
automatic: true,
interval: this.hookData.interval || 900000, // 15 min default
});
// Clean old contexts
await this.cleanOldContexts();
}
/**
* Get current context from StackMemory
*/
getCurrentContext() {
try {
// Try to get from StackMemory
const contextFile = path.join(
process.env.HOME,
'.stackmemory',
'shared-context',
'projects',
`${this.projectName}.json`
);
if (fs.existsSync(contextFile)) {
const data = JSON.parse(fs.readFileSync(contextFile, 'utf8'));
return JSON.stringify(data.contexts || [], null, 2);
}
// Fallback to hook data
return this.hookData.content || 'No context available';
} catch (error) {
return this.hookData.content || 'Context read error';
}
}
/**
* Generate summary of contexts
*/
generateSummary(contexts) {
const summary = {
timestamp: new Date().toISOString(),
project: this.projectName,
session: this.sessionId,
contexts_count: contexts.length,
types: {},
key_points: [],
};
for (const ctx of contexts) {
const type = ctx.metadata.type || 'unknown';
summary.types[type] = (summary.types[type] || 0) + 1;
// Extract key points (first line or first 100 chars)
const point = ctx.content.split('\n')[0].substring(0, 100);
if (point && !summary.key_points.includes(point)) {
summary.key_points.push(point);
}
}
return JSON.stringify(summary, null, 2);
}
/**
* Output contexts for Claude to use
*/
outputContextsForClaude(contexts) {
console.log('\n=== Relevant Context from ChromaDB ===\n');
for (const ctx of contexts) {
console.log(`Type: ${ctx.metadata.type || 'unknown'}`);
console.log(`Time: ${new Date(ctx.metadata.timestamp).toLocaleString()}`);
console.log(`Relevance: ${(1 - ctx.distance).toFixed(2)}`);
console.log('---');
console.log(ctx.content.substring(0, 500));
console.log('\n');
}
console.log('=== End of Context ===\n');
}
/**
* Clean old contexts (retention policy)
*/
async cleanOldContexts() {
if (!await this.initialize()) return;
try {
const cutoffTime = Date.now() - (30 * 24 * 60 * 60 * 1000); // 30 days
const results = await this.collection.get({
where: {
user_id: this.userId,
timestamp: { $lt: cutoffTime },
},
include: ['ids'],
});
if (results.ids && results.ids.length > 0) {
await this.collection.delete({
ids: results.ids,
});
this.log(`Cleaned ${results.ids.length} old contexts`);
}
} catch (error) {
this.logError('Failed to clean old contexts', error);
}
}
log(message) {
const logFile = path.join(
process.env.HOME,
'.stackmemory',
'logs',
'chromadb-hook.log'
);
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
try {
fs.appendFileSync(logFile, logMessage);
} catch {
// Silent fail
}
}
logError(message, error) {
this.log(`ERROR: ${message} - ${error.message}`);
}
}
// Main execution
async function main() {
const hook = new ChromaDBHook();
try {
await hook.handleHook();
} catch (error) {
hook.logError('Hook execution failed', error);
// Don't fail the parent process
process.exit(0);
}
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(error => {
console.error('Fatal error:', error);
process.exit(0); // Don't fail the parent
});
}