UNPKG

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