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.

488 lines (487 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 { EventEmitter } from "events"; import { logger } from "../../core/monitoring/logger.js"; import { LinearClient } from "../../integrations/linear/client.js"; import { ProjectIsolationManager } from "../../core/projects/project-isolation.js"; class LinearTaskManager extends EventEmitter { tasks = /* @__PURE__ */ new Map(); linearClient; config; projectId; syncTimer; isolationManager; lastSyncTimestamp = 0; syncInProgress = false; constructor(config = {}, projectId, projectRoot) { super(); this.config = config; this.projectId = projectId; this.isolationManager = ProjectIsolationManager.getInstance(); if (projectRoot) { const projectInfo = this.isolationManager.getProjectIdentification(projectRoot); this.projectId = projectInfo.projectId; this.config = { ...config, teamId: config.teamId || projectInfo.linearTeamId, projectFilter: config.projectFilter || projectInfo.workspaceFilter, batchSize: config.batchSize || 5, // Conservative batch size for rate limiting rateLimitDelay: config.rateLimitDelay || 1e3 // 1 second between calls }; } if (config.linearApiKey) { this.linearClient = new LinearClient({ apiKey: config.linearApiKey, teamId: this.config.teamId }); } if (config.autoSync && config.syncInterval && this.linearClient) { this.setupAutoSync(); } } /** * Create a new task */ createTask(options) { const id = this.generateTaskId(); const now = /* @__PURE__ */ new Date(); const task = { id, title: options.title, description: options.description || "", status: "todo", priority: options.priority || "medium", tags: [...options.tags || [], ...this.getProjectTags()], metadata: { ...options.metadata, projectId: this.projectId, teamId: this.config.teamId, projectFilter: this.config.projectFilter }, createdAt: now, updatedAt: now }; this.tasks.set(id, task); this.emit("task:created", task); this.emit("sync:needed", "task:created"); return id; } /** * Update task status */ updateTaskStatus(taskId, newStatus, _reason) { const task = this.tasks.get(taskId); if (!task) { throw new Error(`Task not found: ${taskId}`); } task.status = newStatus; task.updatedAt = /* @__PURE__ */ new Date(); this.tasks.set(taskId, task); if (newStatus === "done") { this.emit("task:completed", task); } this.emit("task:updated", task); this.emit("sync:needed", "task:updated"); } /** * Get task by ID */ getTask(taskId) { return this.tasks.get(taskId); } /** * Get all active tasks (not done/cancelled) */ getActiveTasks() { return Array.from(this.tasks.values()).filter((task) => !["done", "cancelled"].includes(task.status)).sort((a, b) => { const priorityOrder = { urgent: 4, high: 3, medium: 2, low: 1 }; const aPriority = priorityOrder[a.priority || "medium"]; const bPriority = priorityOrder[b.priority || "medium"]; if (aPriority !== bPriority) { return bPriority - aPriority; } return a.createdAt.getTime() - b.createdAt.getTime(); }); } /** * Get tasks by status */ getTasksByStatus(status) { return Array.from(this.tasks.values()).filter( (task) => task.status === status ); } /** * Get metrics for tasks */ getMetrics() { const allTasks = Array.from(this.tasks.values()); const totalTasks = allTasks.length; const byStatus = { todo: 0, in_progress: 0, done: 0, cancelled: 0 }; const byPriority = { low: 0, medium: 0, high: 0, urgent: 0 }; for (const task of allTasks) { byStatus[task.status]++; if (task.priority) { byPriority[task.priority]++; } } const completedTasks = byStatus.done; const completionRate = totalTasks > 0 ? completedTasks / totalTasks : 0; return { total_tasks: totalTasks, by_status: byStatus, by_priority: byPriority, completion_rate: completionRate, avg_effort_accuracy: 0, // Not implemented in this simplified version blocked_tasks: 0, // Could be implemented with tags or metadata overdue_tasks: 0 // Could be implemented with due dates }; } /** * Sync with Linear workspace (incremental with rate limiting and exponential backoff) */ async syncWithLinear() { if (!this.linearClient) { throw new Error("Linear client not initialized"); } if (this.syncInProgress) { logger.warn("Sync already in progress, skipping"); return { synced: 0, errors: ["Sync already in progress"] }; } this.syncInProgress = true; const errors = []; let synced = 0; try { const tasksToSync = this.getTasksRequiringSync(); const batchSize = this.config.batchSize || 5; const rateLimitDelay = this.config.rateLimitDelay || 1e3; logger.info( `Starting incremental sync: ${tasksToSync.length} tasks to process` ); for (let i = 0; i < tasksToSync.length; i += batchSize) { const batch = tasksToSync.slice(i, i + batchSize); for (const task of batch) { try { await this.withExponentialBackoff( async () => { if (!task.externalId) { const linearIssue = await this.createLinearIssue(task); task.externalId = linearIssue.id; task.externalIdentifier = linearIssue.identifier; task.externalUrl = linearIssue.url; task.updatedAt = /* @__PURE__ */ new Date(); logger.debug( `Created Linear issue: ${linearIssue.identifier} for task ${task.id}` ); } else { await this.updateLinearIssue(task); logger.debug( `Updated Linear issue: ${task.externalIdentifier} for task ${task.id}` ); } }, 3, rateLimitDelay ); synced++; if (rateLimitDelay > 0) { await this.sleep(rateLimitDelay); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); errors.push(`Failed to sync task ${task.id}: ${errorMsg}`); logger.error("Failed to sync task to Linear after retries", { taskId: task.id, error: errorMsg, projectId: this.projectId }); } } if (i + batchSize < tasksToSync.length && rateLimitDelay > 0) { await this.sleep(rateLimitDelay * 2); } } this.lastSyncTimestamp = Date.now(); this.emit("sync:completed", { synced, errors, projectId: this.projectId }); logger.info( `Linear sync completed: ${synced} tasks synced, ${errors.length} errors` ); return { synced, errors }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); errors.push(`Sync failed: ${errorMsg}`); logger.error("Linear sync failed", { error: errorMsg, projectId: this.projectId }); throw new Error(`Linear sync failed: ${errorMsg}`); } finally { this.syncInProgress = false; } } /** * Load tasks from Linear */ async loadFromLinear() { if (!this.linearClient || !this.config.teamId) { throw new Error("Linear client or team ID not configured"); } try { const issues = await this.linearClient.getIssues({ teamId: this.config.teamId }); let loaded = 0; for (const issue of issues) { if (this.shouldIncludeIssue(issue)) { const task = this.convertLinearIssueToTask(issue); this.tasks.set(task.id, task); loaded++; } } logger.info( `Loaded ${loaded} tasks from Linear for project ${this.projectId}` ); this.emit("tasks:loaded", { count: loaded, projectId: this.projectId }); return loaded; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error("Failed to load tasks from Linear", { error: errorMsg, projectId: this.projectId, teamId: this.config.teamId }); throw new Error(`Failed to load from Linear: ${errorMsg}`); } } /** * Clear all tasks (for testing or cleanup) */ clear() { this.tasks.clear(); this.emit("tasks:cleared"); } /** * Get task count */ getTaskCount() { return this.tasks.size; } // Private methods generateTaskId() { return `tsk-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } async createLinearIssue(task) { if (!this.linearClient || !this.config.teamId) { throw new Error("Linear client or team ID not configured"); } const priorityMap = { urgent: 1, high: 2, medium: 3, low: 4 }; return await this.linearClient.createIssue({ title: task.title, description: task.description, teamId: this.config.teamId, priority: task.priority ? priorityMap[task.priority] : 3 }); } convertLinearIssueToTask(issue) { const priorityMap = { 1: "urgent", 2: "high", 3: "medium", 4: "low" }; const statusMap = { backlog: "todo", unstarted: "todo", started: "in_progress", completed: "done", cancelled: "cancelled" }; return { id: `linear-${issue.id}`, title: issue.title, description: issue.description || "", status: statusMap[issue.state.type] || "todo", priority: priorityMap[issue.priority] || "medium", tags: issue.labels?.map((label) => label.name) || [], externalId: issue.id, externalIdentifier: issue.identifier, externalUrl: issue.url, metadata: { linear: { stateId: issue.state.id, assigneeId: issue.assignee?.id } }, createdAt: new Date(issue.createdAt), updatedAt: new Date(issue.updatedAt) }; } setupAutoSync() { if (this.syncTimer) { clearInterval(this.syncTimer); } const intervalMs = (this.config.syncInterval || 15) * 60 * 1e3; this.syncTimer = setInterval(async () => { try { await this.syncWithLinear(); } catch (error) { logger.error( "Auto-sync failed", error instanceof Error ? error : new Error(String(error)) ); } }, intervalMs); } /** * Get project-specific tags */ getProjectTags() { const tags = []; if (this.config.projectFilter) { tags.push(`project:${this.config.projectFilter}`); } if (this.projectId) { tags.push(`proj:${this.projectId.slice(-8)}`); } return tags; } /** * Check if a Linear issue should be included in this project */ shouldIncludeIssue(issue) { if (!this.config.projectFilter) { return true; } const projectTags = issue.labels?.map((label) => label.name) || []; return projectTags.some( (tag) => tag.includes(this.config.projectFilter) || tag.includes(`proj:${this.projectId?.slice(-8)}`) ); } /** * Get project information */ getProjectInfo() { return { projectId: this.projectId, teamId: this.config.teamId, projectFilter: this.config.projectFilter }; } /** * Cleanup resources */ /** * Get tasks that require syncing (created or updated since last sync) */ getTasksRequiringSync() { return Array.from(this.tasks.values()).filter((task) => { if (!task.externalId) { return true; } if (this.lastSyncTimestamp > 0) { return task.updatedAt.getTime() > this.lastSyncTimestamp; } return false; }); } /** * Update an existing Linear issue */ async updateLinearIssue(task) { if (!this.linearClient || !task.externalId) { return; } const priorityMap = { urgent: 1, high: 2, medium: 3, low: 4 }; if (task.updatedAt.getTime() <= this.lastSyncTimestamp) { return; } await this.linearClient.updateIssue(task.externalId, { title: task.title, description: task.description, priority: task.priority ? priorityMap[task.priority] : 3 }); } /** * Sleep for specified milliseconds */ sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Exponential backoff retry wrapper */ async withExponentialBackoff(operation, maxRetries = 3, baseDelay = 1e3) { let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt === maxRetries) { break; } const errorMsg = lastError.message.toLowerCase(); const isRateLimit = errorMsg.includes("rate limit") || errorMsg.includes("429") || errorMsg.includes("too many requests"); if (isRateLimit) { const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1e3; logger.warn( `Rate limit hit, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries + 1})` ); await this.sleep(delay); } else { const delay = baseDelay * Math.pow(1.5, attempt); logger.warn( `API error, retrying in ${delay}ms: ${lastError.message}` ); await this.sleep(delay); } } } throw lastError; } /** * Cleanup resources */ destroy() { if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = void 0; } this.removeAllListeners(); } } export { LinearTaskManager }; //# sourceMappingURL=linear-task-manager.js.map