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.

411 lines (407 loc) 13.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 { logger } from "../../core/monitoring/logger.js"; class TaskAwareContextManager { db; frameManager; projectId; constructor(db, frameManager, projectId) { this.db = db; this.frameManager = frameManager; this.projectId = projectId; this.initializeTaskSchema(); } initializeTaskSchema() { this.db.exec(` CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY, frame_id TEXT NOT NULL, anchor_id TEXT, name TEXT NOT NULL, description TEXT, status TEXT DEFAULT 'pending', priority TEXT DEFAULT 'medium', parent_task_id TEXT, depends_on TEXT DEFAULT '[]', assigned_to TEXT, estimated_effort INTEGER, actual_effort INTEGER, created_at INTEGER DEFAULT (unixepoch()), started_at INTEGER, completed_at INTEGER, blocked_reason TEXT, context_tags TEXT DEFAULT '[]', metadata TEXT DEFAULT '{}', FOREIGN KEY(frame_id) REFERENCES frames(frame_id), FOREIGN KEY(anchor_id) REFERENCES anchors(anchor_id), FOREIGN KEY(parent_task_id) REFERENCES tasks(task_id) ); CREATE TABLE IF NOT EXISTS task_dependencies ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, depends_on_task_id TEXT NOT NULL, dependency_type TEXT DEFAULT 'blocks', created_at INTEGER DEFAULT (unixepoch()), FOREIGN KEY(task_id) REFERENCES tasks(task_id), FOREIGN KEY(depends_on_task_id) REFERENCES tasks(task_id) ); CREATE TABLE IF NOT EXISTS context_access_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL, task_ids TEXT, -- JSON array of relevant task IDs context_items TEXT, -- JSON array of included context items relevance_scores TEXT, -- JSON object of item -> score mappings total_tokens INTEGER, query_hash TEXT, timestamp INTEGER DEFAULT (unixepoch()) ); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); CREATE INDEX IF NOT EXISTS idx_tasks_frame ON tasks(frame_id); CREATE INDEX IF NOT EXISTS idx_task_deps ON task_dependencies(task_id); `); } /** * Create task from TODO anchor or standalone */ createTask(options) { const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const frameId = options.frameId || this.frameManager.getCurrentFrameId(); if (!frameId) { throw new Error("No active frame for task creation"); } const task = { task_id: taskId, frame_id: frameId, anchor_id: options.anchorId, name: options.name, description: options.description, status: "pending", priority: options.priority || "medium", parent_task_id: options.parentTaskId, depends_on: options.dependsOn || [], estimated_effort: options.estimatedEffort, created_at: Math.floor(Date.now() / 1e3), context_tags: options.contextTags || [], metadata: options.metadata || {} }; this.db.prepare( ` INSERT INTO tasks ( task_id, frame_id, anchor_id, name, description, status, priority, parent_task_id, depends_on, estimated_effort, created_at, context_tags, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` ).run( task.task_id, task.frame_id, task.anchor_id, task.name, task.description, task.status, task.priority, task.parent_task_id, JSON.stringify(task.depends_on), task.estimated_effort, task.created_at, JSON.stringify(task.context_tags), JSON.stringify(task.metadata) ); if (task.depends_on.length > 0) { const dependencyStmt = this.db.prepare(` INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?) `); task.depends_on.forEach((depTaskId) => { dependencyStmt.run(taskId, depTaskId); }); } this.frameManager.addEvent("decision", { action: "create_task", task_id: taskId, name: task.name, priority: task.priority }); logger.info("Created task", { taskId, name: task.name, frameId }); return taskId; } /** * Update task status with automatic time tracking */ updateTaskStatus(taskId, newStatus, reason) { const task = this.getTask(taskId); if (!task) throw new Error(`Task not found: ${taskId}`); const now = Math.floor(Date.now() / 1e3); const updates = { status: newStatus }; if (newStatus === "in_progress" && task.status === "pending") { updates.started_at = now; } else if (newStatus === "completed" && task.status === "in_progress") { updates.completed_at = now; if (task.started_at) { updates.actual_effort = now - task.started_at; } } else if (newStatus === "blocked") { updates.blocked_reason = reason || "No reason provided"; } const setClause = Object.keys(updates).map((key) => `${key} = ?`).join(", "); const values = Object.values(updates); this.db.prepare(`UPDATE tasks SET ${setClause} WHERE task_id = ?`).run(...values, taskId); this.frameManager.addEvent("observation", { action: "task_status_change", task_id: taskId, old_status: task.status, new_status: newStatus, reason }); logger.info("Updated task status", { taskId, oldStatus: task.status, newStatus }); } /** * Assemble context optimized for active tasks and query */ assembleTaskAwareContext(request) { const startTime = Date.now(); const activeTasks = this.getActiveTasks(request.taskFocus); const blockedTasks = this.getBlockedTasks(); const contextItems = this.selectRelevantContext(activeTasks, request); const { context, totalTokens, relevanceScores } = this.buildContextString( contextItems, activeTasks, request.maxTokens || 4e3 ); this.logContextAccess({ taskIds: activeTasks.map((t) => t.task_id), contextItems: contextItems.map((item) => item.id), relevanceScores, totalTokens, query: request.query || "" }); const metadata = { includedTasks: activeTasks, contextSources: contextItems.map((item) => `${item.type}:${item.id}`), totalTokens, relevanceScores }; logger.info("Assembled task-aware context", { activeTasks: activeTasks.length, blockedTasks: blockedTasks.length, contextItems: contextItems.length, totalTokens, assemblyTimeMs: Date.now() - startTime }); return { context, metadata }; } /** * Get tasks that are currently active or should be in context */ getActiveTasks(taskFocus) { let query = ` SELECT * FROM tasks WHERE status IN ('in_progress', 'pending') `; let params = []; if (taskFocus && taskFocus.length > 0) { query += ` AND task_id IN (${taskFocus.map(() => "?").join(",")})`; params = taskFocus; } query += ` ORDER BY priority DESC, created_at ASC`; const rows = this.db.prepare(query).all(...params); return this.hydrateTasks(rows); } getBlockedTasks() { const rows = this.db.prepare( ` SELECT * FROM tasks WHERE status = 'blocked' ORDER BY priority DESC ` ).all(); return this.hydrateTasks(rows); } /** * Select context items relevant to active tasks */ selectRelevantContext(activeTasks, request) { const contextItems = []; const frameIds = [...new Set(activeTasks.map((t) => t.frame_id))]; frameIds.forEach((frameId) => { const frame = this.frameManager.getFrame(frameId); if (frame) { const score = this.calculateFrameRelevance( frame, activeTasks, request.query ); contextItems.push({ id: frameId, type: "frame", content: `Frame: ${frame.name} (${frame.type})`, relevanceScore: score, tokenEstimate: frame.name.length + 20 }); } }); const anchors = this.getRelevantAnchors(frameIds, request); anchors.forEach((anchor) => { const score = this.calculateAnchorRelevance( anchor, activeTasks, request.query ); contextItems.push({ id: anchor.anchor_id, type: "anchor", content: `${anchor.type}: ${anchor.text}`, relevanceScore: score, tokenEstimate: anchor.text.length + 10 }); }); if (request.includeHistory) { frameIds.forEach((frameId) => { const events = this.frameManager.getFrameEvents(frameId, 5); events.forEach((event) => { const score = this.calculateEventRelevance( event, activeTasks, request.query ); if (score > 0.3) { contextItems.push({ id: event.event_id, type: "event", content: `Event: ${event.event_type}`, relevanceScore: score, tokenEstimate: 30 }); } }); }); } return contextItems.sort((a, b) => b.relevanceScore - a.relevanceScore); } buildContextString(contextItems, activeTasks, maxTokens) { let context = "# Active Task Context\n\n"; let totalTokens = 20; const relevanceScores = {}; context += "## Current Tasks\n"; activeTasks.forEach((task) => { const line = `- [${task.status.toUpperCase()}] ${task.name} (${task.priority}) `; context += line; totalTokens += line.length / 4; relevanceScores[task.task_id] = 1; }); context += "\n"; context += "## Relevant Context\n"; for (const item of contextItems) { if (totalTokens + item.tokenEstimate > maxTokens) break; context += `${item.content} `; totalTokens += item.tokenEstimate; relevanceScores[item.id] = item.relevanceScore; } return { context, totalTokens, relevanceScores }; } // Relevance scoring methods calculateFrameRelevance(frame, activeTasks, query) { let score = 0.5; if (activeTasks.some((t) => t.frame_id === frame.frame_id)) { score += 0.4; } if (query) { const queryLower = query.toLowerCase(); if (frame.name.toLowerCase().includes(queryLower)) { score += 0.3; } } const ageHours = (Date.now() / 1e3 - frame.created_at) / 3600; if (ageHours < 24) score += 0.2; return Math.min(score, 1); } calculateAnchorRelevance(anchor, activeTasks, query) { let score = 0.3; if (anchor.type === "TODO") score += 0.4; if (anchor.type === "DECISION") score += 0.3; if (anchor.type === "CONSTRAINT") score += 0.2; score += anchor.priority / 10 * 0.2; if (query) { const queryLower = query.toLowerCase(); if (anchor.text.toLowerCase().includes(queryLower)) { score += 0.3; } } return Math.min(score, 1); } calculateEventRelevance(event, _activeTasks, _query) { let score = 0.1; if (event.event_type === "decision") score += 0.4; if (event.event_type === "tool_call") score += 0.3; if (event.event_type === "observation") score += 0.2; const ageHours = (Date.now() / 1e3 - event.ts) / 3600; if (ageHours < 1) score += 0.3; else if (ageHours < 6) score += 0.2; return Math.min(score, 1); } // Helper methods getTask(taskId) { const row = this.db.prepare(`SELECT * FROM tasks WHERE task_id = ?`).get(taskId); return row ? this.hydrateTask(row) : void 0; } getRelevantAnchors(frameIds, _request) { if (frameIds.length === 0) return []; const placeholders = frameIds.map(() => "?").join(","); const rows = this.db.prepare( ` SELECT * FROM anchors WHERE frame_id IN (${placeholders}) ORDER BY priority DESC, created_at DESC LIMIT 20 ` ).all(...frameIds); return rows.map((row) => ({ ...row, metadata: JSON.parse(row.metadata || "{}") })); } hydrateTasks(rows) { return rows.map(this.hydrateTask); } hydrateTask = (row) => ({ ...row, depends_on: JSON.parse(row.depends_on || "[]"), context_tags: JSON.parse(row.context_tags || "[]"), metadata: JSON.parse(row.metadata || "{}") }); logContextAccess(data) { const requestId = `ctx_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; this.db.prepare( ` INSERT INTO context_access_log ( request_id, task_ids, context_items, relevance_scores, total_tokens, query_hash ) VALUES (?, ?, ?, ?, ?, ?) ` ).run( requestId, JSON.stringify(data.taskIds), JSON.stringify(data.contextItems), JSON.stringify(data.relevanceScores), data.totalTokens, this.hashString(data.query) ); } hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return hash.toString(16); } } export { TaskAwareContextManager }; //# sourceMappingURL=task-aware-context.js.map