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.

507 lines (503 loc) 14.9 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 { config as loadDotenv } from "dotenv"; import { writeFileSecure, ensureSecureDir } from "./secure-fs.js"; import { SMSConfigSchema, parseConfigSafe } from "./schemas.js"; const CONFIG_PATH = join(homedir(), ".stackmemory", "sms-notify.json"); const DEFAULT_CONFIG = { enabled: false, channel: "whatsapp", // WhatsApp is cheaper for conversations notifyOn: { taskComplete: true, reviewReady: true, error: true, custom: true, contextSync: true }, quietHours: { enabled: false, start: "22:00", end: "08:00" }, responseTimeout: 300, // 5 minutes pendingPrompts: [] }; function loadSMSConfig() { loadDotenv({ path: join(process.cwd(), ".env"), debug: false }); loadDotenv({ path: join(process.cwd(), ".env.local"), debug: false }); loadDotenv({ path: join(homedir(), ".env"), debug: false }); loadDotenv({ path: join(homedir(), ".stackmemory", ".env"), debug: false }); try { if (existsSync(CONFIG_PATH)) { const data = readFileSync(CONFIG_PATH, "utf8"); const parsed = JSON.parse(data); const validated = parseConfigSafe( SMSConfigSchema, { ...DEFAULT_CONFIG, ...parsed }, DEFAULT_CONFIG, "sms-notify" ); applyEnvVars(validated); return validated; } } catch { } const config = { ...DEFAULT_CONFIG }; applyEnvVars(config); return config; } function getMissingConfig() { const config = loadSMSConfig(); const missing = []; const configured = []; if (config.accountSid) { configured.push("TWILIO_ACCOUNT_SID"); } else { missing.push("TWILIO_ACCOUNT_SID"); } if (config.authToken) { configured.push("TWILIO_AUTH_TOKEN"); } else { missing.push("TWILIO_AUTH_TOKEN"); } const channel = config.channel || "whatsapp"; if (channel === "whatsapp") { const from = config.whatsappFromNumber || config.fromNumber; const to = config.whatsappToNumber || config.toNumber; if (from) { configured.push("TWILIO_WHATSAPP_FROM"); } else { missing.push("TWILIO_WHATSAPP_FROM"); } if (to) { configured.push("TWILIO_WHATSAPP_TO"); } else { missing.push("TWILIO_WHATSAPP_TO"); } } else { const from = config.smsFromNumber || config.fromNumber; const to = config.smsToNumber || config.toNumber; if (from) { configured.push("TWILIO_SMS_FROM"); } else { missing.push("TWILIO_SMS_FROM"); } if (to) { configured.push("TWILIO_SMS_TO"); } else { missing.push("TWILIO_SMS_TO"); } } return { missing, configured, ready: missing.length === 0 }; } function applyEnvVars(config) { if (process.env["TWILIO_ACCOUNT_SID"]) { config.accountSid = process.env["TWILIO_ACCOUNT_SID"]; } if (process.env["TWILIO_AUTH_TOKEN"]) { config.authToken = process.env["TWILIO_AUTH_TOKEN"]; } if (process.env["TWILIO_SMS_FROM"] || process.env["TWILIO_FROM_NUMBER"]) { config.smsFromNumber = process.env["TWILIO_SMS_FROM"] || process.env["TWILIO_FROM_NUMBER"]; } if (process.env["TWILIO_SMS_TO"] || process.env["TWILIO_TO_NUMBER"]) { config.smsToNumber = process.env["TWILIO_SMS_TO"] || process.env["TWILIO_TO_NUMBER"]; } if (process.env["TWILIO_WHATSAPP_FROM"]) { config.whatsappFromNumber = process.env["TWILIO_WHATSAPP_FROM"]; } if (process.env["TWILIO_WHATSAPP_TO"]) { config.whatsappToNumber = process.env["TWILIO_WHATSAPP_TO"]; } if (process.env["TWILIO_FROM_NUMBER"]) { config.fromNumber = process.env["TWILIO_FROM_NUMBER"]; } if (process.env["TWILIO_TO_NUMBER"]) { config.toNumber = process.env["TWILIO_TO_NUMBER"]; } if (process.env["TWILIO_CHANNEL"]) { config.channel = process.env["TWILIO_CHANNEL"]; } } function saveSMSConfig(config) { try { ensureSecureDir(join(homedir(), ".stackmemory")); const safeConfig = { ...config }; delete safeConfig.accountSid; delete safeConfig.authToken; writeFileSecure(CONFIG_PATH, JSON.stringify(safeConfig, null, 2)); } catch { } } function isQuietHours(config) { if (!config.quietHours?.enabled) return false; const now = /* @__PURE__ */ new Date(); const currentTime = now.getHours() * 60 + now.getMinutes(); const [startH, startM] = config.quietHours.start.split(":").map(Number); const [endH, endM] = config.quietHours.end.split(":").map(Number); const startTime = startH * 60 + startM; const endTime = endH * 60 + endM; if (startTime > endTime) { return currentTime >= startTime || currentTime < endTime; } return currentTime >= startTime && currentTime < endTime; } function generatePromptId() { return Math.random().toString(36).substring(2, 10); } function formatPromptMessage(payload) { let message = `${payload.title} ${payload.message}`; if (payload.prompt) { message += "\n\n"; if (payload.prompt.question) { message += `${payload.prompt.question} `; } if (payload.prompt.type === "yesno") { message += "Reply Y for Yes, N for No"; } else if (payload.prompt.type === "options" && payload.prompt.options) { payload.prompt.options.forEach((opt) => { message += `${opt.key}. ${opt.label} `; }); message += "\nReply with number to select"; } else if (payload.prompt.type === "freeform") { message += "Reply with your response"; } } return appendSessionUrl(message); } function getChannelNumbers(config) { const channel = config.channel || "whatsapp"; if (channel === "whatsapp") { const from2 = config.whatsappFromNumber || config.fromNumber; const to2 = config.whatsappToNumber || config.toNumber; if (from2 && to2) { return { from: from2.startsWith("whatsapp:") ? from2 : `whatsapp:${from2}`, to: to2.startsWith("whatsapp:") ? to2 : `whatsapp:${to2}`, channel: "whatsapp" }; } } const from = config.smsFromNumber || config.fromNumber; const to = config.smsToNumber || config.toNumber; if (from && to) { return { from, to, channel: "sms" }; } return null; } async function sendNotification(payload, channelOverride) { const config = loadSMSConfig(); if (!config.enabled) { return { success: false, error: "Notifications disabled" }; } const typeMap = { task_complete: "taskComplete", review_ready: "reviewReady", error: "error", custom: "custom", context_sync: "contextSync" }; if (!config.notifyOn[typeMap[payload.type]]) { return { success: false, error: `Notifications for ${payload.type} disabled` }; } if (isQuietHours(config)) { return { success: false, error: "Quiet hours active" }; } if (!config.accountSid || !config.authToken) { return { success: false, error: "Missing Twilio credentials. Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN" }; } const originalChannel = config.channel; if (channelOverride) { config.channel = channelOverride; } const numbers = getChannelNumbers(config); config.channel = originalChannel; if (!numbers) { return { success: false, error: config.channel === "whatsapp" ? "Missing WhatsApp numbers. Set TWILIO_WHATSAPP_FROM and TWILIO_WHATSAPP_TO" : "Missing SMS numbers. Set TWILIO_SMS_FROM and TWILIO_SMS_TO" }; } const message = formatPromptMessage(payload); let promptId; if (payload.prompt) { promptId = generatePromptId(); const expiresAt = new Date( Date.now() + config.responseTimeout * 1e3 ).toISOString(); const pendingPrompt = { id: promptId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), message: payload.message, options: payload.prompt.options || [], type: payload.prompt.type, expiresAt }; config.pendingPrompts.push(pendingPrompt); saveSMSConfig(config); } try { const twilioUrl = `https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`; const response = await fetch(twilioUrl, { method: "POST", headers: { Authorization: "Basic " + Buffer.from(`${config.accountSid}:${config.authToken}`).toString( "base64" ), "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ From: numbers.from, To: numbers.to, Body: message }) }); if (!response.ok) { const errorData = await response.text(); return { success: false, channel: numbers.channel, error: `Twilio error: ${errorData}` }; } return { success: true, promptId, channel: numbers.channel }; } catch (err) { return { success: false, channel: numbers.channel, error: `Failed to send ${numbers.channel}: ${err instanceof Error ? err.message : String(err)}` }; } } async function sendSMSNotification(payload) { return sendNotification(payload); } function processIncomingResponse(from, body) { const config = loadSMSConfig(); const response = body.trim().toLowerCase(); const now = /* @__PURE__ */ new Date(); const validPrompts = config.pendingPrompts.filter( (p) => new Date(p.expiresAt) > now ); if (validPrompts.length === 0) { return { matched: false }; } const prompt = validPrompts[validPrompts.length - 1]; let matchedOption; if (prompt.type === "yesno") { if (response === "y" || response === "yes") { matchedOption = { key: "y", label: "Yes" }; } else if (response === "n" || response === "no") { matchedOption = { key: "n", label: "No" }; } } else if (prompt.type === "options") { matchedOption = prompt.options.find( (opt) => opt.key.toLowerCase() === response ); } else if (prompt.type === "freeform") { matchedOption = { key: response, label: response }; } config.pendingPrompts = config.pendingPrompts.filter( (p) => p.id !== prompt.id ); saveSMSConfig(config); if (matchedOption) { return { matched: true, prompt, response: matchedOption.key, action: matchedOption.action }; } return { matched: false, prompt }; } function getSessionId() { return process.env["CLAUDE_INSTANCE_ID"] || process.env["STACKMEMORY_SESSION_ID"] || Math.random().toString(36).substring(2, 8); } function getSessionUrl() { const sessionId = process.env["CLAUDE_SESSION_ID"]; if (sessionId?.startsWith("session_")) { return `https://claude.ai/code/${sessionId}`; } return process.env["CLAUDE_SESSION_URL"]; } function appendSessionUrl(message) { const url = getSessionUrl(); if (url) { return `${message} Session: ${url}`; } return message; } async function notifyReviewReady(title, description, options) { const sessionId = getSessionId(); let finalOptions = options || []; if (finalOptions.length < 2) { const defaults = [ { label: "Approve", action: 'echo "Approved"' }, { label: "Request changes", action: 'echo "Changes requested"' } ]; finalOptions = [...finalOptions, ...defaults].slice( 0, Math.max(2, finalOptions.length) ); } const payload = { type: "review_ready", title: `[Claude ${sessionId}] Review Ready: ${title}`, message: description, prompt: { type: "options", options: finalOptions.map((opt, i) => ({ key: String(i + 1), label: opt.label, action: opt.action })), question: "What would you like to do?" } }; return sendSMSNotification(payload); } async function notifyWithYesNo(title, question, yesAction, noAction) { const sessionId = getSessionId(); return sendSMSNotification({ type: "custom", title: `[Claude ${sessionId}] ${title}`, message: question, prompt: { type: "yesno", options: [ { key: "y", label: "Yes", action: yesAction }, { key: "n", label: "No", action: noAction } ] } }); } async function notifyTaskComplete(taskName, summary) { const sessionId = getSessionId(); return sendSMSNotification({ type: "task_complete", title: `[Claude ${sessionId}] Task Complete: ${taskName}`, message: summary, prompt: { type: "options", options: [ { key: "1", label: "Start next task", action: "claude-sm" }, { key: "2", label: "View details", action: "stackmemory status" } ] } }); } async function notifyError(error, context) { const sessionId = getSessionId(); return sendSMSNotification({ type: "error", title: `[Claude ${sessionId}] Error Alert`, message: context ? `${error} Context: ${context}` : error, prompt: { type: "options", options: [ { key: "1", label: "Retry", action: "claude-sm" }, { key: "2", label: "View logs", action: "tail -50 ~/.claude/logs/*.log" } ] } }); } function cleanupExpiredPrompts() { const config = loadSMSConfig(); const now = /* @__PURE__ */ new Date(); const before = config.pendingPrompts.length; config.pendingPrompts = config.pendingPrompts.filter( (p) => new Date(p.expiresAt) > now ); const removed = before - config.pendingPrompts.length; if (removed > 0) { saveSMSConfig(config); } return removed; } async function notify(message) { const sessionId = getSessionId(); return sendNotification({ type: "custom", title: `[Claude ${sessionId}]`, message }); } async function notifyChoice(message, optionA, optionB) { const sessionId = getSessionId(); return sendNotification({ type: "custom", title: `[Claude ${sessionId}]`, message, prompt: { type: "options", options: [ { key: "1", label: optionA }, { key: "2", label: optionB } ] } }); } async function notifyYesNo(message) { const sessionId = getSessionId(); return sendNotification({ type: "custom", title: `[Claude ${sessionId}]`, message, prompt: { type: "yesno" } }); } async function notifyStep(step, status = "done") { const sessionId = getSessionId(); const symbol = status === "done" ? "\u2713" : status === "failed" ? "\u2717" : "\u23F3"; return sendNotification({ type: "task_complete", title: `[Claude ${sessionId}]`, message: `${symbol} ${step}` }); } export { cleanupExpiredPrompts, getMissingConfig, getSessionUrl, loadSMSConfig, notify, notifyChoice, notifyError, notifyReviewReady, notifyStep, notifyTaskComplete, notifyWithYesNo, notifyYesNo, processIncomingResponse, saveSMSConfig, sendNotification, sendSMSNotification }; //# sourceMappingURL=sms-notify.js.map