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.

783 lines (778 loc) 24.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 { readFileSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; import { logger } from "../../core/monitoring/logger.js"; import { IntegrationError, ErrorCode } from "../../core/errors/index.js"; import { LinearClient } from "./client.js"; class LinearSyncEngine { taskStore; linearClient; authManager; config; mappings = /* @__PURE__ */ new Map(); projectRoot; mappingsPath; constructor(taskStore, authManager, config, projectRoot) { this.taskStore = taskStore; this.authManager = authManager; this.config = config; this.projectRoot = projectRoot || process.cwd(); this.mappingsPath = join( this.projectRoot, ".stackmemory", "linear-mappings.json" ); const apiKey = process.env["LINEAR_API_KEY"]; if (apiKey) { this.linearClient = new LinearClient({ apiKey }); } else { const tokens = this.authManager.loadTokens(); if (!tokens) { throw new IntegrationError( 'Linear API key or authentication tokens not found. Set LINEAR_API_KEY environment variable or run "stackmemory linear setup" first.', ErrorCode.LINEAR_SYNC_FAILED ); } this.linearClient = new LinearClient({ apiKey: tokens.accessToken, useBearer: true, onUnauthorized: async () => { const refreshed = await this.authManager.refreshAccessToken(); return refreshed.accessToken; } }); } this.loadMappings(); } /** * Update sync configuration */ updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; } /** * Perform bi-directional sync */ async sync() { if (!this.config.enabled) { return { success: false, synced: { toLinear: 0, fromLinear: 0, updated: 0 }, conflicts: [], errors: ["Sync is disabled"] }; } const result = { success: true, synced: { toLinear: 0, fromLinear: 0, updated: 0 }, conflicts: [], errors: [] }; try { const apiKey = process.env["LINEAR_API_KEY"]; if (!apiKey) { const token = await this.authManager.getValidToken(); this.linearClient = new LinearClient({ apiKey: token, useBearer: true, onUnauthorized: async () => { const refreshed = await this.authManager.refreshAccessToken(); return refreshed.accessToken; } }); } if (!this.config.defaultTeamId) { const team = await this.linearClient.getTeam(); this.config.defaultTeamId = team.id; logger.info(`Using Linear team: ${team.name} (${team.key})`); } if (this.config.direction === "bidirectional" || this.config.direction === "to_linear") { const toLinearResult = await this.syncToLinear(); result.synced.toLinear = toLinearResult.created; result.synced.updated += toLinearResult.updated; result.errors.push(...toLinearResult.errors); } if (this.config.direction === "bidirectional" || this.config.direction === "from_linear") { const fromLinearResult = await this.syncFromLinear(); result.synced.fromLinear = fromLinearResult.created; result.synced.updated += fromLinearResult.updated; result.conflicts.push(...fromLinearResult.conflicts); result.errors.push(...fromLinearResult.errors); } this.saveMappings(); } catch (error) { result.success = false; result.errors.push(`Sync failed: ${String(error)}`); logger.error("Linear sync failed:", error); } return result; } /** * Delay helper for rate limiting */ async delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Sync tasks from StackMemory to Linear */ async syncToLinear() { const result = { created: 0, updated: 0, errors: [] }; const maxBatchSize = this.config.maxBatchSize || 10; const rateLimitDelay = this.config.rateLimitDelay || 500; const duplicateDetector = new LinearDuplicateDetector(this.linearClient); const unsyncedTasks = this.getUnsyncedTasks(); const tasksToSync = unsyncedTasks.slice(0, maxBatchSize); if (unsyncedTasks.length > maxBatchSize) { logger.info( `Syncing ${tasksToSync.length} of ${unsyncedTasks.length} unsynced tasks (batch limit)` ); } for (const task of tasksToSync) { try { const duplicateCheck = await duplicateDetector.checkForDuplicate( task.title, this.config.defaultTeamId ); let linearIssue; if (duplicateCheck.isDuplicate && duplicateCheck.existingIssue) { logger.info( `Found existing Linear issue for "${task.title}": ${duplicateCheck.existingIssue.identifier} (${Math.round((duplicateCheck.similarity || 0) * 100)}% match)` ); linearIssue = await duplicateDetector.mergeIntoExisting( duplicateCheck.existingIssue, task.title, this.formatDescriptionForLinear(task), `StackMemory Task ID: ${task.id} Frame: ${task.frame_id}` ); } else { linearIssue = await this.createLinearIssueFromTask(task); } const mapping = { stackmemoryId: task.id, linearId: linearIssue.id, linearIdentifier: linearIssue.identifier, lastSyncTimestamp: Date.now(), lastLinearUpdate: linearIssue.updatedAt, lastStackMemoryUpdate: task.timestamp * 1e3 }; this.mappings.set(task.id, mapping); this.updateTaskWithLinearRef(task.id, linearIssue); result.created++; logger.info( `Synced task to Linear: ${task.title} \u2192 ${linearIssue.identifier}` ); await this.delay(rateLimitDelay); } catch (error) { const errorMsg = String(error); if (errorMsg.includes("rate limit") || errorMsg.includes("usage limit")) { logger.warn("Rate limit hit, stopping sync batch"); result.errors.push("Rate limit reached - sync paused"); break; } result.errors.push(`Failed to sync task ${task.id}: ${errorMsg}`); logger.error( `Failed to sync task ${task.id} to Linear:`, error ); } } const modifiedTasks = this.getModifiedTasks(); for (const task of modifiedTasks) { try { const mapping = this.mappings.get(task.id); if (!mapping) continue; await this.updateLinearIssueFromTask(task, mapping); mapping.lastSyncTimestamp = Date.now(); mapping.lastStackMemoryUpdate = task.timestamp * 1e3; result.updated++; logger.info(`Updated Linear issue: ${mapping.linearIdentifier}`); } catch (error) { result.errors.push( `Failed to update Linear issue for task ${task.id}: ${String(error)}` ); logger.error( `Failed to update Linear issue for task ${task.id}:`, error ); } } return result; } /** * Sync tasks from Linear to StackMemory */ async syncFromLinear() { const result = { created: 0, updated: 0, conflicts: [], errors: [] }; const importResult = await this.importFromLinear(); result.created = importResult.imported; result.errors.push(...importResult.errors); for (const [taskId, mapping] of this.mappings) { try { const linearIssue = await this.linearClient.getIssue(mapping.linearId); if (!linearIssue) { result.errors.push(`Linear issue ${mapping.linearId} not found`); continue; } const linearUpdateTime = new Date(linearIssue.updatedAt).getTime(); if (linearUpdateTime <= mapping.lastSyncTimestamp) { continue; } const task = this.taskStore.getTask(taskId); if (!task) { result.errors.push(`StackMemory task ${taskId} not found`); continue; } const stackMemoryUpdateTime = task.timestamp * 1e3; if (stackMemoryUpdateTime > mapping.lastSyncTimestamp && linearUpdateTime > mapping.lastSyncTimestamp) { result.conflicts.push({ taskId, linearId: mapping.linearId, reason: "Both StackMemory and Linear were updated since last sync" }); if (this.config.conflictResolution === "manual") { continue; } } const shouldUpdateFromLinear = this.shouldUpdateFromLinear( task, linearIssue, mapping, stackMemoryUpdateTime, linearUpdateTime ); if (shouldUpdateFromLinear) { this.updateTaskFromLinearIssue(task, linearIssue); mapping.lastSyncTimestamp = Date.now(); mapping.lastLinearUpdate = linearIssue.updatedAt; result.updated++; logger.info(`Updated StackMemory task from Linear: ${task.title}`); } } catch (error) { result.errors.push( `Failed to sync from Linear for task ${taskId}: ${String(error)}` ); logger.error( `Failed to sync from Linear for task ${taskId}:`, error ); } } return result; } /** * Create Linear issue from StackMemory task */ async createLinearIssueFromTask(task) { const input = { title: task.title, description: this.formatDescriptionForLinear(task), teamId: this.config.defaultTeamId, priority: this.mapPriorityToLinear(task.priority), estimate: task.estimated_effort ? Math.ceil(task.estimated_effort / 60) : void 0, // Convert minutes to hours labelIds: this.mapTagsToLinear(task.tags) }; return await this.linearClient.createIssue(input); } /** * Update Linear issue from StackMemory task */ async updateLinearIssueFromTask(task, mapping) { const updates = { title: task.title, description: this.formatDescriptionForLinear(task), priority: this.mapPriorityToLinear(task.priority), estimate: task.estimated_effort ? Math.ceil(task.estimated_effort / 60) : void 0, stateId: await this.mapStatusToLinearState(task.status) }; await this.linearClient.updateIssue(mapping.linearId, updates); } /** * Update StackMemory task from Linear issue */ updateTaskFromLinearIssue(task, linearIssue) { const newStatus = this.mapLinearStateToStatus(linearIssue.state.type); if (newStatus !== task.status) { this.taskStore.updateTaskStatus( task.id, newStatus, "Updated from Linear" ); } } /** * Check if task should be updated from Linear based on conflict resolution strategy */ shouldUpdateFromLinear(task, linearIssue, mapping, stackMemoryUpdateTime, linearUpdateTime) { switch (this.config.conflictResolution) { case "linear_wins": return true; case "stackmemory_wins": return false; case "newest_wins": return linearUpdateTime > stackMemoryUpdateTime; case "manual": return false; default: return false; } } /** * Get tasks that haven't been synced to Linear yet */ getUnsyncedTasks() { const activeTasks = this.taskStore.getActiveTasks(); return activeTasks.filter( (task) => !this.mappings.has(task.id) && !task.external_refs?.linear ); } /** * Get tasks that have been modified since last sync */ getModifiedTasks() { const tasks = []; for (const [taskId, mapping] of this.mappings) { const task = this.taskStore.getTask(taskId); if (task && task.timestamp * 1e3 > mapping.lastSyncTimestamp) { tasks.push(task); } } return tasks; } /** * Update task with Linear reference */ updateTaskWithLinearRef(taskId, linearIssue) { const task = this.taskStore.getTask(taskId); if (!task) return; logger.info(`Task ${taskId} mapped to Linear ${linearIssue.identifier}`); } // Mapping utilities formatDescriptionForLinear(task) { let description = task.description || ""; description += ` --- **StackMemory Context:** `; description += `- Task ID: ${task.id} `; description += `- Frame: ${task.frame_id} `; description += `- Created: ${new Date(task.created_at * 1e3).toISOString()} `; if (task.tags.length > 0) { description += `- Tags: ${task.tags.join(", ")} `; } if (task.depends_on.length > 0) { description += `- Dependencies: ${task.depends_on.join(", ")} `; } return description; } mapPriorityToLinear(priority) { const map = { low: 1, // Low priority in Linear medium: 2, // Medium priority in Linear high: 3, // High priority in Linear urgent: 4 // Urgent priority in Linear }; return map[priority] || 2; } mapTagsToLinear(_tags) { return void 0; } mapLinearStateToStatus(linearStateType) { switch (linearStateType) { case "backlog": case "unstarted": return "pending"; case "started": return "in_progress"; case "completed": return "completed"; case "cancelled": return "cancelled"; default: return "pending"; } } async mapStatusToLinearState(status) { try { const team = await this.linearClient.getTeam(); const states = await this.linearClient.getWorkflowStates(team.id); const targetStateType = this.getLinearStateTypeFromStatus(status); const matchingState = states.find( (state) => state.type === targetStateType ); return matchingState?.id; } catch (error) { logger.warn( "Failed to map status to Linear state:", error instanceof Error ? { error } : void 0 ); return void 0; } } getLinearStateTypeFromStatus(status) { switch (status) { case "pending": return "unstarted"; case "in_progress": return "started"; case "completed": return "completed"; case "cancelled": return "cancelled"; case "blocked": return "unstarted"; // Map blocked to unstarted in Linear default: return "unstarted"; } } // Persistence for mappings loadMappings() { this.mappings.clear(); if (existsSync(this.mappingsPath)) { try { const data = readFileSync(this.mappingsPath, "utf8"); const mappingsArray = JSON.parse(data); for (const mapping of mappingsArray) { this.mappings.set(mapping.stackmemoryId, mapping); } logger.info(`Loaded ${this.mappings.size} task mappings from disk`); } catch (error) { logger.warn("Failed to load mappings, starting fresh"); } } } saveMappings() { try { const mappingsArray = Array.from(this.mappings.values()); writeFileSync(this.mappingsPath, JSON.stringify(mappingsArray, null, 2)); logger.info(`Saved ${this.mappings.size} task mappings to disk`); } catch (error) { logger.error("Failed to save mappings:", error); } } /** * Import all issues from Linear to local task store */ async importFromLinear() { const result = { imported: 0, skipped: 0, errors: [] }; try { if (!this.config.defaultTeamId) { const team = await this.linearClient.getTeam(); this.config.defaultTeamId = team.id; logger.info(`Using Linear team: ${team.name} (${team.key})`); } const issues = await this.linearClient.getIssues({ teamId: this.config.defaultTeamId, limit: 100 }); logger.info(`Found ${issues.length} issues in Linear`); const linearIdToTaskId = /* @__PURE__ */ new Map(); for (const [taskId, mapping] of this.mappings) { linearIdToTaskId.set(mapping.linearId, taskId); } for (const issue of issues) { try { if (linearIdToTaskId.has(issue.id)) { result.skipped++; continue; } const taskId = await this.createTaskFromLinearIssue(issue); if (taskId) { const mapping = { stackmemoryId: taskId, linearId: issue.id, linearIdentifier: issue.identifier, lastSyncTimestamp: Date.now(), lastLinearUpdate: issue.updatedAt, lastStackMemoryUpdate: Date.now() }; this.mappings.set(taskId, mapping); result.imported++; logger.info(`Imported ${issue.identifier}: ${issue.title}`); } } catch (error) { result.errors.push( `Failed to import ${issue.identifier}: ${String(error)}` ); logger.error(`Failed to import ${issue.identifier}:`, error); } } this.saveMappings(); } catch (error) { result.errors.push(`Import failed: ${String(error)}`); logger.error("Linear import failed:", error); } return result; } /** * Create a local task from a Linear issue */ async createTaskFromLinearIssue(issue) { try { const priority = this.mapLinearPriorityToLocal(issue.priority); let description = issue.description || ""; description += ` --- **Linear:** ${issue.identifier} | ${issue.url}`; const labels = Array.isArray(issue.labels) ? issue.labels : issue.labels?.nodes || []; const tags = labels.map((l) => l.name); if (tags.length === 0) tags.push("linear"); const taskId = this.taskStore.createTask({ title: `[${issue.identifier}] ${issue.title}`, description, priority, frameId: "linear-import", tags, estimatedEffort: issue.estimate ? issue.estimate * 60 : void 0 }); const status = this.mapLinearStateToStatus(issue.state.type); if (status !== "pending") { this.taskStore.updateTaskStatus( taskId, status, `Imported from Linear as ${status}` ); } return taskId; } catch (error) { logger.error( `Failed to create task from Linear issue ${issue.identifier}: ${String(error)}` ); return null; } } /** * Map Linear priority (0-4) to local TaskPriority */ mapLinearPriorityToLocal(priority) { switch (priority) { case 1: return "urgent"; case 2: return "high"; case 3: return "medium"; case 4: return "low"; default: return "medium"; } } } const DEFAULT_SYNC_CONFIG = { enabled: false, direction: "bidirectional", autoSync: true, conflictResolution: "newest_wins", syncInterval: 15, // minutes maxBatchSize: 10, // max tasks per sync batch rateLimitDelay: 500 // 500ms between API calls }; class LinearDuplicateDetector { linearClient; titleCache = /* @__PURE__ */ new Map(); cacheExpiry = 5 * 60 * 1e3; // 5 minutes lastCacheRefresh = 0; constructor(linearClient) { this.linearClient = linearClient; } /** * Search for existing Linear issues with similar titles */ async searchByTitle(title, teamId) { const normalizedTitle = this.normalizeTitle(title); if (this.isCacheValid()) { const cached = this.titleCache.get(normalizedTitle); if (cached) return cached; } try { const allIssues = await this.linearClient.getIssues({ teamId, limit: 100 // Use smaller limit to avoid API errors }); const matchingIssues = allIssues.filter((issue) => { const issueNormalized = this.normalizeTitle(issue.title); if (issueNormalized === normalizedTitle) return true; const similarity = this.calculateSimilarity( normalizedTitle, issueNormalized ); return similarity > 0.85; }); this.titleCache.set(normalizedTitle, matchingIssues); this.lastCacheRefresh = Date.now(); return matchingIssues; } catch (error) { logger.error("Failed to search Linear issues by title:", error); return []; } } /** * Check if a task title would create a duplicate in Linear */ async checkForDuplicate(title, teamId) { const existingIssues = await this.searchByTitle(title, teamId); if (existingIssues.length === 0) { return { isDuplicate: false }; } let bestMatch; let bestSimilarity = 0; for (const issue of existingIssues) { const similarity = this.calculateSimilarity( this.normalizeTitle(title), this.normalizeTitle(issue.title) ); if (similarity > bestSimilarity) { bestSimilarity = similarity; bestMatch = issue; } } return { isDuplicate: true, existingIssue: bestMatch, similarity: bestSimilarity }; } /** * Merge task content into existing Linear issue */ async mergeIntoExisting(existingIssue, newTitle, newDescription, additionalContext) { try { let mergedDescription = existingIssue.description || ""; if (newDescription && !mergedDescription.includes(newDescription)) { mergedDescription += ` ## Additional Context (${(/* @__PURE__ */ new Date()).toISOString()}) `; mergedDescription += newDescription; } if (additionalContext) { mergedDescription += ` --- ${additionalContext}`; } const updateQuery = ` mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { issue { id identifier title description updatedAt } } } `; const variables = { id: existingIssue.id, input: { description: mergedDescription } }; const response = await this.linearClient.graphql(updateQuery, variables); const updatedIssue = response.issueUpdate?.issue; if (updatedIssue) { logger.info( `Merged content into existing Linear issue ${existingIssue.identifier}: ${existingIssue.title}` ); return updatedIssue; } return existingIssue; } catch (error) { logger.error( "Failed to merge into existing Linear issue:", error ); return existingIssue; } } /** * Normalize title for comparison */ normalizeTitle(title) { return title.toLowerCase().trim().replace(/\s+/g, " ").replace(/[^\w\s-]/g, "").replace(/^(sta|eng|bug|feat|task|tsk)[-\s]\d+[-\s:]*/, "").trim(); } /** * Calculate similarity between two strings (Levenshtein distance based) */ calculateSimilarity(str1, str2) { if (str1 === str2) return 1; if (str1.length === 0 || str2.length === 0) return 0; const distance = this.levenshteinDistance(str1, str2); const maxLength = Math.max(str1.length, str2.length); return 1 - distance / maxLength; } /** * Calculate Levenshtein distance between two strings */ levenshteinDistance(str1, str2) { const m = str1.length; const n = str2.length; const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (str1[i - 1] === str2[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = 1 + Math.min( dp[i - 1][j], // deletion dp[i][j - 1], // insertion dp[i - 1][j - 1] // substitution ); } } } return dp[m][n]; } /** * Check if cache is still valid */ isCacheValid() { return Date.now() - this.lastCacheRefresh < this.cacheExpiry; } /** * Clear the title cache */ clearCache() { this.titleCache.clear(); this.lastCacheRefresh = 0; } } export { DEFAULT_SYNC_CONFIG, LinearDuplicateDetector, LinearSyncEngine }; //# sourceMappingURL=sync.js.map