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