UNPKG

@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.

765 lines (764 loc) 21.8 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import "dotenv/config"; import Redis from "ioredis"; import { v4 as uuidv4 } from "uuid"; import { logger } from "../monitoring/logger.js"; function getEnv(key, defaultValue) { const value = process.env[key]; if (value === void 0) { if (defaultValue !== void 0) return defaultValue; throw new Error(`Environment variable ${key} is required`); } return value; } function getOptionalEnv(key) { return process.env[key]; } import { REDIS_KEYS, CACHE_TTL, calculateSkillTTL, SkillSchema, JournalEntrySchema } from "./types.js"; class SkillStorageService { redis; userId; keyPrefix; enableMetrics; // Metrics tracking metrics = { cacheHits: 0, cacheMisses: 0 }; constructor(config) { this.redis = new Redis(config.redisUrl); this.userId = config.userId; this.keyPrefix = config.keyPrefix || "sm:skills"; this.enableMetrics = config.enableMetrics ?? true; this.redis.on("error", (err) => { logger.error("Redis connection error in SkillStorage", err); }); this.redis.on("connect", () => { logger.info("SkillStorage connected to Redis"); }); logger.info("SkillStorageService initialized", { userId: this.userId, keyPrefix: this.keyPrefix, enableMetrics: this.enableMetrics }); } key(pattern) { return `${this.keyPrefix}:${pattern}`; } /** * Get the current user ID */ getUserId() { return this.userId; } // ============================================================ // SKILL CRUD OPERATIONS // ============================================================ /** * Create a new skill */ async createSkill(input) { const now = (/* @__PURE__ */ new Date()).toISOString(); const skill = { ...input, id: uuidv4(), createdAt: now, updatedAt: now, validatedCount: 0 }; SkillSchema.parse(skill); const pipeline = this.redis.pipeline(); const skillKey = this.key(REDIS_KEYS.skill(this.userId, skill.id)); pipeline.setex( skillKey, calculateSkillTTL(skill.validatedCount), JSON.stringify(skill) ); if (skill.tool) { pipeline.zadd( this.key(REDIS_KEYS.skillsByTool(this.userId, skill.tool)), Date.now(), skill.id ); } pipeline.zadd( this.key(REDIS_KEYS.skillsByCategory(this.userId, skill.category)), this.priorityScore(skill.priority), skill.id ); for (const tag of skill.tags) { pipeline.zadd( this.key(REDIS_KEYS.skillsByTag(this.userId, tag)), Date.now(), skill.id ); } pipeline.zadd( this.key(REDIS_KEYS.skillsRecent(this.userId)), Date.now(), skill.id ); pipeline.zremrangebyrank( this.key(REDIS_KEYS.skillsRecent(this.userId)), 0, -1001 ); await pipeline.exec(); logger.info("Created skill", { userId: this.userId, id: skill.id, category: skill.category, tool: skill.tool }); return skill; } /** * Get skill by ID */ async getSkill(id) { const skillKey = this.key(REDIS_KEYS.skill(this.userId, id)); const data = await this.redis.get(skillKey); if (!data) { this.metrics.cacheMisses++; return null; } this.metrics.cacheHits++; return JSON.parse(data); } /** * Update an existing skill */ async updateSkill(input) { const existing = await this.getSkill(input.id); if (!existing) { return null; } const updated = { ...existing, ...input, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }; SkillSchema.parse(updated); const skillKey = this.key(REDIS_KEYS.skill(this.userId, updated.id)); await this.redis.setex( skillKey, calculateSkillTTL(updated.validatedCount), JSON.stringify(updated) ); logger.info("Updated skill", { userId: this.userId, id: updated.id }); return updated; } /** * Validate a skill (increment validation count) */ async validateSkill(id) { const skill = await this.getSkill(id); if (!skill) { return null; } skill.validatedCount++; skill.lastValidated = (/* @__PURE__ */ new Date()).toISOString(); skill.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); const skillKey = this.key(REDIS_KEYS.skill(this.userId, id)); await this.redis.setex( skillKey, calculateSkillTTL(skill.validatedCount), JSON.stringify(skill) ); await this.redis.zadd( this.key(REDIS_KEYS.skillsValidated(this.userId)), skill.validatedCount, id ); if (skill.validatedCount >= 3 && skill.priority !== "critical") { await this.redis.sadd( this.key(REDIS_KEYS.promotionCandidates(this.userId)), id ); } logger.info("Validated skill", { userId: this.userId, id, validatedCount: skill.validatedCount }); return skill; } /** * Delete a skill */ async deleteSkill(id) { const skill = await this.getSkill(id); if (!skill) { return false; } const pipeline = this.redis.pipeline(); const skillKey = this.key(REDIS_KEYS.skill(this.userId, id)); pipeline.del(skillKey); if (skill.tool) { pipeline.zrem( this.key(REDIS_KEYS.skillsByTool(this.userId, skill.tool)), id ); } pipeline.zrem( this.key(REDIS_KEYS.skillsByCategory(this.userId, skill.category)), id ); for (const tag of skill.tags) { pipeline.zrem(this.key(REDIS_KEYS.skillsByTag(this.userId, tag)), id); } pipeline.zrem(this.key(REDIS_KEYS.skillsRecent(this.userId)), id); pipeline.zrem(this.key(REDIS_KEYS.skillsValidated(this.userId)), id); pipeline.srem(this.key(REDIS_KEYS.promotionCandidates(this.userId)), id); await pipeline.exec(); logger.info("Deleted skill", { userId: this.userId, id }); return true; } // ============================================================ // SKILL QUERIES // ============================================================ /** * Query skills with filters */ async querySkills(query) { let skillIds = []; if (query.tool) { skillIds = await this.redis.zrevrange( this.key(REDIS_KEYS.skillsByTool(this.userId, query.tool)), 0, -1 ); } else if (query.categories && query.categories.length === 1) { skillIds = await this.redis.zrevrange( this.key(REDIS_KEYS.skillsByCategory(this.userId, query.categories[0])), 0, -1 ); } else if (query.tags && query.tags.length === 1) { skillIds = await this.redis.zrevrange( this.key(REDIS_KEYS.skillsByTag(this.userId, query.tags[0])), 0, -1 ); } else { skillIds = await this.redis.zrevrange( this.key(REDIS_KEYS.skillsRecent(this.userId)), 0, query.limit + query.offset ); } if (skillIds.length === 0) { return []; } const pipeline = this.redis.pipeline(); for (const id of skillIds) { pipeline.get(this.key(REDIS_KEYS.skill(this.userId, id))); } const results = await pipeline.exec(); if (!results) { return []; } let skills = results.map(([err, data]) => { if (err || !data) return null; try { return JSON.parse(data); } catch { return null; } }).filter((s) => s !== null); if (query.categories && query.categories.length > 0) { skills = skills.filter((s) => query.categories.includes(s.category)); } if (query.priorities && query.priorities.length > 0) { skills = skills.filter((s) => query.priorities.includes(s.priority)); } if (query.minValidatedCount !== void 0) { skills = skills.filter( (s) => s.validatedCount >= query.minValidatedCount ); } if (query.language) { skills = skills.filter((s) => s.language === query.language); } if (query.framework) { skills = skills.filter((s) => s.framework === query.framework); } skills.sort((a, b) => { let aVal, bVal; switch (query.sortBy) { case "priority": aVal = this.priorityScore(a.priority); bVal = this.priorityScore(b.priority); break; case "validatedCount": aVal = a.validatedCount; bVal = b.validatedCount; break; case "createdAt": aVal = new Date(a.createdAt).getTime(); bVal = new Date(b.createdAt).getTime(); break; case "updatedAt": aVal = new Date(a.updatedAt).getTime(); bVal = new Date(b.updatedAt).getTime(); break; default: aVal = this.priorityScore(a.priority); bVal = this.priorityScore(b.priority); } return query.sortOrder === "desc" ? bVal - aVal : aVal - bVal; }); return skills.slice(query.offset, query.offset + query.limit); } /** * Get skills relevant to current context */ async getRelevantSkills(context) { const skills = []; const seenIds = /* @__PURE__ */ new Set(); const criticalIds = await this.redis.zrevrange( this.key(REDIS_KEYS.skillsByCategory(this.userId, "correction")), 0, -1 ); for (const id of criticalIds) { const skill = await this.getSkill(id); if (skill && skill.priority === "critical" && !seenIds.has(id)) { skills.push(skill); seenIds.add(id); } } if (context.tool) { const toolSkills = await this.querySkills({ tool: context.tool, limit: 20, offset: 0, sortBy: "priority", sortOrder: "desc" }); for (const skill of toolSkills) { if (!seenIds.has(skill.id)) { skills.push(skill); seenIds.add(skill.id); } } } const validatedIds = await this.redis.zrevrange( this.key(REDIS_KEYS.skillsValidated(this.userId)), 0, 10 ); for (const id of validatedIds) { if (!seenIds.has(id)) { const skill = await this.getSkill(id); if (skill) { skills.push(skill); seenIds.add(id); } } } return skills.slice(0, 50); } // ============================================================ // SESSION JOURNAL // ============================================================ /** * Create a journal entry */ async createJournalEntry(sessionId, type, title, content, context) { const entry = { id: uuidv4(), sessionId, type, title, content, context, createdAt: (/* @__PURE__ */ new Date()).toISOString() }; JournalEntrySchema.parse(entry); const pipeline = this.redis.pipeline(); pipeline.setex( this.key(REDIS_KEYS.journalEntry(this.userId, entry.id)), CACHE_TTL.journal, JSON.stringify(entry) ); pipeline.zadd( this.key(REDIS_KEYS.journalSession(this.userId, sessionId)), Date.now(), entry.id ); pipeline.zadd( this.key(REDIS_KEYS.journalRecent(this.userId)), Date.now(), entry.id ); pipeline.zremrangebyrank( this.key(REDIS_KEYS.journalRecent(this.userId)), 0, -501 ); await pipeline.exec(); logger.info("Created journal entry", { userId: this.userId, id: entry.id, sessionId, type, title }); return entry; } /** * Get journal entries for a session */ async getSessionJournal(sessionId) { const entryIds = await this.redis.zrevrange( this.key(REDIS_KEYS.journalSession(this.userId, sessionId)), 0, -1 ); if (entryIds.length === 0) { return []; } const pipeline = this.redis.pipeline(); for (const id of entryIds) { pipeline.get(this.key(REDIS_KEYS.journalEntry(this.userId, id))); } const results = await pipeline.exec(); if (!results) { return []; } return results.map(([err, data]) => { if (err || !data) return null; try { return JSON.parse(data); } catch { return null; } }).filter((e) => e !== null); } /** * Promote a journal entry to a skill */ async promoteToSkill(entryId, category, priority = "medium") { const entryData = await this.redis.get( this.key(REDIS_KEYS.journalEntry(this.userId, entryId)) ); if (!entryData) { return null; } const entry = JSON.parse(entryData); const skill = await this.createSkill({ content: entry.content, summary: entry.title, category, priority, tags: [], tool: entry.context?.tool, source: "observation", sessionId: entry.sessionId }); entry.promotedToSkillId = skill.id; await this.redis.setex( this.key(REDIS_KEYS.journalEntry(this.userId, entryId)), CACHE_TTL.journal, JSON.stringify(entry) ); logger.info("Promoted journal entry to skill", { userId: this.userId, entryId, skillId: skill.id }); return skill; } // ============================================================ // SESSION MANAGEMENT // ============================================================ /** * Start tracking a new session */ async startSession(sessionId) { const summary = { sessionId, startedAt: (/* @__PURE__ */ new Date()).toISOString(), entriesCount: 0, correctionsCount: 0, decisionsCount: 0, keyLearnings: [], promotedSkillIds: [] }; await this.redis.setex( this.key(REDIS_KEYS.sessionSummary(this.userId, sessionId)), CACHE_TTL.session, JSON.stringify(summary) ); await this.redis.sadd( this.key(REDIS_KEYS.sessionsActive(this.userId)), sessionId ); logger.info("Started session tracking", { userId: this.userId, sessionId }); } /** * End a session and generate summary */ async endSession(sessionId) { const summaryData = await this.redis.get( this.key(REDIS_KEYS.sessionSummary(this.userId, sessionId)) ); if (!summaryData) { return null; } const summary = JSON.parse(summaryData); summary.endedAt = (/* @__PURE__ */ new Date()).toISOString(); const entries = await this.getSessionJournal(sessionId); summary.entriesCount = entries.length; summary.correctionsCount = entries.filter( (e) => e.type === "correction" ).length; summary.decisionsCount = entries.filter( (e) => e.type === "decision" ).length; summary.keyLearnings = entries.filter((e) => e.type === "correction" || e.type === "resolution").slice(0, 5).map((e) => e.title); summary.promotedSkillIds = entries.filter((e) => e.promotedToSkillId).map((e) => e.promotedToSkillId); await this.redis.setex( this.key(REDIS_KEYS.sessionSummary(this.userId, sessionId)), CACHE_TTL.session, JSON.stringify(summary) ); await this.redis.srem( this.key(REDIS_KEYS.sessionsActive(this.userId)), sessionId ); logger.info("Ended session", { userId: this.userId, sessionId, entriesCount: summary.entriesCount, keyLearnings: summary.keyLearnings.length }); return summary; } /** * Get session summary */ async getSessionSummary(sessionId) { const data = await this.redis.get( this.key(REDIS_KEYS.sessionSummary(this.userId, sessionId)) ); if (!data) { return null; } return JSON.parse(data); } // ============================================================ // KNOWLEDGE HYGIENE // ============================================================ /** * Get skills eligible for promotion */ async getPromotionCandidates() { const ids = await this.redis.smembers( this.key(REDIS_KEYS.promotionCandidates(this.userId)) ); const skills = []; for (const id of ids) { const skill = await this.getSkill(id); if (skill && skill.validatedCount >= 3) { skills.push(skill); } } return skills; } /** * Promote a skill (increase priority) */ async promoteSkill(id) { const skill = await this.getSkill(id); if (!skill) { return null; } const priorityOrder = [ "low", "medium", "high", "critical" ]; const currentIndex = priorityOrder.indexOf(skill.priority); if (currentIndex < priorityOrder.length - 1) { skill.priority = priorityOrder[currentIndex + 1]; skill.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); await this.redis.setex( this.key(REDIS_KEYS.skill(this.userId, id)), calculateSkillTTL(skill.validatedCount), JSON.stringify(skill) ); await this.redis.zadd( this.key(REDIS_KEYS.skillsByCategory(this.userId, skill.category)), this.priorityScore(skill.priority), id ); if (skill.priority === "critical") { await this.redis.srem( this.key(REDIS_KEYS.promotionCandidates(this.userId)), id ); } logger.info("Promoted skill", { userId: this.userId, id, newPriority: skill.priority }); } return skill; } /** * Archive stale skills (not validated in 90 days) */ async archiveStaleSkills(daysThreshold = 90) { const cutoff = Date.now() - daysThreshold * 24 * 60 * 60 * 1e3; let archivedCount = 0; const skillIds = await this.redis.zrangebyscore( this.key(REDIS_KEYS.skillsRecent(this.userId)), 0, cutoff ); for (const id of skillIds) { const skill = await this.getSkill(id); if (skill && skill.priority !== "critical") { if (!skill.lastValidated || new Date(skill.lastValidated).getTime() < cutoff) { if (skill.priority !== "low") { skill.priority = "low"; skill.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); await this.redis.setex( this.key(REDIS_KEYS.skill(this.userId, id)), calculateSkillTTL(skill.validatedCount), JSON.stringify(skill) ); archivedCount++; } } } } logger.info("Archived stale skills", { userId: this.userId, archivedCount, daysThreshold }); return archivedCount; } // ============================================================ // METRICS & UTILITIES // ============================================================ /** * Get storage metrics */ async getMetrics() { const [ skillsTotal, toolSkills, workflowSkills, correctionSkills, patternSkills, preferenceSkills, pitfallSkills, optimizationSkills, journalTotal, sessionsActive ] = await Promise.all([ this.redis.zcard(this.key(REDIS_KEYS.skillsRecent(this.userId))), this.redis.zcard( this.key(REDIS_KEYS.skillsByCategory(this.userId, "tool")) ), this.redis.zcard( this.key(REDIS_KEYS.skillsByCategory(this.userId, "workflow")) ), this.redis.zcard( this.key(REDIS_KEYS.skillsByCategory(this.userId, "correction")) ), this.redis.zcard( this.key(REDIS_KEYS.skillsByCategory(this.userId, "pattern")) ), this.redis.zcard( this.key(REDIS_KEYS.skillsByCategory(this.userId, "preference")) ), this.redis.zcard( this.key(REDIS_KEYS.skillsByCategory(this.userId, "pitfall")) ), this.redis.zcard( this.key(REDIS_KEYS.skillsByCategory(this.userId, "optimization")) ), this.redis.zcard(this.key(REDIS_KEYS.journalRecent(this.userId))), this.redis.scard(this.key(REDIS_KEYS.sessionsActive(this.userId))) ]); return { userId: this.userId, skillsTotal, skillsByCategory: { tool: toolSkills, workflow: workflowSkills, correction: correctionSkills, pattern: patternSkills, preference: preferenceSkills, pitfall: pitfallSkills, optimization: optimizationSkills }, journalEntriesTotal: journalTotal, sessionsTracked: sessionsActive, cacheHits: this.metrics.cacheHits, cacheMisses: this.metrics.cacheMisses }; } /** * Priority to numeric score for sorting */ priorityScore(priority) { const scores = { critical: 1e3, high: 100, medium: 10, low: 1 }; return scores[priority]; } /** * Close Redis connection */ async close() { await this.redis.quit(); logger.info("SkillStorageService closed"); } } const userStorageInstances = /* @__PURE__ */ new Map(); function getSkillStorage(config) { const existing = userStorageInstances.get(config.userId); if (existing) { return existing; } const instance = new SkillStorageService(config); userStorageInstances.set(config.userId, instance); return instance; } function initializeSkillStorage(userId, redisUrl) { const url = redisUrl || process.env["REDIS_URL"]; if (!url) { throw new Error("REDIS_URL environment variable not set"); } return getSkillStorage({ redisUrl: url, userId }); } function getDefaultUserId() { return process.env["STACKMEMORY_USER_ID"] || process.env["USER"] || process.env["USERNAME"] || "default"; } export { SkillStorageService, getDefaultUserId, getSkillStorage, initializeSkillStorage }; //# sourceMappingURL=skill-storage.js.map