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