UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

440 lines (439 loc) 14.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 { existsSync } from "fs"; import { join } from "path"; import { homedir } from "os"; class DaemonMaintenanceService { config; state; embeddingProvider = null; intervalId; gcIntervalId; isRunning = false; onLog; constructor(config, onLog) { this.config = config; this.onLog = onLog; this.state = { lastRunTime: 0, lastFtsRebuild: 0, lastVacuum: 0, staleFramesCleaned: 0, embeddingsGenerated: 0, embeddingsTotal: 0, embeddingsRemaining: 0, ftsRebuilds: 0, framesGarbageCollected: 0, lastGcRun: 0, errors: [] }; } start() { if (this.isRunning || !this.config.enabled) { return; } this.isRunning = true; const intervalMs = this.config.interval * 60 * 1e3; this.onLog("INFO", "Maintenance service started", { interval: this.config.interval, staleThresholdDays: this.config.staleFrameThresholdDays, ftsRebuildInterval: this.config.ftsRebuildInterval, vacuumInterval: this.config.vacuumInterval }); this.intervalId = setInterval(() => { this.runMaintenance().catch((err) => { this.addError( `Maintenance cycle failed: ${err instanceof Error ? err.message : String(err)}` ); }); }, intervalMs); if (this.config.gcEnabled !== false) { const gcMs = (this.config.gcIntervalSeconds ?? 60) * 1e3; this.gcIntervalId = setInterval(() => { this.runGCCycle().catch((err) => { this.addError( `GC cycle: ${err instanceof Error ? err.message : String(err)}` ); }); }, gcMs); } } stop() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = void 0; } if (this.gcIntervalId) { clearInterval(this.gcIntervalId); this.gcIntervalId = void 0; } this.isRunning = false; this.onLog("INFO", "Maintenance service stopped"); } getState() { return { ...this.state }; } updateConfig(config) { const wasRunning = this.isRunning; if (wasRunning) { this.stop(); } this.config = { ...this.config, ...config }; if (wasRunning && this.config.enabled) { this.start(); } } /** * Force-run all maintenance tasks immediately */ async forceRun() { await this.runMaintenance(); } /** * Run all maintenance tasks in sequence */ async runMaintenance() { const startTime = Date.now(); this.onLog("INFO", "Starting maintenance cycle"); try { const db = await this.getDatabase(); if (!db) { this.onLog("WARN", "No database available for maintenance"); return; } await this.cleanStaleFrames(db); await this.maybeRebuildFts(db); await this.backfillEmbeddings(db); await this.maybeVacuum(db); await this.generateMissingDigests(db); await this.recomputeScores(db); await this.runGC(db); await db.disconnect(); this.state.lastRunTime = Date.now(); this.onLog("INFO", "Maintenance cycle completed", { durationMs: Date.now() - startTime, staleFramesCleaned: this.state.staleFramesCleaned, ftsRebuilds: this.state.ftsRebuilds, embeddingsGenerated: this.state.embeddingsGenerated }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); this.addError(errorMsg); this.onLog("ERROR", "Maintenance cycle failed", { error: errorMsg }); } } async cleanStaleFrames(db) { try { const thresholdSec = Math.floor(Date.now() / 1e3) - this.config.staleFrameThresholdDays * 24 * 3600; const rawDb = db.getRawDatabase?.(); if (!rawDb) return; const result = rawDb.prepare( "UPDATE frames SET state = 'stale' WHERE state = 'active' AND created_at < ?" ).run(thresholdSec); const cleaned = result.changes || 0; this.state.staleFramesCleaned += cleaned; if (cleaned > 0) { this.onLog("INFO", `Marked ${cleaned} stale frames`); } } catch (err) { this.addError( `Stale frame cleanup: ${err instanceof Error ? err.message : String(err)}` ); } } async maybeRebuildFts(db) { try { const hoursSinceLastRebuild = (Date.now() - this.state.lastFtsRebuild) / (1e3 * 3600); if (hoursSinceLastRebuild < this.config.ftsRebuildInterval) return; if (typeof db.rebuildFtsIndex === "function") { await db.rebuildFtsIndex(); this.state.lastFtsRebuild = Date.now(); this.state.ftsRebuilds++; this.onLog("INFO", "FTS index rebuilt"); } } catch (err) { this.addError( `FTS rebuild: ${err instanceof Error ? err.message : String(err)}` ); } } async backfillEmbeddings(db) { try { if (typeof db.getFramesMissingEmbeddings !== "function") return; if (!db.getFeatures?.().supportsVectorSearch) return; if (!this.embeddingProvider) return; let sinceRowid; if (typeof db.getMaintenanceState === "function") { const lastId = await db.getMaintenanceState( "embedding_backfill_last_id" ); if (lastId != null) { sinceRowid = parseInt(lastId, 10); if (isNaN(sinceRowid)) sinceRowid = void 0; } } const frames = await db.getFramesMissingEmbeddings( this.config.embeddingBatchSize, sinceRowid ); if (typeof db.getMaintenanceState === "function") { const totalStr = await db.getMaintenanceState( "embedding_backfill_total" ); const completedStr = await db.getMaintenanceState( "embedding_backfill_completed" ); if (totalStr != null) this.state.embeddingsTotal = parseInt(totalStr, 10) || 0; if (completedStr != null) { const completed = parseInt(completedStr, 10) || 0; this.state.embeddingsRemaining = Math.max( 0, this.state.embeddingsTotal - completed ); } } if (frames.length === 0) return; if (this.state.embeddingsTotal === 0 && typeof db.setMaintenanceState === "function") { const rawDb = db.getRawDatabase?.(); if (rawDb) { const countResult = rawDb.prepare( `SELECT COUNT(*) as count FROM frames f LEFT JOIN frame_embeddings ve ON f.frame_id = ve.frame_id WHERE ve.frame_id IS NULL` ).get(); this.state.embeddingsTotal = countResult.count; await db.setMaintenanceState( "embedding_backfill_total", String(this.state.embeddingsTotal) ); } } this.onLog("INFO", `Generating embeddings for ${frames.length} frames`); let generated = 0; let lastRowid; for (const frame of frames) { try { const text = [ frame.name, frame.digest_text, JSON.stringify(frame.inputs) ].filter(Boolean).join(" "); if (!text.trim()) continue; const embedding = await this.embeddingProvider.embed(text); await db.storeEmbedding(frame.frame_id, embedding); generated++; const rawDb = db.getRawDatabase?.(); if (rawDb) { const rowidRow = rawDb.prepare("SELECT rowid FROM frames WHERE frame_id = ?").get(frame.frame_id); if (rowidRow) lastRowid = rowidRow.rowid; } } catch (err) { this.addError( `Embedding frame ${frame.frame_id}: ${err instanceof Error ? err.message : String(err)}` ); } } if (typeof db.setMaintenanceState === "function") { if (lastRowid != null) { await db.setMaintenanceState( "embedding_backfill_last_id", String(lastRowid) ); } const prevCompleted = await db.getMaintenanceState( "embedding_backfill_completed" ); const totalCompleted = (parseInt(prevCompleted ?? "0", 10) || 0) + generated; await db.setMaintenanceState( "embedding_backfill_completed", String(totalCompleted) ); this.state.embeddingsRemaining = Math.max( 0, this.state.embeddingsTotal - totalCompleted ); } this.state.embeddingsGenerated += generated; this.onLog("INFO", `Generated ${generated} embeddings`); } catch (err) { this.addError( `Embedding backfill: ${err instanceof Error ? err.message : String(err)}` ); } } async maybeVacuum(db) { try { const hoursSinceLastVacuum = (Date.now() - this.state.lastVacuum) / (1e3 * 3600); if (hoursSinceLastVacuum < this.config.vacuumInterval) return; const rawDb = db.getRawDatabase?.(); if (!rawDb) return; rawDb.pragma("optimize"); rawDb.pragma("vacuum"); this.state.lastVacuum = Date.now(); this.onLog("INFO", "Database optimized and vacuumed"); } catch (err) { this.addError( `VACUUM: ${err instanceof Error ? err.message : String(err)}` ); } } async generateMissingDigests(db) { try { const rawDb = db.getRawDatabase?.(); if (!rawDb) return; const count = rawDb.prepare( "SELECT COUNT(*) as count FROM frames WHERE digest_text IS NULL AND state = 'active'" ).get().count; if (count > 0) { this.onLog("INFO", `Found ${count} frames missing digest_text`); } } catch (err) { this.addError( `Digest generation: ${err instanceof Error ? err.message : String(err)}` ); } } /** * Dedicated lightweight GC cycle (runs more frequently than full maintenance). * Queries active run_ids for protection and delegates to adapter.runGC(). */ async runGCCycle() { try { const db = await this.getDatabase(); if (!db) return; const rawDb = db.getRawDatabase?.(); let protectedRunIds = []; if (rawDb) { const rows = rawDb.prepare( "SELECT DISTINCT run_id FROM frames WHERE state = 'active' LIMIT 10" ).all(); protectedRunIds = rows.map((r) => r.run_id); } const result = await db.runGC({ retentionDays: this.config.gcRetentionDays ?? 90, batchSize: this.config.gcBatchSize ?? 100, dryRun: false, protectedRunIds }); this.state.framesGarbageCollected += result.framesDeleted; this.state.lastGcRun = Date.now(); if (result.framesDeleted > 0) { this.onLog( "INFO", `GC cycle deleted ${result.framesDeleted} expired frames` ); } await db.disconnect(); } catch (err) { this.addError( `GC cycle: ${err instanceof Error ? err.message : String(err)}` ); } } async recomputeScores(db) { try { if (typeof db.recomputeImportanceScores !== "function") return; const updated = db.recomputeImportanceScores(100); if (updated > 0) { this.onLog("INFO", `Recomputed ${updated} importance scores`); } } catch (err) { this.addError( `Score recompute: ${err instanceof Error ? err.message : String(err)}` ); } } async runGC(db) { try { if (this.config.gcEnabled === false) return; if (typeof db.runGC !== "function") return; const rawDb = db.getRawDatabase?.(); let protectedRunIds = []; if (rawDb) { const rows = rawDb.prepare( "SELECT DISTINCT run_id FROM frames WHERE state = 'active' LIMIT 10" ).all(); protectedRunIds = rows.map((r) => r.run_id); } const result = await db.runGC({ retentionDays: this.config.gcRetentionDays ?? 90, batchSize: this.config.gcBatchSize ?? 100, dryRun: false, protectedRunIds }); this.state.framesGarbageCollected += result.framesDeleted; this.state.lastGcRun = Date.now(); if (result.framesDeleted > 0) { this.onLog( "INFO", `GC deleted ${result.framesDeleted} expired frames`, { eventsDeleted: result.eventsDeleted, anchorsDeleted: result.anchorsDeleted, embeddingsDeleted: result.embeddingsDeleted } ); } } catch (err) { this.addError(`GC: ${err instanceof Error ? err.message : String(err)}`); } } async getDatabase() { try { const { SQLiteAdapter } = await import("../../core/database/sqlite-adapter.js"); const { EmbeddingProviderFactory } = await import("../../core/database/embedding-provider-factory.js"); const dbPath = this.findDatabasePath(); if (!dbPath) { this.onLog("WARN", "No database found for maintenance"); return null; } if (!this.embeddingProvider) { this.embeddingProvider = await EmbeddingProviderFactory.create({ provider: this.config.embeddingProvider ?? "transformers", model: this.config.embeddingModel, dimension: this.config.embeddingDimension, apiKey: this.config.embeddingApiKey, baseUrl: this.config.embeddingBaseUrl, fallbackProviders: this.config.embeddingFallbackProviders }); } const adapter = new SQLiteAdapter("maintenance", { dbPath, embeddingProvider: this.embeddingProvider ?? void 0 }); await adapter.connect(); await adapter.initializeSchema(); return adapter; } catch (err) { this.addError( `Database init: ${err instanceof Error ? err.message : String(err)}` ); return null; } } findDatabasePath() { const homeDir = homedir(); const candidates = [ join(process.cwd(), ".stackmemory", "stackmemory.db"), join(homeDir, ".stackmemory", "stackmemory.db") ]; for (const candidate of candidates) { if (existsSync(candidate)) { return candidate; } } return null; } addError(msg) { this.state.errors.push(msg); if (this.state.errors.length > 10) { this.state.errors = this.state.errors.slice(-10); } } } export { DaemonMaintenanceService };