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.

480 lines (477 loc) 14 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 { execFileSync } from "child_process"; import { writeFileSecure, ensureSecureDir } from "./secure-fs.js"; import { WhatsAppCommandsConfigSchema, parseConfigSafe } from "./schemas.js"; import { executeActionSafe } from "./sms-action-runner.js"; import { syncContext, getFrameDigestData, generateMobileDigest, loadSyncOptions } from "./whatsapp-sync.js"; import { sendNotification } from "./sms-notify.js"; const MAX_REGEX_INPUT_LENGTH = 200; const DANGEROUS_PATTERNS = [ /(\+|\*|\?)\s*(\+|\*|\?)/, // Nested quantifiers like .+* or .*+ /\(\?[^)]*\)\s*[+*]/, // Quantified groups with + or * /\[[^\]]*\]\s*[+*]\s*[+*]/, // Character classes with nested quantifiers /(\.\*|\.\+)\s*(\.\*|\.\+)/, // Overlapping .* or .+ /\(\[[^\]]+\]\+\)\+/, // Nested + with character class /\(.*\+\).*\+/ // Nested + quantifiers ]; function isPatternSafe(pattern) { for (const dangerous of DANGEROUS_PATTERNS) { if (dangerous.test(pattern)) { console.warn( `[whatsapp-commands] Potentially dangerous regex pattern blocked: ${pattern}` ); return false; } } const quantifierCount = (pattern.match(/[+*?]/g) || []).length; const groupCount = (pattern.match(/\(/g) || []).length; if (quantifierCount > 5 || groupCount > 3) { console.warn( `[whatsapp-commands] Complex regex pattern blocked: ${pattern} (${quantifierCount} quantifiers, ${groupCount} groups)` ); return false; } return true; } function safeRegexTest(pattern, input) { if (!isPatternSafe(pattern)) { return false; } const safeInput = input.slice(0, MAX_REGEX_INPUT_LENGTH); try { const regex = new RegExp(pattern); return regex.test(safeInput); } catch { console.warn(`[whatsapp-commands] Invalid regex pattern: ${pattern}`); return false; } } const CONFIG_PATH = join(homedir(), ".stackmemory", "whatsapp-commands.json"); const REMOTE_SESSIONS_PATH = join( homedir(), ".stackmemory", "remote-sessions.json" ); function loadRemoteSessions() { try { if (existsSync(REMOTE_SESSIONS_PATH)) { return JSON.parse(readFileSync(REMOTE_SESSIONS_PATH, "utf8")); } } catch { } return { sessions: [] }; } function saveRemoteSessions(store) { try { ensureSecureDir(join(homedir(), ".stackmemory")); writeFileSecure(REMOTE_SESSIONS_PATH, JSON.stringify(store, null, 2)); } catch { } } function addRemoteSession(session) { const store = loadRemoteSessions(); store.sessions = [session, ...store.sessions.slice(0, 19)]; saveRemoteSessions(store); } function getRemoteSessions() { return loadRemoteSessions().sessions; } function getActiveRemoteSessions() { return loadRemoteSessions().sessions.filter((s) => s.status === "active"); } const DEFAULT_COMMANDS = [ { name: "help", description: "List available commands", enabled: true }, { name: "status", description: "Get current task/frame status", enabled: true }, { name: "sessions", description: "List active remote sessions with URLs", enabled: true }, { name: "remote", description: "Launch remote Claude session (requires task prompt)", enabled: true, requiresArg: true }, // Disabled by default - can be enabled in config if needed { name: "context", description: "Get latest context digest", enabled: false }, { name: "sync", description: "Push current context to WhatsApp", enabled: false }, { name: "tasks", description: "List active tasks", enabled: false } ]; const DEFAULT_CONFIG = { enabled: true, commands: DEFAULT_COMMANDS }; function loadCommandsConfig() { try { if (existsSync(CONFIG_PATH)) { const data = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); return parseConfigSafe( WhatsAppCommandsConfigSchema, { ...DEFAULT_CONFIG, ...data }, DEFAULT_CONFIG, "whatsapp-commands" ); } } catch { } return { ...DEFAULT_CONFIG }; } function saveCommandsConfig(config) { try { ensureSecureDir(join(homedir(), ".stackmemory")); writeFileSecure(CONFIG_PATH, JSON.stringify(config, null, 2)); } catch { } } function isCommand(message) { const trimmed = message.trim().toLowerCase(); const config = loadCommandsConfig(); if (!config.enabled) return false; const words = trimmed.split(/\s+/); const firstWord = words[0]; return config.commands.some( (cmd) => cmd.enabled && cmd.name.toLowerCase() === firstWord ); } function parseCommand(message) { const trimmed = message.trim(); const words = trimmed.split(/\s+/); if (words.length === 0) return null; const name = words[0].toLowerCase(); const arg = words.slice(1).join(" ").trim() || void 0; return { name, arg }; } function generateHelpText(config) { const lines = ["Available commands:"]; config.commands.filter((cmd) => cmd.enabled).forEach((cmd) => { const argHint = cmd.requiresArg ? " <arg>" : ""; lines.push(` ${cmd.name}${argHint} - ${cmd.description}`); }); lines.push(""); lines.push("Reply with command name to execute"); return lines.join("\n"); } async function handleContextCommand() { const data = await getFrameDigestData(); if (!data) { return "No context available. Start a task first."; } const options = loadSyncOptions(); return generateMobileDigest(data, options); } async function handleSyncCommand() { const result = await syncContext(); if (result.success) { return `Context synced (${result.digestLength} chars)`; } else { return `Sync failed: ${result.error}`; } } async function handleStatusCommand() { try { const data = await getFrameDigestData(); if (!data) { return "No active session. Start with: claude-sm"; } const lines = []; lines.push(`Frame: ${data.name || data.frameId}`); lines.push(`Status: ${data.status}`); lines.push(`Files: ${data.filesModified?.length || 0} modified`); lines.push(`Tools: ${data.toolCallCount || 0} calls`); if (data.errors?.length > 0) { const unresolved = data.errors.filter((e) => !e.resolved).length; if (unresolved > 0) lines.push(`Errors: ${unresolved} unresolved`); } lines.push(`Duration: ${Math.round(data.durationSeconds / 60)}min`); return lines.join("\n"); } catch { return "Status unavailable"; } } async function handleTasksCommand() { try { const data = await getFrameDigestData(); if (!data) { return "No active tasks"; } const lines = []; if (data.decisions?.length > 0) { lines.push("Recent decisions:"); data.decisions.slice(0, 3).forEach((d, i) => { lines.push( `${i + 1}. ${d.substring(0, 50)}${d.length > 50 ? "..." : ""}` ); }); } if (data.risks?.length > 0) { lines.push(""); lines.push("Risks:"); data.risks.slice(0, 2).forEach((r) => { lines.push(`- ${r.substring(0, 50)}${r.length > 50 ? "..." : ""}`); }); } if (lines.length === 0) { return "No active tasks or decisions"; } return lines.join("\n"); } catch { return "Tasks unavailable"; } } async function handleRemoteCommand(prompt) { try { const sanitizedPrompt = prompt.replace(/[`$\\]/g, "").replace(/["']/g, "'").substring(0, 500); if (!sanitizedPrompt.trim()) { return "Please provide a task prompt. Usage: remote <your task>"; } console.log( `[whatsapp-commands] Launching remote session: ${sanitizedPrompt.substring(0, 50)}...` ); const output = execFileSync("claude", ["--remote", sanitizedPrompt], { encoding: "utf8", timeout: 3e4, stdio: ["pipe", "pipe", "pipe"] }); const urlMatch = output.match( /https:\/\/claude\.ai\/code\/session_[a-zA-Z0-9]+/ ); if (urlMatch) { const sessionUrl = urlMatch[0]; const sessionId = sessionUrl.split("/").pop() || "unknown"; addRemoteSession({ id: sessionId, url: sessionUrl, prompt: sanitizedPrompt, createdAt: (/* @__PURE__ */ new Date()).toISOString(), status: "active" }); return `Remote session launched! ${sessionUrl} Task: ${sanitizedPrompt.substring(0, 100)}`; } return `Session launched: ${output.substring(0, 300)}`; } catch (err) { const error = err instanceof Error ? err.message : String(err); console.error(`[whatsapp-commands] Remote launch failed: ${error}`); return `Failed to launch remote session: ${error.substring(0, 100)}`; } } function handleSessionsCommand() { const sessions = getActiveRemoteSessions(); if (sessions.length === 0) { return "No active remote sessions"; } const lines = ["Active remote sessions:"]; sessions.slice(0, 5).forEach((s, i) => { const age = Math.round( (Date.now() - new Date(s.createdAt).getTime()) / 6e4 ); const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`; lines.push(`${i + 1}. ${s.prompt.substring(0, 40)}... (${ageStr})`); lines.push(` ${s.url}`); }); return lines.join("\n"); } async function processCommand(from, message) { const config = loadCommandsConfig(); if (!config.enabled) { return { handled: false }; } const parsed = parseCommand(message); if (!parsed) { return { handled: false }; } const command = config.commands.find( (cmd) => cmd.enabled && cmd.name.toLowerCase() === parsed.name ); if (!command) { return { handled: false }; } if (command.name === "help") { const helpText = generateHelpText(config); return { handled: true, response: helpText }; } if (command.name === "context") { const contextText = await handleContextCommand(); return { handled: true, response: contextText }; } if (command.name === "sync") { const syncText = await handleSyncCommand(); return { handled: true, response: syncText }; } if (command.name === "status") { const statusText = await handleStatusCommand(); return { handled: true, response: statusText }; } if (command.name === "tasks") { const tasksText = await handleTasksCommand(); return { handled: true, response: tasksText }; } if (command.name === "remote") { if (!parsed.arg) { return { handled: true, response: "Usage: remote <task prompt>\nExample: remote Fix the login bug", error: "Missing prompt" }; } const remoteText = await handleRemoteCommand(parsed.arg); return { handled: true, response: remoteText }; } if (command.name === "sessions") { const sessionsText = handleSessionsCommand(); return { handled: true, response: sessionsText }; } if (command.requiresArg && !parsed.arg) { return { handled: true, response: `${command.name} requires an argument. Usage: ${command.name} <arg>`, error: "Missing argument" }; } if (command.argPattern && parsed.arg) { if (!safeRegexTest(command.argPattern, parsed.arg)) { return { handled: true, response: `Invalid argument format for ${command.name}`, error: "Invalid argument format" }; } } let action = command.action; if (action && parsed.arg) { if (command.name === "approve") { action = `gh pr review ${parsed.arg} --approve`; } else if (command.name === "merge") { action = `gh pr merge ${parsed.arg} --squash`; } } if (action) { console.log(`[whatsapp-commands] Executing: ${action}`); const result = await executeActionSafe(action, message); if (result.success) { const output = result.output?.slice(0, 200) || "Done"; return { handled: true, response: `${command.name}: ${output}`, action }; } else { return { handled: true, response: `${command.name} failed: ${result.error?.slice(0, 100)}`, error: result.error, action }; } } return { handled: true, response: `Command ${command.name} acknowledged` }; } async function sendCommandResponse(response) { const result = await sendNotification({ type: "custom", title: "Command Result", message: response }); return { success: result.success, error: result.error }; } function enableCommands() { const config = loadCommandsConfig(); config.enabled = true; saveCommandsConfig(config); } function disableCommands() { const config = loadCommandsConfig(); config.enabled = false; saveCommandsConfig(config); } function isCommandsEnabled() { const config = loadCommandsConfig(); return config.enabled; } function addCommand(command) { const config = loadCommandsConfig(); const existingIndex = config.commands.findIndex( (c) => c.name.toLowerCase() === command.name.toLowerCase() ); if (existingIndex >= 0) { config.commands[existingIndex] = command; } else { config.commands.push(command); } saveCommandsConfig(config); } function removeCommand(name) { const config = loadCommandsConfig(); const initialLength = config.commands.length; config.commands = config.commands.filter( (c) => c.name.toLowerCase() !== name.toLowerCase() ); if (config.commands.length < initialLength) { saveCommandsConfig(config); return true; } return false; } function getAvailableCommands() { const config = loadCommandsConfig(); return config.commands.filter((c) => c.enabled); } export { addCommand, disableCommands, enableCommands, getActiveRemoteSessions, getAvailableCommands, getRemoteSessions, isCommand, isCommandsEnabled, loadCommandsConfig, processCommand, removeCommand, saveCommandsConfig, sendCommandResponse }; //# sourceMappingURL=whatsapp-commands.js.map