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.

698 lines (694 loc) 21.4 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { execSync } from "child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { join, basename, dirname } from "path"; import { homedir } from "os"; import Database from "better-sqlite3"; import { logger } from "../monitoring/logger.js"; import { DatabaseError, ProjectError, ErrorCode, createErrorHandler } from "../errors/index.js"; import { retry } from "../errors/recovery.js"; class ProjectManager { static instance; db; configPath; organizations = /* @__PURE__ */ new Map(); projectCache = /* @__PURE__ */ new Map(); currentProject; constructor() { this.configPath = join(homedir(), ".stackmemory"); this.ensureDirectoryStructure(); this.initializeDatabase(); this.loadOrganizations(); this.autoDiscoverOrganizations(); } static getInstance() { if (!ProjectManager.instance) { ProjectManager.instance = new ProjectManager(); } return ProjectManager.instance; } /** * Auto-detect project from current directory */ async detectProject(projectPath) { const path = projectPath || process.cwd(); const errorHandler = createErrorHandler({ operation: "detectProject", projectPath: path }); try { const cached = this.projectCache.get(path); if (cached && this.isCacheValid(cached)) { return cached; } const project = await this.analyzeProject(path); if (project.gitRemote) { project.organization = this.extractOrganization(project.gitRemote); project.accountType = this.determineAccountType( project.gitRemote, project.organization ); project.isPrivate = this.isPrivateRepo(project.gitRemote); } project.primaryLanguage = this.detectPrimaryLanguage(path); project.framework = this.detectFramework(path); await retry(() => Promise.resolve(this.saveProject(project)), { maxAttempts: 3, initialDelay: 100, onRetry: (attempt, error) => { logger.warn(`Retrying project save (attempt ${attempt})`, { projectId: project.id, error: error instanceof Error ? error.message : String(error) }); } }); this.projectCache.set(path, project); this.currentProject = project; logger.info("Project auto-detected", { id: project.id, org: project.organization, type: project.accountType }); return project; } catch (error) { const wrappedError = errorHandler(error, { projectPath: path, operation: "detectProject" }); throw new ProjectError( `Failed to detect project at path: ${path}`, ErrorCode.PROJECT_INVALID_PATH, { projectPath: path, operation: "detectProject" } ); } } /** * Analyze project directory */ async analyzeProject(projectPath) { const gitInfo = this.getGitInfo(projectPath); const projectName = gitInfo.name || basename(projectPath); return { id: this.generateProjectId(gitInfo.remote || projectPath), name: projectName, path: projectPath, gitRemote: gitInfo.remote, organization: void 0, accountType: "personal", isPrivate: false, lastAccessed: /* @__PURE__ */ new Date(), metadata: { branch: gitInfo.branch, lastCommit: gitInfo.lastCommit, isDirty: gitInfo.isDirty } }; } /** * Extract Git information */ getGitInfo(projectPath) { const info = {}; const errorHandler = createErrorHandler({ operation: "getGitInfo", projectPath }); try { info.remote = execSync("git config --get remote.origin.url", { cwd: projectPath, encoding: "utf-8", timeout: 5e3 // 5 second timeout }).trim(); info.branch = execSync("git branch --show-current", { cwd: projectPath, encoding: "utf-8", timeout: 5e3 }).trim(); info.lastCommit = execSync("git log -1 --format=%H", { cwd: projectPath, encoding: "utf-8", timeout: 5e3 }).trim(); const status = execSync("git status --porcelain", { cwd: projectPath, encoding: "utf-8", timeout: 5e3 }); info.isDirty = status.length > 0; const match = info.remote.match(/\/([^\/]+?)(\.git)?$/); info.name = match ? match[1] : basename(projectPath); } catch (error) { logger.debug("Git info extraction failed, using directory name", { projectPath, error: error instanceof Error ? error.message : String(error) }); info.name = basename(projectPath); } return info; } /** * Extract organization from Git remote */ extractOrganization(gitRemote) { const githubMatch = gitRemote.match(/github\.com[:/]([^/]+)\//); if (githubMatch) return githubMatch[1]; const gitlabMatch = gitRemote.match(/gitlab\.com[:/]([^/]+)\//); if (gitlabMatch) return gitlabMatch[1]; const bitbucketMatch = gitRemote.match(/bitbucket\.org[:/]([^/]+)\//); if (bitbucketMatch) return bitbucketMatch[1]; const customMatch = gitRemote.match(/@([^:]+)[:/]([^/]+)\//); if (customMatch) return customMatch[2]; return "unknown"; } /** * Determine account type based on patterns */ determineAccountType(gitRemote, organization) { for (const [, org] of this.organizations) { if (org.githubOrgs.includes(organization || "")) { return org.accountType; } for (const domain of org.domains) { if (gitRemote.includes(domain)) { return org.accountType; } } } if (organization) { if (organization.includes("corp") || organization.includes("company") || organization.includes("team") || organization.includes("work")) { return "work"; } if (organization.includes("apache") || organization.includes("mozilla") || organization.includes("foundation") || gitRemote.includes("gitlab.freedesktop")) { return "opensource"; } const username = this.getCurrentGitUser(); if (username && organization.toLowerCase() === username.toLowerCase()) { return "personal"; } } if (this.isPrivateRepo(gitRemote)) { const currentPath = process.cwd(); if (currentPath.includes("/work/") || currentPath.includes("/Work/") || currentPath.includes("/company/") || currentPath.includes("/job/")) { return "work"; } } return "personal"; } /** * Check if repository is private */ isPrivateRepo(gitRemote) { if (gitRemote.startsWith("git@")) { return true; } if (gitRemote.includes("@")) { return true; } return false; } /** * Detect primary programming language */ detectPrimaryLanguage(projectPath) { const checks = [ { file: "package.json", language: "JavaScript/TypeScript" }, { file: "Cargo.toml", language: "Rust" }, { file: "go.mod", language: "Go" }, { file: "pom.xml", language: "Java" }, { file: "requirements.txt", language: "Python" }, { file: "Gemfile", language: "Ruby" }, { file: "composer.json", language: "PHP" }, { file: "*.csproj", language: "C#" }, { file: "Podfile", language: "Swift/Objective-C" } ]; for (const check of checks) { if (check.file.includes("*")) { try { const files = execSync( `find ${projectPath} -maxdepth 2 -name "${check.file}" 2>/dev/null`, { encoding: "utf-8" } ); if (files.trim()) return check.language; } catch { } } else if (existsSync(join(projectPath, check.file))) { return check.language; } } return void 0; } /** * Detect framework */ detectFramework(projectPath) { const packageJsonPath = join(projectPath, "package.json"); if (existsSync(packageJsonPath)) { try { const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; if (deps["next"]) return "Next.js"; if (deps["react"]) return "React"; if (deps["vue"]) return "Vue"; if (deps["@angular/core"]) return "Angular"; if (deps["express"]) return "Express"; if (deps["fastify"]) return "Fastify"; if (deps["@nestjs/core"]) return "NestJS"; } catch { } } if (existsSync(join(projectPath, "Cargo.toml"))) { const cargo = readFileSync(join(projectPath, "Cargo.toml"), "utf-8"); if (cargo.includes("actix-web")) return "Actix"; if (cargo.includes("rocket")) return "Rocket"; } return void 0; } /** * Get current Git user */ getCurrentGitUser() { try { const email = execSync("git config --global user.email", { encoding: "utf-8" }).trim(); const username = email.split("@")[0]; return username; } catch { return void 0; } } /** * Generate unique project ID */ generateProjectId(identifier) { const cleaned = identifier.replace(/\.git$/, "").replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); return cleaned.substring(cleaned.length - 50); } /** * Initialize database */ initializeDatabase() { const dbPath = join(this.configPath, "projects.db"); const errorHandler = createErrorHandler({ operation: "initializeDatabase", dbPath }); try { this.db = new Database(dbPath); this.db.exec(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, git_remote TEXT, organization TEXT, account_type TEXT, is_private BOOLEAN, primary_language TEXT, framework TEXT, last_accessed DATETIME, metadata JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS organizations ( name TEXT PRIMARY KEY, type TEXT, account_type TEXT, config JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS project_contexts ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, context_type TEXT, content TEXT, metadata JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (project_id) REFERENCES projects(id) ); CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(organization); CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(account_type); CREATE INDEX IF NOT EXISTS idx_contexts_project ON project_contexts(project_id); `); } catch (error) { const dbError = errorHandler(error, { dbPath, operation: "initializeDatabase" }); throw new DatabaseError( "Failed to initialize projects database", ErrorCode.DB_MIGRATION_FAILED, { dbPath, configPath: this.configPath, operation: "initializeDatabase" }, error instanceof Error ? error : void 0 ); } } /** * Save project to database */ saveProject(project) { try { const stmt = this.db.prepare(` INSERT OR REPLACE INTO projects (id, name, path, git_remote, organization, account_type, is_private, primary_language, framework, last_accessed, metadata, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); stmt.run( project.id, project.name, project.path, project.gitRemote, project.organization, project.accountType, project.isPrivate ? 1 : 0, project.primaryLanguage, project.framework, project.lastAccessed.toISOString(), JSON.stringify(project.metadata) ); } catch (error) { throw new DatabaseError( `Failed to save project: ${project.name}`, ErrorCode.DB_QUERY_FAILED, { projectId: project.id, projectName: project.name, projectPath: project.path, operation: "saveProject" }, error instanceof Error ? error : void 0 ); } } /** * Load organizations configuration */ loadOrganizations() { const configFile = join(this.configPath, "organizations.json"); if (existsSync(configFile)) { try { const config = JSON.parse(readFileSync(configFile, "utf-8")); for (const org of config.organizations || []) { this.organizations.set(org.name, org); } } catch (error) { logger.error( "Failed to load organizations config", error instanceof Error ? error : void 0 ); } } } /** * Auto-discover organizations from existing projects */ autoDiscoverOrganizations() { const errorHandler = createErrorHandler({ operation: "autoDiscoverOrganizations" }); try { const stmt = this.db.prepare(` SELECT DISTINCT organization, account_type, COUNT(*) as project_count FROM projects WHERE organization IS NOT NULL GROUP BY organization, account_type `); const orgs = stmt.all(); for (const org of orgs) { if (!this.organizations.has(org.organization)) { this.organizations.set(org.organization, { name: org.organization, type: org.account_type === "work" ? "company" : "personal", domains: [], githubOrgs: [org.organization], accountType: org.account_type, autoPatterns: [] }); } } } catch (error) { const wrappedError = errorHandler(error, { operation: "autoDiscoverOrganizations" }); logger.error( "Failed to auto-discover organizations", error instanceof Error ? error : new Error(String(error)), { operation: "autoDiscoverOrganizations" } ); } } /** * Ensure directory structure exists */ ensureDirectoryStructure() { const dirs = [ this.configPath, join(this.configPath, "accounts"), join(this.configPath, "accounts", "personal"), join(this.configPath, "accounts", "work"), join(this.configPath, "accounts", "opensource"), join(this.configPath, "accounts", "client"), join(this.configPath, "contexts"), join(this.configPath, "patterns") ]; for (const dir of dirs) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } } /** * Check if cache is still valid */ isCacheValid(project) { const cacheAge = Date.now() - project.lastAccessed.getTime(); return cacheAge < 5 * 60 * 1e3; } /** * Get all projects */ getAllProjects() { try { const stmt = this.db.prepare(` SELECT * FROM projects ORDER BY last_accessed DESC `); const projects = stmt.all(); return projects.map((p) => ({ ...p, isPrivate: p.is_private === 1, lastAccessed: new Date(p.last_accessed), metadata: JSON.parse(p.metadata || "{}") })); } catch (error) { throw new DatabaseError( "Failed to get all projects", ErrorCode.DB_QUERY_FAILED, { operation: "getAllProjects" }, error instanceof Error ? error : void 0 ); } } /** * Get projects by organization */ getProjectsByOrganization(organization) { try { const stmt = this.db.prepare(` SELECT * FROM projects WHERE organization = ? ORDER BY last_accessed DESC `); const projects = stmt.all(organization); return projects.map((p) => ({ ...p, isPrivate: p.is_private === 1, lastAccessed: new Date(p.last_accessed), metadata: JSON.parse(p.metadata || "{}") })); } catch (error) { throw new DatabaseError( `Failed to get projects by organization: ${organization}`, ErrorCode.DB_QUERY_FAILED, { organization, operation: "getProjectsByOrganization" }, error instanceof Error ? error : void 0 ); } } /** * Get projects by account type */ getProjectsByAccountType(accountType) { try { const stmt = this.db.prepare(` SELECT * FROM projects WHERE account_type = ? ORDER BY last_accessed DESC `); const projects = stmt.all(accountType); return projects.map((p) => ({ ...p, isPrivate: p.is_private === 1, lastAccessed: new Date(p.last_accessed), metadata: JSON.parse(p.metadata || "{}") })); } catch (error) { throw new DatabaseError( `Failed to get projects by account type: ${accountType}`, ErrorCode.DB_QUERY_FAILED, { accountType, operation: "getProjectsByAccountType" }, error instanceof Error ? error : void 0 ); } } /** * Get current project */ getCurrentProject() { if (!this.currentProject) { this.detectProject(); } return this.currentProject; } /** * Save organization config */ saveOrganization(org) { const errorHandler = createErrorHandler({ operation: "saveOrganization", orgName: org.name }); try { this.organizations.set(org.name, org); const configFile = join(this.configPath, "organizations.json"); const config = { organizations: Array.from(this.organizations.values()) }; writeFileSync(configFile, JSON.stringify(config, null, 2)); const stmt = this.db.prepare(` INSERT OR REPLACE INTO organizations (name, type, account_type, config) VALUES (?, ?, ?, ?) `); stmt.run(org.name, org.type, org.accountType, JSON.stringify(org)); } catch (error) { const wrappedError = errorHandler(error, { orgName: org.name, operation: "saveOrganization" }); throw new DatabaseError( `Failed to save organization: ${org.name}`, ErrorCode.DB_QUERY_FAILED, { orgName: org.name, operation: "saveOrganization" }, error instanceof Error ? error : void 0 ); } } /** * Auto-categorize all Git repositories in home directory */ async scanAndCategorizeAllProjects(basePaths) { const paths = basePaths || [ join(homedir(), "Dev"), join(homedir(), "dev"), join(homedir(), "Projects"), join(homedir(), "projects"), join(homedir(), "Work"), join(homedir(), "work"), join(homedir(), "Documents/GitHub"), join(homedir(), "code") ]; logger.info("Scanning for Git repositories..."); for (const basePath of paths) { if (!existsSync(basePath)) continue; try { const gitDirs = execSync( `find ${basePath} -type d -name .git -maxdepth 4 2>/dev/null`, { encoding: "utf-8", timeout: 3e4 } // 30 second timeout ).trim().split("\n").filter(Boolean); for (const gitDir of gitDirs) { const projectPath = dirname(gitDir); try { await this.detectProject(projectPath); logger.info(`Discovered project: ${projectPath}`); } catch (error) { logger.warn(`Failed to analyze project: ${projectPath}`, { projectPath, error: error instanceof Error ? error.message : String(error), operation: "scanAndCategorizeAllProjects" }); } } } catch (error) { logger.warn( `Failed to scan ${basePath}`, error instanceof Error ? { error } : void 0 ); } } logger.info(`Scan complete. Found ${this.projectCache.size} projects`); } /** * Generate summary report */ generateReport() { const allProjects = this.getAllProjects(); const report = { total: allProjects.length, byAccountType: {}, byOrganization: {}, byLanguage: {}, byFramework: {} }; for (const project of allProjects) { report.byAccountType[project.accountType] = (report.byAccountType[project.accountType] || 0) + 1; if (project.organization) { report.byOrganization[project.organization] = (report.byOrganization[project.organization] || 0) + 1; } if (project.primaryLanguage) { report.byLanguage[project.primaryLanguage] = (report.byLanguage[project.primaryLanguage] || 0) + 1; } if (project.framework) { report.byFramework[project.framework] = (report.byFramework[project.framework] || 0) + 1; } } return JSON.stringify(report, null, 2); } } export { ProjectManager }; //# sourceMappingURL=project-manager.js.map