@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
JavaScript
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