@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.
410 lines (409 loc) • 12.1 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 { existsSync, readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import {
sendNotification,
loadSMSConfig,
saveSMSConfig
} from "./sms-notify.js";
import { writeFileSecure, ensureSecureDir } from "./secure-fs.js";
import { SyncOptionsSchema, parseConfigSafe } from "./schemas.js";
import {
frameLifecycleHooks
} from "../core/context/frame-lifecycle-hooks.js";
const SYNC_CONFIG_PATH = join(homedir(), ".stackmemory", "whatsapp-sync.json");
const DEFAULT_SYNC_OPTIONS = {
autoSyncOnClose: false,
minFrameDuration: 30,
// Skip frames shorter than 30 seconds
includeDecisions: true,
includeFiles: true,
includeTests: true,
maxDigestLength: 400
};
function loadSyncOptions() {
try {
if (existsSync(SYNC_CONFIG_PATH)) {
const data = JSON.parse(readFileSync(SYNC_CONFIG_PATH, "utf8"));
return parseConfigSafe(
SyncOptionsSchema,
{ ...DEFAULT_SYNC_OPTIONS, ...data },
DEFAULT_SYNC_OPTIONS,
"whatsapp-sync"
);
}
} catch {
}
return { ...DEFAULT_SYNC_OPTIONS };
}
function saveSyncOptions(options) {
try {
ensureSecureDir(join(homedir(), ".stackmemory"));
writeFileSecure(SYNC_CONFIG_PATH, JSON.stringify(options, null, 2));
} catch {
}
}
function formatDuration(seconds) {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${mins}m${secs}s` : `${mins}m`;
} else {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor(seconds % 3600 / 60);
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
}
function truncate(text, maxLen) {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 3) + "...";
}
function getStatusSymbol(status) {
switch (status) {
case "success":
return "OK";
case "failure":
return "FAIL";
case "partial":
return "PARTIAL";
case "ongoing":
return "ACTIVE";
default:
return "?";
}
}
function generateMobileDigest(data, options = DEFAULT_SYNC_OPTIONS) {
const parts = [];
const maxLen = options.maxDigestLength;
const header = `FRAME: ${truncate(data.name, 30)} [${data.type}] - ${formatDuration(data.durationSeconds)} ${getStatusSymbol(data.status)}`;
parts.push(header);
const activityParts = [];
if (options.includeFiles && data.filesModified.length > 0) {
activityParts.push(`FILES: ${data.filesModified.length}`);
}
if (data.toolCallCount > 0) {
activityParts.push(`TOOLS: ${data.toolCallCount}`);
}
if (options.includeTests && data.testsRun.length > 0) {
const passed = data.testsRun.filter((t) => t.status === "passed").length;
const failed = data.testsRun.filter((t) => t.status === "failed").length;
if (failed > 0) {
activityParts.push(`TESTS: ${passed}ok/${failed}fail`);
} else {
activityParts.push(`TESTS: ${passed} pass`);
}
}
if (activityParts.length > 0) {
parts.push(activityParts.join(" | "));
}
if (options.includeFiles && data.filesModified.length > 0) {
const fileList = data.filesModified.slice(0, 3).map((f) => {
const basename = f.path.split("/").pop() || f.path;
const op = f.operation.charAt(0).toUpperCase();
return `${op}:${truncate(basename, 20)}`;
}).join(", ");
const more = data.filesModified.length > 3 ? ` +${data.filesModified.length - 3}` : "";
parts.push(` ${fileList}${more}`);
}
if (options.includeDecisions && data.decisions.length > 0) {
parts.push("");
parts.push("DECISIONS:");
data.decisions.slice(0, 3).forEach((d) => {
parts.push(` ${truncate(d, 60)}`);
});
if (data.decisions.length > 3) {
parts.push(` +${data.decisions.length - 3} more`);
}
}
if (data.risks.length > 0) {
parts.push("");
parts.push("RISKS:");
data.risks.slice(0, 2).forEach((r) => {
parts.push(` ${truncate(r, 50)}`);
});
}
const unresolvedErrors = data.errors.filter((e) => !e.resolved);
if (unresolvedErrors.length > 0) {
parts.push("");
parts.push(`ERRORS: ${unresolvedErrors.length} unresolved`);
unresolvedErrors.slice(0, 2).forEach((e) => {
parts.push(` ${truncate(e.message, 50)}`);
});
}
parts.push("");
if (data.status === "success") {
parts.push("NEXT: commit & test");
} else if (data.status === "failure") {
parts.push("NEXT: fix errors");
} else if (data.status === "partial") {
parts.push("NEXT: review & continue");
} else {
parts.push("NEXT: check status");
}
let result = parts.join("\n");
if (result.length > maxLen) {
const essentialParts = [header];
if (activityParts.length > 0) {
essentialParts.push(activityParts.join(" | "));
}
if (options.includeDecisions && data.decisions.length > 0) {
essentialParts.push("");
essentialParts.push(`DECISIONS: ${data.decisions.length}`);
essentialParts.push(` ${truncate(data.decisions[0], 50)}`);
}
if (unresolvedErrors.length > 0) {
essentialParts.push("");
essentialParts.push(`ERRORS: ${unresolvedErrors.length} unresolved`);
}
essentialParts.push("");
essentialParts.push(
data.status === "success" ? "NEXT: commit" : "NEXT: review"
);
result = essentialParts.join("\n");
}
return result.slice(0, maxLen);
}
async function getFrameDigestData(frameId) {
try {
const digestPath = join(
homedir(),
".stackmemory",
"latest-frame-digest.json"
);
if (existsSync(digestPath)) {
const data = JSON.parse(readFileSync(digestPath, "utf8"));
if (frameId && data.frameId !== frameId) {
return null;
}
return data;
}
return null;
} catch {
return null;
}
}
function storeFrameDigest(data) {
try {
ensureSecureDir(join(homedir(), ".stackmemory"));
const digestPath = join(
homedir(),
".stackmemory",
"latest-frame-digest.json"
);
writeFileSecure(digestPath, JSON.stringify(data, null, 2));
} catch {
}
}
async function syncContext() {
const options = loadSyncOptions();
const data = await getFrameDigestData();
if (!data) {
return {
success: false,
error: "No context data available. Run a task first."
};
}
return syncFrameData(data, options);
}
async function syncFrame(frameId) {
const options = loadSyncOptions();
const data = await getFrameDigestData(frameId);
if (!data) {
return {
success: false,
error: `Frame not found: ${frameId}`
};
}
return syncFrameData(data, options);
}
async function syncFrameData(data, options) {
const config = loadSMSConfig();
if (!config.enabled) {
return { success: false, error: "Notifications disabled" };
}
if (data.durationSeconds < options.minFrameDuration) {
return {
success: false,
error: `Frame too short (${data.durationSeconds}s < ${options.minFrameDuration}s min)`
};
}
const digest = generateMobileDigest(data, options);
const payload = {
type: "custom",
title: "Context Sync",
message: digest,
prompt: {
type: "options",
options: [
{ key: "1", label: "Commit", action: "git add -A && git commit" },
{ key: "2", label: "Status", action: "stackmemory status" },
{ key: "3", label: "Continue", action: 'echo "Continuing..."' }
],
question: "Action?"
}
};
const result = await sendNotification(payload);
return {
success: result.success,
messageId: result.promptId,
channel: result.channel,
error: result.error,
digestLength: digest.length
};
}
function enableAutoSync(options) {
const current = loadSyncOptions();
const updated = {
...current,
...options,
autoSyncOnClose: true
};
saveSyncOptions(updated);
const smsConfig = loadSMSConfig();
if (!smsConfig.notifyOn.custom) {
smsConfig.notifyOn.custom = true;
saveSMSConfig(smsConfig);
}
}
function disableAutoSync() {
const current = loadSyncOptions();
current.autoSyncOnClose = false;
saveSyncOptions(current);
}
function isAutoSyncEnabled() {
const options = loadSyncOptions();
return options.autoSyncOnClose;
}
async function onFrameClosed(frameData) {
if (!isAutoSyncEnabled()) {
return null;
}
storeFrameDigest(frameData);
const options = loadSyncOptions();
return syncFrameData(frameData, options);
}
async function handleFrameCloseHook(data) {
const digestData = createFrameDigestData(
data.frame,
data.events,
data.anchors
);
await onFrameClosed(digestData);
}
let hookUnregister = null;
function registerWhatsAppSyncHook() {
if (hookUnregister) {
return hookUnregister;
}
hookUnregister = frameLifecycleHooks.onFrameClosed(
"whatsapp-sync",
handleFrameCloseHook,
-10
// Low priority - run after other hooks
);
return () => {
if (hookUnregister) {
hookUnregister();
hookUnregister = null;
}
};
}
function isHookRegistered() {
return hookUnregister !== null;
}
function createFrameDigestData(frame, events, anchors) {
const now = Math.floor(Date.now() / 1e3);
const duration = (frame.closed_at || now) - frame.created_at;
const filesModified = [];
const filesSeen = /* @__PURE__ */ new Set();
events.filter((e) => e.event_type === "tool_call").forEach((e) => {
const path = e.payload["path"];
if (path && !filesSeen.has(path)) {
filesSeen.add(path);
const toolName = e.payload["tool_name"] || "";
let operation = "modify";
if (toolName.includes("Write") || toolName.includes("Create")) {
operation = "create";
} else if (toolName.includes("Read")) {
operation = "read";
} else if (toolName.includes("Delete")) {
operation = "delete";
}
filesModified.push({ path, operation });
}
});
const testsRun = [];
events.filter(
(e) => e.event_type === "tool_result" && String(e.payload["output"] || "").includes("test")
).forEach((e) => {
const output = String(e.payload["output"] || "");
const passMatch = output.match(/(\d+) pass/i);
const failMatch = output.match(/(\d+) fail/i);
if (passMatch) {
testsRun.push({ name: "Tests", status: "passed" });
}
if (failMatch && parseInt(failMatch[1]) > 0) {
testsRun.push({ name: "Tests", status: "failed" });
}
});
const decisions = anchors.filter((a) => a.type === "DECISION").map((a) => a.text);
const risks = anchors.filter((a) => a.type === "RISK").map((a) => a.text);
const errors = [];
events.filter((e) => e.payload["error"] || e.payload["status"] === "error").forEach((e) => {
const errorMsg = e.payload["error"] || e.payload["message"] || "Unknown error";
errors.push({
type: e.payload["type"] || "error",
message: errorMsg,
resolved: false
});
});
let status = "ongoing";
if (frame.closed_at) {
if (errors.filter((e) => !e.resolved).length > 0) {
status = "failure";
} else if (testsRun.some((t) => t.status === "failed") || filesModified.length === 0) {
status = "partial";
} else {
status = "success";
}
}
const toolCallCount = events.filter(
(e) => e.event_type === "tool_call"
).length;
return {
frameId: frame.frame_id,
name: frame.name,
type: frame.type,
status,
durationSeconds: duration,
filesModified: filesModified.filter((f) => f.operation !== "read"),
testsRun,
decisions,
risks,
toolCallCount,
errors
};
}
export {
createFrameDigestData,
disableAutoSync,
enableAutoSync,
generateMobileDigest,
getFrameDigestData,
isAutoSyncEnabled,
isHookRegistered,
loadSyncOptions,
onFrameClosed,
registerWhatsAppSyncHook,
saveSyncOptions,
storeFrameDigest,
syncContext,
syncFrame
};
//# sourceMappingURL=whatsapp-sync.js.map