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