@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.
411 lines (407 loc) • 13.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 { logger } from "../../core/monitoring/logger.js";
class TaskAwareContextManager {
db;
frameManager;
projectId;
constructor(db, frameManager, projectId) {
this.db = db;
this.frameManager = frameManager;
this.projectId = projectId;
this.initializeTaskSchema();
}
initializeTaskSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
frame_id TEXT NOT NULL,
anchor_id TEXT,
name TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'pending',
priority TEXT DEFAULT 'medium',
parent_task_id TEXT,
depends_on TEXT DEFAULT '[]',
assigned_to TEXT,
estimated_effort INTEGER,
actual_effort INTEGER,
created_at INTEGER DEFAULT (unixepoch()),
started_at INTEGER,
completed_at INTEGER,
blocked_reason TEXT,
context_tags TEXT DEFAULT '[]',
metadata TEXT DEFAULT '{}',
FOREIGN KEY(frame_id) REFERENCES frames(frame_id),
FOREIGN KEY(anchor_id) REFERENCES anchors(anchor_id),
FOREIGN KEY(parent_task_id) REFERENCES tasks(task_id)
);
CREATE TABLE IF NOT EXISTS task_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
depends_on_task_id TEXT NOT NULL,
dependency_type TEXT DEFAULT 'blocks',
created_at INTEGER DEFAULT (unixepoch()),
FOREIGN KEY(task_id) REFERENCES tasks(task_id),
FOREIGN KEY(depends_on_task_id) REFERENCES tasks(task_id)
);
CREATE TABLE IF NOT EXISTS context_access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
task_ids TEXT, -- JSON array of relevant task IDs
context_items TEXT, -- JSON array of included context items
relevance_scores TEXT, -- JSON object of item -> score mappings
total_tokens INTEGER,
query_hash TEXT,
timestamp INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_frame ON tasks(frame_id);
CREATE INDEX IF NOT EXISTS idx_task_deps ON task_dependencies(task_id);
`);
}
/**
* Create task from TODO anchor or standalone
*/
createTask(options) {
const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const frameId = options.frameId || this.frameManager.getCurrentFrameId();
if (!frameId) {
throw new Error("No active frame for task creation");
}
const task = {
task_id: taskId,
frame_id: frameId,
anchor_id: options.anchorId,
name: options.name,
description: options.description,
status: "pending",
priority: options.priority || "medium",
parent_task_id: options.parentTaskId,
depends_on: options.dependsOn || [],
estimated_effort: options.estimatedEffort,
created_at: Math.floor(Date.now() / 1e3),
context_tags: options.contextTags || [],
metadata: options.metadata || {}
};
this.db.prepare(
`
INSERT INTO tasks (
task_id, frame_id, anchor_id, name, description, status, priority,
parent_task_id, depends_on, estimated_effort, created_at, context_tags, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
).run(
task.task_id,
task.frame_id,
task.anchor_id,
task.name,
task.description,
task.status,
task.priority,
task.parent_task_id,
JSON.stringify(task.depends_on),
task.estimated_effort,
task.created_at,
JSON.stringify(task.context_tags),
JSON.stringify(task.metadata)
);
if (task.depends_on.length > 0) {
const dependencyStmt = this.db.prepare(`
INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)
`);
task.depends_on.forEach((depTaskId) => {
dependencyStmt.run(taskId, depTaskId);
});
}
this.frameManager.addEvent("decision", {
action: "create_task",
task_id: taskId,
name: task.name,
priority: task.priority
});
logger.info("Created task", { taskId, name: task.name, frameId });
return taskId;
}
/**
* Update task status with automatic time tracking
*/
updateTaskStatus(taskId, newStatus, reason) {
const task = this.getTask(taskId);
if (!task) throw new Error(`Task not found: ${taskId}`);
const now = Math.floor(Date.now() / 1e3);
const updates = { status: newStatus };
if (newStatus === "in_progress" && task.status === "pending") {
updates.started_at = now;
} else if (newStatus === "completed" && task.status === "in_progress") {
updates.completed_at = now;
if (task.started_at) {
updates.actual_effort = now - task.started_at;
}
} else if (newStatus === "blocked") {
updates.blocked_reason = reason || "No reason provided";
}
const setClause = Object.keys(updates).map((key) => `${key} = ?`).join(", ");
const values = Object.values(updates);
this.db.prepare(`UPDATE tasks SET ${setClause} WHERE task_id = ?`).run(...values, taskId);
this.frameManager.addEvent("observation", {
action: "task_status_change",
task_id: taskId,
old_status: task.status,
new_status: newStatus,
reason
});
logger.info("Updated task status", {
taskId,
oldStatus: task.status,
newStatus
});
}
/**
* Assemble context optimized for active tasks and query
*/
assembleTaskAwareContext(request) {
const startTime = Date.now();
const activeTasks = this.getActiveTasks(request.taskFocus);
const blockedTasks = this.getBlockedTasks();
const contextItems = this.selectRelevantContext(activeTasks, request);
const { context, totalTokens, relevanceScores } = this.buildContextString(
contextItems,
activeTasks,
request.maxTokens || 4e3
);
this.logContextAccess({
taskIds: activeTasks.map((t) => t.task_id),
contextItems: contextItems.map((item) => item.id),
relevanceScores,
totalTokens,
query: request.query || ""
});
const metadata = {
includedTasks: activeTasks,
contextSources: contextItems.map((item) => `${item.type}:${item.id}`),
totalTokens,
relevanceScores
};
logger.info("Assembled task-aware context", {
activeTasks: activeTasks.length,
blockedTasks: blockedTasks.length,
contextItems: contextItems.length,
totalTokens,
assemblyTimeMs: Date.now() - startTime
});
return { context, metadata };
}
/**
* Get tasks that are currently active or should be in context
*/
getActiveTasks(taskFocus) {
let query = `
SELECT * FROM tasks
WHERE status IN ('in_progress', 'pending')
`;
let params = [];
if (taskFocus && taskFocus.length > 0) {
query += ` AND task_id IN (${taskFocus.map(() => "?").join(",")})`;
params = taskFocus;
}
query += ` ORDER BY priority DESC, created_at ASC`;
const rows = this.db.prepare(query).all(...params);
return this.hydrateTasks(rows);
}
getBlockedTasks() {
const rows = this.db.prepare(
`
SELECT * FROM tasks WHERE status = 'blocked' ORDER BY priority DESC
`
).all();
return this.hydrateTasks(rows);
}
/**
* Select context items relevant to active tasks
*/
selectRelevantContext(activeTasks, request) {
const contextItems = [];
const frameIds = [...new Set(activeTasks.map((t) => t.frame_id))];
frameIds.forEach((frameId) => {
const frame = this.frameManager.getFrame(frameId);
if (frame) {
const score = this.calculateFrameRelevance(
frame,
activeTasks,
request.query
);
contextItems.push({
id: frameId,
type: "frame",
content: `Frame: ${frame.name} (${frame.type})`,
relevanceScore: score,
tokenEstimate: frame.name.length + 20
});
}
});
const anchors = this.getRelevantAnchors(frameIds, request);
anchors.forEach((anchor) => {
const score = this.calculateAnchorRelevance(
anchor,
activeTasks,
request.query
);
contextItems.push({
id: anchor.anchor_id,
type: "anchor",
content: `${anchor.type}: ${anchor.text}`,
relevanceScore: score,
tokenEstimate: anchor.text.length + 10
});
});
if (request.includeHistory) {
frameIds.forEach((frameId) => {
const events = this.frameManager.getFrameEvents(frameId, 5);
events.forEach((event) => {
const score = this.calculateEventRelevance(
event,
activeTasks,
request.query
);
if (score > 0.3) {
contextItems.push({
id: event.event_id,
type: "event",
content: `Event: ${event.event_type}`,
relevanceScore: score,
tokenEstimate: 30
});
}
});
});
}
return contextItems.sort((a, b) => b.relevanceScore - a.relevanceScore);
}
buildContextString(contextItems, activeTasks, maxTokens) {
let context = "# Active Task Context\n\n";
let totalTokens = 20;
const relevanceScores = {};
context += "## Current Tasks\n";
activeTasks.forEach((task) => {
const line = `- [${task.status.toUpperCase()}] ${task.name} (${task.priority})
`;
context += line;
totalTokens += line.length / 4;
relevanceScores[task.task_id] = 1;
});
context += "\n";
context += "## Relevant Context\n";
for (const item of contextItems) {
if (totalTokens + item.tokenEstimate > maxTokens) break;
context += `${item.content}
`;
totalTokens += item.tokenEstimate;
relevanceScores[item.id] = item.relevanceScore;
}
return { context, totalTokens, relevanceScores };
}
// Relevance scoring methods
calculateFrameRelevance(frame, activeTasks, query) {
let score = 0.5;
if (activeTasks.some((t) => t.frame_id === frame.frame_id)) {
score += 0.4;
}
if (query) {
const queryLower = query.toLowerCase();
if (frame.name.toLowerCase().includes(queryLower)) {
score += 0.3;
}
}
const ageHours = (Date.now() / 1e3 - frame.created_at) / 3600;
if (ageHours < 24) score += 0.2;
return Math.min(score, 1);
}
calculateAnchorRelevance(anchor, activeTasks, query) {
let score = 0.3;
if (anchor.type === "TODO") score += 0.4;
if (anchor.type === "DECISION") score += 0.3;
if (anchor.type === "CONSTRAINT") score += 0.2;
score += anchor.priority / 10 * 0.2;
if (query) {
const queryLower = query.toLowerCase();
if (anchor.text.toLowerCase().includes(queryLower)) {
score += 0.3;
}
}
return Math.min(score, 1);
}
calculateEventRelevance(event, _activeTasks, _query) {
let score = 0.1;
if (event.event_type === "decision") score += 0.4;
if (event.event_type === "tool_call") score += 0.3;
if (event.event_type === "observation") score += 0.2;
const ageHours = (Date.now() / 1e3 - event.ts) / 3600;
if (ageHours < 1) score += 0.3;
else if (ageHours < 6) score += 0.2;
return Math.min(score, 1);
}
// Helper methods
getTask(taskId) {
const row = this.db.prepare(`SELECT * FROM tasks WHERE task_id = ?`).get(taskId);
return row ? this.hydrateTask(row) : void 0;
}
getRelevantAnchors(frameIds, _request) {
if (frameIds.length === 0) return [];
const placeholders = frameIds.map(() => "?").join(",");
const rows = this.db.prepare(
`
SELECT * FROM anchors
WHERE frame_id IN (${placeholders})
ORDER BY priority DESC, created_at DESC
LIMIT 20
`
).all(...frameIds);
return rows.map((row) => ({
...row,
metadata: JSON.parse(row.metadata || "{}")
}));
}
hydrateTasks(rows) {
return rows.map(this.hydrateTask);
}
hydrateTask = (row) => ({
...row,
depends_on: JSON.parse(row.depends_on || "[]"),
context_tags: JSON.parse(row.context_tags || "[]"),
metadata: JSON.parse(row.metadata || "{}")
});
logContextAccess(data) {
const requestId = `ctx_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
this.db.prepare(
`
INSERT INTO context_access_log (
request_id, task_ids, context_items, relevance_scores, total_tokens, query_hash
) VALUES (?, ?, ?, ?, ?, ?)
`
).run(
requestId,
JSON.stringify(data.taskIds),
JSON.stringify(data.contextItems),
JSON.stringify(data.relevanceScores),
data.totalTokens,
this.hashString(data.query)
);
}
hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(16);
}
}
export {
TaskAwareContextManager
};
//# sourceMappingURL=task-aware-context.js.map