@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.
283 lines (282 loc) • 8.76 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 { MetricsQueries } from "../queries/metrics-queries.js";
import { LinearClient } from "../../../integrations/linear/client.js";
import { LinearTaskManager } from "../../tasks/linear-task-manager.js";
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import os from "os";
function getEnv(key, defaultValue) {
const value = process.env[key];
if (value === void 0) {
if (defaultValue !== void 0) return defaultValue;
throw new Error(`Environment variable ${key} is required`);
}
return value;
}
function getOptionalEnv(key) {
return process.env[key];
}
class AnalyticsService {
metricsQueries;
linearClient;
taskStore;
dbPath;
projectPath;
updateCallbacks = /* @__PURE__ */ new Set();
constructor(projectPath) {
this.projectPath = projectPath || process.cwd();
this.dbPath = path.join(this.projectPath, ".stackmemory", "analytics.db");
this.ensureDirectoryExists();
this.metricsQueries = new MetricsQueries(this.dbPath);
this.initializeTaskStore();
if (process.env["LINEAR_API_KEY"]) {
this.initializeLinearIntegration();
}
}
initializeTaskStore() {
try {
const contextDbPath = path.join(
this.projectPath,
".stackmemory",
"context.db"
);
if (fs.existsSync(contextDbPath)) {
const db = new Database(contextDbPath);
this.taskStore = new LinearTaskManager(this.projectPath, db);
}
} catch (error) {
console.error("Failed to initialize task store:", error);
}
}
ensureDirectoryExists() {
const dir = path.dirname(this.dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
async initializeLinearIntegration() {
try {
const configPath = path.join(
os.homedir(),
".stackmemory",
"linear-config.json"
);
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
this.linearClient = new LinearClient(config);
await this.syncLinearTasks();
}
} catch (error) {
console.error("Failed to initialize Linear integration:", error);
}
}
async syncLinearTasks() {
await this.syncFromTaskStore();
if (this.linearClient) {
try {
const issues = await this.linearClient.getIssues({ limit: 100 });
for (const issue of issues) {
const task = {
id: issue.id,
title: issue.title,
state: this.mapLinearState(issue.state.type),
createdAt: new Date(issue.createdAt),
completedAt: issue.state.type === "completed" ? new Date(issue.updatedAt) : void 0,
estimatedEffort: issue.estimate ? issue.estimate * 60 : void 0,
assigneeId: issue.assignee?.id,
priority: this.mapLinearPriority(issue.priority),
labels: Array.isArray(issue.labels) ? issue.labels.map((l) => l.name) : issue.labels?.nodes?.map((l) => l.name) || [],
blockingIssues: []
};
this.metricsQueries.upsertTask(task);
}
} catch (error) {
console.error("Failed to sync from Linear API:", error);
}
}
await this.notifyUpdate();
}
async syncFromTaskStore() {
if (!this.taskStore) return 0;
try {
const allTasks = this.getAllTasksFromStore();
let synced = 0;
for (const task of allTasks) {
const analyticsTask = {
id: task.id,
title: task.title,
state: this.mapTaskStatus(task.status),
createdAt: new Date(task.created_at * 1e3),
completedAt: task.completed_at ? new Date(task.completed_at * 1e3) : void 0,
estimatedEffort: task.estimated_effort,
actualEffort: task.actual_effort,
assigneeId: task.assignee,
priority: task.priority,
labels: task.tags || [],
blockingIssues: task.depends_on || []
};
this.metricsQueries.upsertTask(analyticsTask);
synced++;
}
return synced;
} catch (error) {
console.error("Failed to sync from task store:", error);
return 0;
}
}
getAllTasksFromStore() {
if (!this.taskStore) return [];
try {
const contextDbPath = path.join(
this.projectPath,
".stackmemory",
"context.db"
);
const db = new Database(contextDbPath);
const rows = db.prepare(
`
SELECT * FROM task_cache
ORDER BY created_at DESC
`
).all();
db.close();
return rows.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
status: row.status,
priority: row.priority,
created_at: row.created_at,
completed_at: row.completed_at,
estimated_effort: row.estimated_effort,
actual_effort: row.actual_effort,
assignee: row.assignee,
tags: JSON.parse(row.tags || "[]"),
depends_on: JSON.parse(row.depends_on || "[]")
}));
} catch (error) {
console.error("Failed to get all tasks:", error);
return [];
}
}
mapTaskStatus(status) {
const statusMap = {
pending: "todo",
in_progress: "in_progress",
completed: "completed",
blocked: "blocked",
cancelled: "blocked"
};
return statusMap[status] || "todo";
}
mapLinearState(linearState) {
const stateMap = {
backlog: "todo",
unstarted: "todo",
started: "in_progress",
completed: "completed",
done: "completed",
canceled: "blocked"
};
return stateMap[linearState.toLowerCase()] || "todo";
}
mapLinearPriority(priority) {
if (priority === 1) return "urgent";
if (priority === 2) return "high";
if (priority === 3) return "medium";
return "low";
}
async getDashboardState(query = {}) {
const timeRange = query.timeRange || this.getDefaultTimeRange();
const metrics = this.metricsQueries.getTaskMetrics({
...query,
timeRange
});
const recentTasks = this.metricsQueries.getRecentTasks({
...query,
limit: 20
});
const teamMetrics = await this.getTeamMetrics(query);
return {
metrics,
teamMetrics,
recentTasks,
timeRange,
teamFilter: query.userIds || [],
isLive: this.updateCallbacks.size > 0,
lastUpdated: /* @__PURE__ */ new Date()
};
}
async getTeamMetrics(query) {
const uniqueUserIds = /* @__PURE__ */ new Set();
const tasks = this.metricsQueries.getRecentTasks({ limit: 1e3 });
tasks.forEach((task) => {
if (task.assigneeId) {
uniqueUserIds.add(task.assigneeId);
}
});
const teamMetrics = [];
const totalCompleted = tasks.filter((t) => t.state === "completed").length;
for (const userId of uniqueUserIds) {
const userQuery = { ...query, userIds: [userId] };
const individualMetrics = this.metricsQueries.getTaskMetrics(userQuery);
teamMetrics.push({
userId,
userName: await this.getUserName(userId),
individualMetrics,
contributionPercentage: totalCompleted > 0 ? individualMetrics.completedTasks / totalCompleted * 100 : 0,
lastActive: /* @__PURE__ */ new Date()
});
}
return teamMetrics.sort(
(a, b) => b.contributionPercentage - a.contributionPercentage
);
}
async getUserName(userId) {
return userId;
}
getDefaultTimeRange() {
const end = /* @__PURE__ */ new Date();
const start = /* @__PURE__ */ new Date();
start.setDate(start.getDate() - 7);
return {
start,
end,
preset: "7d"
};
}
subscribeToUpdates(callback) {
this.updateCallbacks.add(callback);
return () => {
this.updateCallbacks.delete(callback);
};
}
async notifyUpdate() {
const state = await this.getDashboardState();
this.updateCallbacks.forEach((callback) => callback(state));
}
async addTask(task) {
this.metricsQueries.upsertTask(task);
await this.notifyUpdate();
}
async updateTask(taskId, updates) {
const tasks = this.metricsQueries.getRecentTasks({ limit: 1 });
const existingTask = tasks.find((t) => t.id === taskId);
if (existingTask) {
const updatedTask = { ...existingTask, ...updates };
this.metricsQueries.upsertTask(updatedTask);
await this.notifyUpdate();
}
}
close() {
this.metricsQueries.close();
}
}
export {
AnalyticsService
};
//# sourceMappingURL=analytics-service.js.map