@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.
554 lines (552 loc) • 18.6 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 {
DEFAULT_DIGEST_CONFIG
} from "./types.js";
import { logger } from "../monitoring/logger.js";
class HybridDigestGenerator {
db;
config;
llmProvider;
queue = [];
processing = false;
idleTimer;
stats = {
pending: 0,
processing: 0,
completed: 0,
failed: 0,
avgProcessingTimeMs: 0
};
constructor(db, config = {}, llmProvider) {
this.db = db;
this.config = { ...DEFAULT_DIGEST_CONFIG, ...config };
this.llmProvider = llmProvider;
this.initializeSchema();
}
initializeSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS digest_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
frame_id TEXT NOT NULL UNIQUE,
frame_name TEXT NOT NULL,
frame_type TEXT NOT NULL,
priority TEXT DEFAULT 'normal',
status TEXT DEFAULT 'pending',
retry_count INTEGER DEFAULT 0,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch()),
error_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_digest_queue_status ON digest_queue(status);
CREATE INDEX IF NOT EXISTS idx_digest_queue_priority ON digest_queue(priority, created_at);
`);
}
/**
* Generate digest for a frame (immediate deterministic, queued AI)
*/
generateDigest(input) {
const startTime = Date.now();
const deterministic = this.extractDeterministicFields(input);
const text = this.generateDeterministicText(input.frame, deterministic);
const digest = {
frameId: input.frame.frame_id,
frameName: input.frame.name,
frameType: input.frame.type,
deterministic,
status: "deterministic_only",
text,
version: 1,
createdAt: Date.now(),
updatedAt: Date.now()
};
if (this.config.enableAIGeneration && this.llmProvider) {
this.queueForAIGeneration({
frameId: input.frame.frame_id,
frameName: input.frame.name,
frameType: input.frame.type,
priority: this.determinePriority(input),
createdAt: Date.now(),
retryCount: 0,
maxRetries: this.config.maxRetries
});
digest.status = "ai_pending";
}
logger.debug("Generated deterministic digest", {
frameId: input.frame.frame_id,
durationMs: Date.now() - startTime,
aiQueued: digest.status === "ai_pending"
});
return digest;
}
/**
* Extract deterministic fields from frame data (60%)
*/
extractDeterministicFields(input) {
const { frame, anchors, events } = input;
const filesModified = this.extractFilesModified(events);
const testsRun = this.extractTestResults(events);
const errorsEncountered = this.extractErrors(events);
const toolCalls = events.filter((e) => e.event_type === "tool_call");
const toolCallsByType = {};
for (const tc of toolCalls) {
const toolName = tc.payload?.tool_name || "unknown";
toolCallsByType[toolName] = (toolCallsByType[toolName] || 0) + 1;
}
const anchorCounts = {};
for (const anchor of anchors) {
anchorCounts[anchor.type] = (anchorCounts[anchor.type] || 0) + 1;
}
const decisions = anchors.filter((a) => a.type === "DECISION").map((a) => a.text);
const constraints = anchors.filter((a) => a.type === "CONSTRAINT").map((a) => a.text);
const risks = anchors.filter((a) => a.type === "RISK").map((a) => a.text);
const durationSeconds = frame.closed_at ? frame.closed_at - frame.created_at : Math.floor(Date.now() / 1e3 - frame.created_at);
const exitStatus = this.determineExitStatus(frame, errorsEncountered);
return {
filesModified,
testsRun,
errorsEncountered,
toolCallCount: toolCalls.length,
toolCallsByType,
durationSeconds,
exitStatus,
anchorCounts,
decisions,
constraints,
risks
};
}
extractFilesModified(events) {
const fileMap = /* @__PURE__ */ new Map();
for (const event of events) {
if (event.event_type === "tool_call" || event.event_type === "tool_result") {
const payload = event.payload || {};
const filePath = payload.file_path || payload.path || payload.file;
if (filePath && typeof filePath === "string") {
const toolName = payload.tool_name || "";
let operation = "read";
if (toolName.includes("write") || toolName.includes("edit") || toolName.includes("create")) {
operation = "modify";
} else if (toolName.includes("delete") || toolName.includes("remove")) {
operation = "delete";
} else if (toolName.includes("read") || toolName.includes("cat") || toolName.includes("view")) {
operation = "read";
}
const existing = fileMap.get(filePath);
if (!existing || this.operationPriority(operation) > this.operationPriority(existing.operation)) {
fileMap.set(filePath, {
path: filePath,
operation,
linesChanged: payload.lines_changed
});
}
}
const filesAffected = payload.filesAffected || payload.files_affected;
if (Array.isArray(filesAffected)) {
for (const f of filesAffected) {
if (typeof f === "string" && !fileMap.has(f)) {
fileMap.set(f, { path: f, operation: "modify" });
}
}
}
}
}
return Array.from(fileMap.values());
}
operationPriority(op) {
const priorities = { delete: 4, create: 3, modify: 2, read: 1 };
return priorities[op] || 0;
}
extractTestResults(events) {
const tests = [];
for (const event of events) {
const payload = event.payload || {};
if (payload.tool_name?.includes("test") || payload.command?.includes("test") || payload.test_name) {
const testName = payload.test_name || payload.command || "unknown test";
const success = payload.success !== false && !payload.error;
tests.push({
name: testName,
status: success ? "passed" : "failed",
duration: payload.duration
});
}
const output = payload.output || payload.result;
if (typeof output === "string") {
const passMatch = output.match(/(\d+)\s*(?:tests?\s*)?passed/i);
const failMatch = output.match(/(\d+)\s*(?:tests?\s*)?failed/i);
if (passMatch || failMatch) {
const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
if (passed > 0) {
tests.push({ name: `${passed} tests`, status: "passed" });
}
if (failed > 0) {
tests.push({ name: `${failed} tests`, status: "failed" });
}
}
}
}
return tests;
}
extractErrors(events) {
const errorMap = /* @__PURE__ */ new Map();
for (const event of events) {
const payload = event.payload || {};
if (payload.error || payload.success === false) {
const errorType = payload.error_type || "UnknownError";
const message = payload.error?.message || payload.error || "Unknown error";
const key = `${errorType}:${message.substring(0, 50)}`;
const existing = errorMap.get(key);
if (existing) {
existing.count++;
} else {
errorMap.set(key, {
type: errorType,
message: String(message).substring(0, 200),
resolved: false,
count: 1
});
}
}
}
return Array.from(errorMap.values());
}
determineExitStatus(frame, errors) {
const outputs = frame.outputs || {};
if (outputs.cancelled || outputs.status === "cancelled") return "cancelled";
if (errors.length === 0) return "success";
if (errors.some((e) => !e.resolved)) return "failure";
return "partial";
}
/**
* Generate text summary from deterministic data
*/
generateDeterministicText(frame, det) {
const parts = [];
parts.push(`## ${frame.name} (${frame.type})`);
parts.push(`Status: ${det.exitStatus}`);
if (det.durationSeconds > 0) {
const mins = Math.floor(det.durationSeconds / 60);
const secs = det.durationSeconds % 60;
parts.push(`Duration: ${mins}m ${secs}s`);
}
if (det.filesModified.length > 0) {
parts.push(`
### Files Modified (${det.filesModified.length})`);
for (const f of det.filesModified.slice(0, 10)) {
parts.push(`- ${f.operation}: ${f.path}`);
}
if (det.filesModified.length > 10) {
parts.push(` ...and ${det.filesModified.length - 10} more`);
}
}
if (det.toolCallCount > 0) {
parts.push(`
### Tool Calls (${det.toolCallCount})`);
const sorted = Object.entries(det.toolCallsByType).sort((a, b) => b[1] - a[1]).slice(0, 5);
for (const [tool, count] of sorted) {
parts.push(`- ${tool}: ${count}`);
}
}
if (det.decisions.length > 0) {
parts.push(`
### Decisions (${det.decisions.length})`);
for (const d of det.decisions.slice(0, 5)) {
parts.push(`- ${d}`);
}
}
if (det.constraints.length > 0) {
parts.push(`
### Constraints (${det.constraints.length})`);
for (const c of det.constraints.slice(0, 3)) {
parts.push(`- ${c}`);
}
}
if (det.errorsEncountered.length > 0) {
parts.push(`
### Errors (${det.errorsEncountered.length})`);
for (const e of det.errorsEncountered.slice(0, 3)) {
parts.push(`- ${e.type}: ${e.message.substring(0, 80)}`);
}
}
if (det.testsRun.length > 0) {
const passed = det.testsRun.filter((t) => t.status === "passed").length;
const failed = det.testsRun.filter((t) => t.status === "failed").length;
parts.push(`
### Tests: ${passed} passed, ${failed} failed`);
}
return parts.join("\n");
}
/**
* Queue frame for AI generation
*/
queueForAIGeneration(request) {
try {
this.db.prepare(
`
INSERT OR REPLACE INTO digest_queue
(frame_id, frame_name, frame_type, priority, status, retry_count, created_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?)
`
).run(
request.frameId,
request.frameName,
request.frameType,
request.priority,
request.retryCount,
Math.floor(request.createdAt / 1e3)
);
this.stats.pending++;
this.scheduleIdleProcessing();
logger.debug("Queued frame for AI digest generation", {
frameId: request.frameId,
priority: request.priority
});
} catch (error) {
logger.error("Failed to queue digest generation", error);
}
}
/**
* Determine priority based on frame characteristics
*/
determinePriority(input) {
const { frame, anchors, events } = input;
const decisionCount = anchors.filter((a) => a.type === "DECISION").length;
const errorCount = events.filter(
(e) => e.payload?.error || e.payload?.success === false
).length;
if (decisionCount >= 3 || errorCount >= 2) return "high";
if (decisionCount >= 1 || events.length >= 20) return "normal";
return "low";
}
/**
* Schedule idle-time processing
*/
scheduleIdleProcessing() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
this.idleTimer = setTimeout(() => {
this.processQueue();
}, this.config.idleThresholdMs);
}
/**
* Process queued AI generation requests
*/
async processQueue() {
if (this.processing || !this.llmProvider) return;
this.processing = true;
try {
const pending = this.db.prepare(
`
SELECT * FROM digest_queue
WHERE status = 'pending'
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'normal' THEN 2
WHEN 'low' THEN 3
END,
created_at ASC
LIMIT ?
`
).all(this.config.batchSize);
for (const item of pending) {
await this.processQueueItem(item);
}
} finally {
this.processing = false;
}
}
async processQueueItem(item) {
const startTime = Date.now();
try {
this.db.prepare(
`UPDATE digest_queue SET status = 'processing', updated_at = unixepoch() WHERE frame_id = ?`
).run(item.frame_id);
this.stats.processing++;
this.stats.pending--;
const frame = this.db.prepare(`SELECT * FROM frames WHERE frame_id = ?`).get(item.frame_id);
if (!frame) {
throw new Error(`Frame not found: ${item.frame_id}`);
}
const anchors = this.db.prepare(`SELECT * FROM anchors WHERE frame_id = ?`).all(item.frame_id);
const events = this.db.prepare(`SELECT * FROM events WHERE frame_id = ? ORDER BY ts ASC`).all(item.frame_id);
const parsedFrame = {
...frame,
inputs: JSON.parse(frame.inputs || "{}"),
outputs: JSON.parse(frame.outputs || "{}"),
digest_json: JSON.parse(frame.digest_json || "{}")
};
const input = {
frame: parsedFrame,
anchors: anchors.map((a) => ({
...a,
metadata: JSON.parse(a.metadata || "{}")
})),
events: events.map((e) => ({
...e,
payload: JSON.parse(e.payload || "{}")
}))
};
const deterministic = this.extractDeterministicFields(input);
const aiGenerated = await this.llmProvider.generateSummary(
input,
deterministic,
this.config.maxTokens
);
const existingDigest = parsedFrame.digest_json || {};
const updatedDigest = {
...existingDigest,
aiGenerated,
status: "complete",
updatedAt: Date.now()
};
const enhancedText = this.generateEnhancedText(
parsedFrame,
deterministic,
aiGenerated
);
this.db.prepare(
`
UPDATE frames
SET digest_json = ?, digest_text = ?
WHERE frame_id = ?
`
).run(JSON.stringify(updatedDigest), enhancedText, item.frame_id);
this.db.prepare(
`UPDATE digest_queue SET status = 'completed', updated_at = unixepoch() WHERE frame_id = ?`
).run(item.frame_id);
this.stats.processing--;
this.stats.completed++;
const processingTime = Date.now() - startTime;
this.stats.avgProcessingTimeMs = (this.stats.avgProcessingTimeMs * (this.stats.completed - 1) + processingTime) / this.stats.completed;
logger.info("Generated AI digest", {
frameId: item.frame_id,
processingTimeMs: processingTime
});
} catch (error) {
const newRetryCount = item.retry_count + 1;
if (newRetryCount < this.config.maxRetries) {
this.db.prepare(
`
UPDATE digest_queue
SET status = 'pending', retry_count = ?, error_message = ?, updated_at = unixepoch()
WHERE frame_id = ?
`
).run(newRetryCount, error.message, item.frame_id);
this.stats.processing--;
this.stats.pending++;
logger.warn("AI digest generation failed, will retry", {
frameId: item.frame_id,
retryCount: newRetryCount,
error: error.message
});
} else {
this.db.prepare(
`
UPDATE digest_queue
SET status = 'failed', error_message = ?, updated_at = unixepoch()
WHERE frame_id = ?
`
).run(error.message, item.frame_id);
this.stats.processing--;
this.stats.failed++;
logger.error("AI digest generation failed permanently", error, {
frameId: item.frame_id
});
}
}
}
/**
* Generate enhanced text with AI review (20%)
*/
generateEnhancedText(frame, det, ai) {
const parts = [];
parts.push(this.generateDeterministicText(frame, det));
parts.push(`
---`);
parts.push(`**AI Review**: ${ai.summary}`);
if (ai.insight) {
parts.push(`**Insight**: ${ai.insight}`);
}
if (ai.flaggedIssue) {
parts.push(`**Flag**: ${ai.flaggedIssue}`);
}
return parts.join("\n");
}
/**
* Get queue statistics
*/
getStats() {
return { ...this.stats };
}
/**
* Set LLM provider
*/
setLLMProvider(provider) {
this.llmProvider = provider;
}
/**
* Force process queue (for testing or manual trigger)
*/
async forceProcessQueue() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
await this.processQueue();
}
/**
* Get digest for a frame
*/
getDigest(frameId) {
const frame = this.db.prepare(`SELECT * FROM frames WHERE frame_id = ?`).get(frameId);
if (!frame) return null;
const digestJson = JSON.parse(frame.digest_json || "{}");
const anchors = this.db.prepare(`SELECT * FROM anchors WHERE frame_id = ?`).all(frameId);
const events = this.db.prepare(`SELECT * FROM events WHERE frame_id = ?`).all(frameId);
const parsedFrame = {
...frame,
inputs: JSON.parse(frame.inputs || "{}"),
outputs: JSON.parse(frame.outputs || "{}"),
digest_json: digestJson
};
const input = {
frame: parsedFrame,
anchors: anchors.map((a) => ({
...a,
metadata: JSON.parse(a.metadata || "{}")
})),
events: events.map((e) => ({
...e,
payload: JSON.parse(e.payload || "{}")
}))
};
const deterministic = this.extractDeterministicFields(input);
const queueItem = this.db.prepare(`SELECT status FROM digest_queue WHERE frame_id = ?`).get(frameId);
let status = "deterministic_only";
if (digestJson.aiGenerated) {
status = "complete";
} else if (queueItem) {
status = queueItem.status === "processing" ? "ai_processing" : queueItem.status === "failed" ? "ai_failed" : "ai_pending";
}
return {
frameId: frame.frame_id,
frameName: frame.name,
frameType: frame.type,
deterministic,
aiGenerated: digestJson.aiGenerated,
status,
text: frame.digest_text || this.generateDeterministicText(parsedFrame, deterministic),
version: 1,
createdAt: frame.created_at * 1e3,
updatedAt: digestJson.updatedAt || frame.created_at * 1e3
};
}
}
export {
HybridDigestGenerator
};
//# sourceMappingURL=hybrid-digest-generator.js.map