diff --git a/AGENTS.md b/AGENTS.md index 9d30179bd..c45c5a4ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,12 +107,15 @@ src/ ## Build, Test, and Development Commands - Install deps: `pnpm install` - Run CLI in dev: `pnpm warelay ...` (tsx entry) or `pnpm dev` for `src/index.ts` -- Type-check/build: `pnpm build` (tsc) +- Type-check/build: `bun run build` (tsc) +- **Relink globally after code changes**: `bun run build && bun link` - Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format) - Fix lint/format: `pnpm lint:fix`, `pnpm format:fix` - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` - Node requirement: >=22.0.0 +> **Important**: Always use `bun` for building and linking. After modifying source code, run `bun run build && bun link` to rebuild and update the global `warelay` command. + ## Key Dependencies | Package | Purpose | |---------|---------| diff --git a/CONTEXT/PLAN-heartbeat-prehook-2025-12-01.md b/CONTEXT/PLAN-heartbeat-prehook-2025-12-01.md new file mode 100644 index 000000000..c3e02ad27 --- /dev/null +++ b/CONTEXT/PLAN-heartbeat-prehook-2025-12-01.md @@ -0,0 +1,807 @@ +# Plan: Add `heartbeatPreHook` Support to Warelay + +**Date:** 2025-12-01 +**Status:** Complete +**Feature:** Allow users to run a custom script before each heartbeat that gathers context (like email summaries) to inject into the heartbeat prompt + +--- + +## Overview + +### Motivation +Users want to inject dynamic context into heartbeat prompts. The primary use case is fetching unread Office 365 emails since the last heartbeat and summarizing them, so the AI assistant can proactively inform users about important communications. + +### Behavior Summary +1. Before each heartbeat fires, run the configured pre-hook command +2. Capture stdout from the command +3. If stdout is non-empty, prepend it to the heartbeat prompt: + - Normal: `HEARTBEAT ultrathink` + - With context: `HEARTBEAT ultrathink\n\n---\nContext from pre-hook:\n{stdout}` +4. If the pre-hook fails or times out, log a warning but still send the basic heartbeat +5. If stdout is empty, just send the normal heartbeat prompt + +--- + +## Plan Updates (2025-12-02) + +- Centralize pre-hook execution in `runWebHeartbeatOnce`; callers like `runReplyHeartbeat` should pass `skipPreHook: true` to avoid double runs in fallback flows. +- Run pre-hook only after confirming a heartbeat recipient and after queue/interval guards, so skipped heartbeats do not trigger scripts. +- Add `cfg?` (or injected `loadConfig`) plus `skipPreHook`/`overrideBody` gates to the Twilio path; ensure tests can stub config to avoid reading real user config. +- Add an in-flight guard to prevent overlapping heartbeats when pre-hook runs long. +- Keep the pre-hook module’s logging minimal and let callers emit structured heartbeat logs; optionally accept a logger/context parameter. +- Cap injected context size (chars/lines) before prompt injection and include a short stderr preview in warnings for easier debugging. +- Expand tests for skip conditions (queue > 0), fallback double-run prevention, and manual override skipping in both providers. + +## Task Breakdown + +### Phase 1: Config Schema Changes + +#### 1.1 Update TypeScript Types +- [x] Add `heartbeatPreHook?: string[]` to `SessionConfig` type in `src/config/config.ts` +- [x] Add `heartbeatPreHookTimeoutSeconds?: number` to `SessionConfig` type (default: 30) + +**File:** `src/config/config.ts` + +```typescript +// Add to SessionConfig type (around line 12-25) +export type SessionConfig = { + scope?: SessionScope; + resetTriggers?: string[]; + idleMinutes?: number; + heartbeatIdleMinutes?: number; + store?: string; + sessionArgNew?: string[]; + sessionArgResume?: string[]; + sessionArgBeforeBody?: boolean; + sendSystemOnce?: boolean; + sessionIntro?: string; + typingIntervalSeconds?: number; + heartbeatMinutes?: number; + // NEW: + heartbeatPreHook?: string[]; // Command + args to run before heartbeat + heartbeatPreHookTimeoutSeconds?: number; // Default: 30 +}; +``` + +#### 1.2 Update Zod Schema +- [x] Add `heartbeatPreHook` array validation to `ReplySchema.session` object +- [x] Add `heartbeatPreHookTimeoutSeconds` positive integer validation + +**File:** `src/config/config.ts` + +```typescript +// Add to session schema (around line 90-106) +.object({ + scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), + resetTriggers: z.array(z.string()).optional(), + idleMinutes: z.number().int().positive().optional(), + heartbeatIdleMinutes: z.number().int().positive().optional(), + store: z.string().optional(), + sessionArgNew: z.array(z.string()).optional(), + sessionArgResume: z.array(z.string()).optional(), + sessionArgBeforeBody: z.boolean().optional(), + sendSystemOnce: z.boolean().optional(), + sessionIntro: z.string().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + // NEW: + heartbeatPreHook: z.array(z.string()).optional(), + heartbeatPreHookTimeoutSeconds: z.number().int().positive().optional(), +}) +``` + +--- + +### Phase 2: Create Shared Pre-Hook Module + +#### 2.1 Create New Module +- [x] Create `src/auto-reply/heartbeat-prehook.ts` with shared logic for both providers +- [x] Allow optional logger/context input; keep internal logging minimal (debug-level only); let callers log structured summaries +- [x] Cap stdout before injection (e.g., max chars/lines) and include a short stderr preview in warnings + +**New File:** `src/auto-reply/heartbeat-prehook.ts` + +```typescript +import { logVerbose, danger } from "../globals.js"; +import { logDebug, logWarn } from "../logger.js"; +import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js"; +import type { WarelayConfig } from "../config/config.js"; + +export type PreHookResult = { + context?: string; // stdout to prepend to heartbeat + durationMs: number; + error?: string; // error message if failed + timedOut?: boolean; +}; + +const DEFAULT_PREHOOK_TIMEOUT_SECONDS = 30; + +export function buildHeartbeatPrompt( + basePrompt: string, + preHookContext?: string, +): string { + if (!preHookContext?.trim()) { + return basePrompt; + } + return `${basePrompt}\n\n---\nContext from pre-hook:\n${preHookContext.trim()}`; +} + +export async function runHeartbeatPreHook( + cfg: WarelayConfig, + commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout, +): Promise { + const sessionCfg = cfg.inbound?.reply?.session; + const preHookCommand = sessionCfg?.heartbeatPreHook; + + if (!preHookCommand?.length) { + return { durationMs: 0 }; + } + + const timeoutSeconds = sessionCfg?.heartbeatPreHookTimeoutSeconds ?? DEFAULT_PREHOOK_TIMEOUT_SECONDS; + const timeoutMs = timeoutSeconds * 1000; + const started = Date.now(); + + logVerbose(`Running heartbeat pre-hook: ${preHookCommand.join(" ")}`); + + try { + const result: SpawnResult = await commandRunner(preHookCommand, { timeoutMs }); + const durationMs = Date.now() - started; + + if (result.killed || result.signal === "SIGKILL") { + logWarn(`Heartbeat pre-hook timed out after ${timeoutSeconds}s`); + return { + durationMs, + timedOut: true, + error: `Pre-hook timed out after ${timeoutSeconds}s`, + }; + } + + if ((result.code ?? 0) !== 0) { + const errorMsg = `Pre-hook exited with code ${result.code}`; + logWarn(errorMsg); + logVerbose(`Pre-hook stderr: ${result.stderr?.trim() || "(empty)"}`); + return { + durationMs, + error: errorMsg, + }; + } + + const stdout = result.stdout?.trim(); + logVerbose(`Pre-hook completed in ${durationMs}ms, output length: ${stdout?.length ?? 0}`); + + if (stdout) { + logDebug(`Pre-hook output: ${stdout.slice(0, 200)}${stdout.length > 200 ? "..." : ""}`); + } + + return { + context: stdout || undefined, + durationMs, + }; + } catch (err) { + const durationMs = Date.now() - started; + const anyErr = err as { killed?: boolean; signal?: string }; + + if (anyErr.killed || anyErr.signal === "SIGKILL") { + return { + durationMs, + timedOut: true, + error: `Pre-hook timed out after ${timeoutSeconds}s`, + }; + } + + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(danger(`Heartbeat pre-hook failed: ${errorMsg}`)); + + return { + durationMs, + error: errorMsg, + }; + } +} +``` + +--- + +### Phase 3: Integrate Pre-Hook into Web Provider + +#### 3.1 Update Web Heartbeat +- [x] Import `runHeartbeatPreHook` and `buildHeartbeatPrompt` in `src/web/auto-reply.ts` +- [x] Centralize pre-hook execution inside `runWebHeartbeatOnce`; call from `runReplyHeartbeat` with `skipPreHook: true` to avoid double runs in fallback flow +- [x] Run pre-hook only after queue/interval guards and after a recipient is determined +- [x] Add in-flight guard to prevent overlapping heartbeats when pre-hook runs long +- [x] Log pre-hook outcomes via the existing heartbeat logger (structured), keeping the hook’s own logging minimal + +**File:** `src/web/auto-reply.ts` + +Add import at top: +```typescript +import { runHeartbeatPreHook, buildHeartbeatPrompt } from "../auto-reply/heartbeat-prehook.js"; +``` + +Modify `runReplyHeartbeat` function (around line 797): +```typescript +const runReplyHeartbeat = async () => { + const queued = getQueueSize(); + if (queued > 0) { + heartbeatLogger.info( + { connectionId, reason: "requests-in-flight", queued }, + "reply heartbeat skipped", + ); + console.log(success("heartbeat: skipped (requests in flight)")); + return; + } + if (!replyHeartbeatMinutes) return; + const tickStart = Date.now(); + + // NEW: Run pre-hook to gather context + const preHookResult = await runHeartbeatPreHook(cfg); + if (preHookResult.error) { + heartbeatLogger.warn( + { connectionId, error: preHookResult.error, durationMs: preHookResult.durationMs, timedOut: preHookResult.timedOut }, + "heartbeat pre-hook failed (continuing with basic heartbeat)", + ); + } else if (preHookResult.context) { + heartbeatLogger.info( + { connectionId, contextLength: preHookResult.context.length, durationMs: preHookResult.durationMs }, + "heartbeat pre-hook succeeded", + ); + } + + // Build heartbeat prompt with optional pre-hook context + const heartbeatPrompt = buildHeartbeatPrompt(HEARTBEAT_PROMPT, preHookResult.context); + + // ... rest of function, replace HEARTBEAT_PROMPT with heartbeatPrompt ... +``` + +Also update `runWebHeartbeatOnce` (around line 98) to support pre-hook: +```typescript +export async function runWebHeartbeatOnce(opts: { + cfg?: ReturnType; + to: string; + verbose?: boolean; + replyResolver?: typeof getReplyFromConfig; + runtime?: RuntimeEnv; + sender?: typeof sendMessageWeb; + sessionId?: string; + overrideBody?: string; + dryRun?: boolean; + skipPreHook?: boolean; // NEW: allow skipping for manual/override cases +}) { + // ... existing setup code ... + + // NEW: Run pre-hook unless skipped or overrideBody provided + let heartbeatPrompt = HEARTBEAT_PROMPT; + if (!overrideBody && !opts.skipPreHook) { + const preHookResult = await runHeartbeatPreHook(cfg); + if (preHookResult.error) { + heartbeatLogger.warn( + { to, error: preHookResult.error, durationMs: preHookResult.durationMs }, + "heartbeat pre-hook failed", + ); + } + heartbeatPrompt = buildHeartbeatPrompt(HEARTBEAT_PROMPT, preHookResult.context); + } + + // ... use heartbeatPrompt instead of HEARTBEAT_PROMPT in replyResolver call ... +``` + +--- + +### Phase 4: Integrate Pre-Hook into Twilio Provider + +#### 4.1 Update Twilio Heartbeat +- [x] Import `runHeartbeatPreHook` and `buildHeartbeatPrompt` in `src/twilio/heartbeat.ts` +- [x] Add `cfg?` (or injected `loadConfig`) plus `skipPreHook` and `overrideBody` gates; avoid loading real user config in tests +- [x] Modify `runTwilioHeartbeatOnce()` to call pre-hook (with size-capped stdout) and log via structured logger/context + +**File:** `src/twilio/heartbeat.ts` + +Add import at top: +```typescript +import { runHeartbeatPreHook, buildHeartbeatPrompt } from "../auto-reply/heartbeat-prehook.js"; +import { loadConfig } from "../config/config.js"; +``` + +Modify `runTwilioHeartbeatOnce` function: +```typescript +export async function runTwilioHeartbeatOnce(opts: { + to: string; + verbose?: boolean; + runtime?: RuntimeEnv; + replyResolver?: ReplyResolver; + overrideBody?: string; + dryRun?: boolean; + skipPreHook?: boolean; // NEW +}) { + const { + to, + verbose: _verbose = false, + runtime = defaultRuntime, + overrideBody, + dryRun = false, + skipPreHook = false, // NEW + } = opts; + const replyResolver = opts.replyResolver ?? getReplyFromConfig; + const cfg = loadConfig(); // NEW: load config for pre-hook + + // ... existing overrideBody handling ... + + // NEW: Run pre-hook unless skipped + let heartbeatPrompt = HEARTBEAT_PROMPT; + if (!skipPreHook) { + const preHookResult = await runHeartbeatPreHook(cfg); + if (preHookResult.error) { + logInfo(`Pre-hook failed: ${preHookResult.error} (continuing)`, runtime); + } + heartbeatPrompt = buildHeartbeatPrompt(HEARTBEAT_PROMPT, preHookResult.context); + } + + const replyResult = await replyResolver( + { + Body: heartbeatPrompt, // Use dynamic prompt + From: to, + To: to, + MessageSid: undefined, + }, + undefined, + ); + + // ... rest unchanged ... +``` + +--- + +### Phase 5: Unit Tests + +#### 5.1 Test Pre-Hook Module +- [x] Create `src/auto-reply/heartbeat-prehook.test.ts` + +**New File:** `src/auto-reply/heartbeat-prehook.test.ts` + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { buildHeartbeatPrompt, runHeartbeatPreHook } from "./heartbeat-prehook.js"; +import type { WarelayConfig } from "../config/config.js"; +import type { SpawnResult } from "../process/exec.js"; + +describe("buildHeartbeatPrompt", () => { + it("returns base prompt when no context", () => { + expect(buildHeartbeatPrompt("HEARTBEAT ultrathink")).toBe("HEARTBEAT ultrathink"); + expect(buildHeartbeatPrompt("HEARTBEAT ultrathink", "")).toBe("HEARTBEAT ultrathink"); + expect(buildHeartbeatPrompt("HEARTBEAT ultrathink", " ")).toBe("HEARTBEAT ultrathink"); + }); + + it("appends context when provided", () => { + const result = buildHeartbeatPrompt("HEARTBEAT ultrathink", "You have 3 unread emails"); + expect(result).toBe("HEARTBEAT ultrathink\n\n---\nContext from pre-hook:\nYou have 3 unread emails"); + }); + + it("trims context whitespace", () => { + const result = buildHeartbeatPrompt("HEARTBEAT", " context with spaces "); + expect(result).toContain("context with spaces"); + expect(result).not.toContain(" context"); + }); +}); + +describe("runHeartbeatPreHook", () => { + it("returns empty result when no pre-hook configured", async () => { + const cfg: WarelayConfig = {}; + const result = await runHeartbeatPreHook(cfg); + expect(result.durationMs).toBe(0); + expect(result.context).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it("returns stdout as context on success", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["echo", "email summary"], + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "email summary\n", + stderr: "", + code: 0, + signal: null, + killed: false, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.context).toBe("email summary"); + expect(result.error).toBeUndefined(); + expect(mockRunner).toHaveBeenCalledWith( + ["echo", "email summary"], + { timeoutMs: 30000 }, + ); + }); + + it("returns error on non-zero exit", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["failing-script"], + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "error output", + code: 1, + signal: null, + killed: false, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.context).toBeUndefined(); + expect(result.error).toContain("exited with code 1"); + }); + + it("handles timeout gracefully", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["slow-script"], + heartbeatPreHookTimeoutSeconds: 5, + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "", + code: null, + signal: "SIGKILL", + killed: true, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.timedOut).toBe(true); + expect(result.error).toContain("timed out"); + expect(result.context).toBeUndefined(); + }); + + it("uses custom timeout from config", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["script"], + heartbeatPreHookTimeoutSeconds: 60, + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + } satisfies SpawnResult); + + await runHeartbeatPreHook(cfg, mockRunner); + expect(mockRunner).toHaveBeenCalledWith( + ["script"], + { timeoutMs: 60000 }, + ); + }); + + it("returns empty context for whitespace-only stdout", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["script"], + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: " \n\n ", + stderr: "", + code: 0, + signal: null, + killed: false, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.context).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); +}); +``` + +#### 5.2 Update Existing Tests +- [x] Add pre-hook tests to `src/twilio/heartbeat.test.ts` (cfg injection, override skips hook) +- [ ] Add pre-hook tests to `src/web/auto-reply.test.ts` (skip on queue>0, no double-run in fallback, override skips hook, in-flight guard) + +--- + +### Phase 6: Documentation + +#### 6.1 Update Config Documentation +- [ ] Document new config options in `README.md` or dedicated docs +- [ ] Add example pre-hook script patterns + +**Example Config:** +```json5 +{ + "inbound": { + "reply": { + "mode": "command", + "command": ["claude", "{{Body}}"], + "session": { + "scope": "per-sender", + "heartbeatMinutes": 30, + // NEW: Pre-hook configuration + "heartbeatPreHook": ["./scripts/fetch-unread-emails.sh"], + "heartbeatPreHookTimeoutSeconds": 45 + } + } + } +} +``` + +--- + +## Office 365 Email Integration Options + +### Background +The pre-hook feature is designed to be script-agnostic. Users can write any executable that outputs context to stdout. Below are options for Office 365 email integration. + +### Option 1: Microsoft Graph API with Device Code Flow (Recommended for Personal Use) + +**Pros:** +- Works without admin consent for personal accounts +- One-time interactive authentication, then refresh tokens +- Full access to mailbox + +**Implementation:** +```bash +#!/bin/bash +# fetch-unread-emails.sh + +# Uses Azure CLI or custom OAuth token management +# Prerequisites: az login with device code, or store refresh token + +ACCESS_TOKEN=$(az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv) + +curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?\$filter=isRead eq false&\$top=5&\$select=subject,from,receivedDateTime" \ + | jq -r '.value[] | "- \(.receivedDateTime | split("T")[0]): \(.from.emailAddress.name // .from.emailAddress.address): \(.subject)"' +``` + +**Setup Steps:** +1. Register an Azure AD app (single-tenant or multi-tenant) +2. Add `Mail.Read` delegated permission +3. Use device code flow for initial auth: `az login --scope https://graph.microsoft.com/Mail.Read` +4. Store refresh token securely for unattended use + +### Option 2: Application Permissions (Admin Consent Required) + +**Pros:** +- No user interaction needed after setup +- Works with client credentials flow + +**Cons:** +- Requires Azure AD admin consent +- Grants access to all mailboxes (use with caution) + +**Implementation:** +```bash +#!/bin/bash +# fetch-emails-app-auth.sh + +CLIENT_ID="your-app-id" +CLIENT_SECRET="your-secret" +TENANT_ID="your-tenant" +USER_EMAIL="user@domain.com" + +# Get token +TOKEN=$(curl -s -X POST \ + "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "scope=https://graph.microsoft.com/.default" \ + -d "grant_type=client_credentials" \ + | jq -r '.access_token') + +# Fetch emails +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://graph.microsoft.com/v1.0/users/$USER_EMAIL/mailFolders/inbox/messages?\$filter=isRead eq false&\$top=5" \ + | jq -r '.value[] | "- \(.from.emailAddress.name): \(.subject)"' +``` + +### Option 3: Using `msgraph-cli` (Easiest Setup) + +Microsoft provides an official CLI tool: + +```bash +# Install +pip install msgraph-cli + +# Login (one-time, uses device code) +mgc login --scopes Mail.Read + +# Fetch unread emails +mgc users mail-folders messages list \ + --user-id me \ + --mail-folder-id inbox \ + --filter "isRead eq false" \ + --top 5 \ + --select subject,from,receivedDateTime \ + --output json | jq -r '.value[] | "- \(.subject)"' +``` + +### Option 4: IMAP (Legacy, but Simple) + +If OAuth is too complex, IMAP with app passwords works: + +```bash +#!/bin/bash +# fetch-imap-emails.sh +# Requires: curl with IMAP support, or python imaplib + +python3 << 'EOF' +import imaplib +import email +from email.header import decode_header + +mail = imaplib.IMAP4_SSL("outlook.office365.com") +mail.login("user@domain.com", "app-password-here") +mail.select("inbox") + +_, messages = mail.search(None, "UNSEEN") +for num in messages[0].split()[:5]: + _, msg = mail.fetch(num, "(RFC822)") + email_msg = email.message_from_bytes(msg[0][1]) + subject = decode_header(email_msg["Subject"])[0][0] + if isinstance(subject, bytes): + subject = subject.decode() + print(f"- {email_msg['From']}: {subject}") + +mail.logout() +EOF +``` + +### Recommended Script Structure + +```bash +#!/bin/bash +# ~/.warelay/scripts/email-context.sh +# Output format: plain text summary for AI consumption + +set -e + +# Track last check time +LAST_CHECK_FILE="$HOME/.warelay/last-email-check" +if [ -f "$LAST_CHECK_FILE" ]; then + SINCE=$(cat "$LAST_CHECK_FILE") +else + SINCE=$(date -u -v-1H +"%Y-%m-%dT%H:%M:%SZ") # Default: last hour +fi + +# Fetch emails (using your preferred method) +EMAILS=$(fetch_unread_emails_since "$SINCE") + +# Update last check time +date -u +"%Y-%m-%dT%H:%M:%SZ" > "$LAST_CHECK_FILE" + +# Output summary if any emails found +if [ -n "$EMAILS" ]; then + echo "Unread emails since last heartbeat:" + echo "$EMAILS" +fi + +# Exit cleanly even with no emails (empty stdout = no context added) +exit 0 +``` + +--- + +## Implementation Order + +``` +Phase 1: Config Schema (1-2 hours) + | + v +Phase 2: Pre-Hook Module (2-3 hours) + | + v +Phase 3: Web Provider Integration (1-2 hours) + | + v +Phase 4: Twilio Provider Integration (1 hour) + | + v +Phase 5: Unit Tests (2-3 hours) + | + v +Phase 6: Documentation (1 hour) +``` + +**Total Estimated Time:** 8-12 hours + +--- + +## Validation Criteria + +### Config Validation +- [ ] `pnpm lint` passes with new schema +- [ ] `pnpm build` compiles without errors +- [ ] Invalid config (e.g., negative timeout) produces clear error message + +### Functional Validation +- [ ] Pre-hook runs before each heartbeat when configured +- [ ] Pre-hook stdout appears in Claude's context (visible in verbose logs) +- [ ] Pre-hook timeout doesn't block heartbeat (basic heartbeat still sends) +- [ ] Pre-hook failure doesn't block heartbeat (basic heartbeat still sends) +- [ ] Empty pre-hook output results in normal heartbeat prompt +- [ ] Pre-hook respects configured timeout value + +### Test Validation +- [ ] `pnpm test` passes all new tests +- [ ] Coverage thresholds maintained (70% lines/branches/functions/statements) + +### Manual Testing +1. Configure a simple pre-hook: `["echo", "Test context"]` +2. Run `warelay relay --provider web --verbose` +3. Wait for heartbeat or trigger with `warelay heartbeat` +4. Verify logs show pre-hook execution and context injection +5. Test timeout with: `["sleep", "60"]` and `heartbeatPreHookTimeoutSeconds: 2` +6. Verify heartbeat still fires after timeout + +--- + +## Code References + +### Internal Files to Modify +| File | Purpose | +|------|---------| +| `src/config/config.ts` | Add new config types and Zod schema | +| `src/auto-reply/heartbeat-prehook.ts` | **NEW** - Shared pre-hook logic | +| `src/auto-reply/heartbeat-prehook.test.ts` | **NEW** - Unit tests | +| `src/web/auto-reply.ts` | Integrate pre-hook into web heartbeat | +| `src/twilio/heartbeat.ts` | Integrate pre-hook into Twilio heartbeat | +| `src/twilio/heartbeat.test.ts` | Add pre-hook test cases | + +### External References +| Resource | URL | +|----------|-----| +| Microsoft Graph Mail API | https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview | +| Device Code Flow | https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code | +| Microsoft Graph CLI | https://github.com/microsoftgraph/msgraph-cli | +| Azure CLI | https://learn.microsoft.com/en-us/cli/azure/install-azure-cli | + +### Relevant Existing Patterns +| Pattern | File | Purpose | +|---------|------|---------| +| `runCommandWithTimeout` | `src/process/exec.ts` | Execute commands with timeout | +| `loadConfig` | `src/config/config.ts` | Load and validate config | +| Zod schemas | `src/config/config.ts` | Config validation patterns | +| Heartbeat constants | `src/web/auto-reply.ts` | `HEARTBEAT_PROMPT`, `HEARTBEAT_TOKEN` | + +--- + +## Notes + +- The pre-hook is intentionally script-agnostic to support any context source (email, calendar, RSS, etc.) +- The `---\nContext from pre-hook:` separator helps Claude distinguish injected context from the heartbeat command +- Pre-hook failures are logged but don't block heartbeats - this is intentional for reliability +- Consider adding `heartbeatPreHookCwd` in the future if users need to run scripts from specific directories diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 71e564948..5f3678b09 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -260,7 +260,9 @@ export async function runCommandReply( `Command auto-reply exited with code ${code ?? "unknown"} (signal: ${signal ?? "none"})`, ); // Include any partial output or stderr in error message - const partialOut = trimmed ? `\n\nOutput: ${trimmed.slice(0, 500)}${trimmed.length > 500 ? "..." : ""}` : ""; + const partialOut = trimmed + ? `\n\nOutput: ${trimmed.slice(0, 500)}${trimmed.length > 500 ? "..." : ""}` + : ""; const errorText = `⚠️ Command exited with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${partialOut}`; return { payload: { text: errorText }, diff --git a/src/auto-reply/heartbeat-prehook.test.ts b/src/auto-reply/heartbeat-prehook.test.ts new file mode 100644 index 000000000..06b0a07b1 --- /dev/null +++ b/src/auto-reply/heartbeat-prehook.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WarelayConfig } from "../config/config.js"; +import type { SpawnResult } from "../process/exec.js"; +import { + buildHeartbeatPrompt, + runHeartbeatPreHook, +} from "./heartbeat-prehook.js"; + +describe("buildHeartbeatPrompt", () => { + it("returns base prompt when no context", () => { + expect(buildHeartbeatPrompt("HEARTBEAT ultrathink")).toBe( + "HEARTBEAT ultrathink", + ); + expect(buildHeartbeatPrompt("HEARTBEAT ultrathink", "")).toBe( + "HEARTBEAT ultrathink", + ); + expect(buildHeartbeatPrompt("HEARTBEAT ultrathink", " ")).toBe( + "HEARTBEAT ultrathink", + ); + }); + + it("appends context when provided", () => { + const result = buildHeartbeatPrompt( + "HEARTBEAT ultrathink", + "You have 3 unread emails", + ); + expect(result).toBe( + "HEARTBEAT ultrathink\n\n---\nContext from pre-hook:\nYou have 3 unread emails", + ); + }); + + it("trims context whitespace", () => { + const result = buildHeartbeatPrompt("HEARTBEAT", " context with spaces "); + expect(result).toContain("context with spaces"); + expect(result).not.toContain(" context"); + }); +}); + +describe("runHeartbeatPreHook", () => { + it("returns empty result when no pre-hook configured", async () => { + const cfg: WarelayConfig = {}; + const result = await runHeartbeatPreHook(cfg); + expect(result.durationMs).toBe(0); + expect(result.context).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it("returns empty result when pre-hook is empty array", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: [], + }, + }, + }, + }; + const result = await runHeartbeatPreHook(cfg); + expect(result.durationMs).toBe(0); + expect(result.context).toBeUndefined(); + }); + + it("returns stdout as context on success", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["echo", "email summary"], + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "email summary\n", + stderr: "", + code: 0, + signal: null, + killed: false, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.context).toBe("email summary"); + expect(result.error).toBeUndefined(); + expect(mockRunner).toHaveBeenCalledWith(["echo", "email summary"], { + timeoutMs: 30000, + }); + }); + + it("returns error on non-zero exit", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["failing-script"], + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "error output", + code: 1, + signal: null, + killed: false, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.context).toBeUndefined(); + expect(result.error).toContain("exited with code 1"); + }); + + it("handles timeout gracefully", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["slow-script"], + heartbeatPreHookTimeoutSeconds: 5, + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "", + code: null, + signal: "SIGKILL", + killed: true, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.timedOut).toBe(true); + expect(result.error).toContain("timed out"); + expect(result.context).toBeUndefined(); + }); + + it("uses custom timeout from config", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["script"], + heartbeatPreHookTimeoutSeconds: 60, + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + } satisfies SpawnResult); + + await runHeartbeatPreHook(cfg, mockRunner); + expect(mockRunner).toHaveBeenCalledWith(["script"], { timeoutMs: 60000 }); + }); + + it("returns empty context for whitespace-only stdout", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["script"], + }, + }, + }, + }; + const mockRunner = vi.fn().mockResolvedValue({ + stdout: " \n\n ", + stderr: "", + code: 0, + signal: null, + killed: false, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.context).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it("handles thrown error from command runner", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["script"], + }, + }, + }, + }; + const mockRunner = vi.fn().mockRejectedValue(new Error("spawn ENOENT")); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.error).toBe("spawn ENOENT"); + expect(result.context).toBeUndefined(); + }); + + it("handles thrown timeout error (killed property)", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["script"], + }, + }, + }, + }; + const timeoutError = new Error("Command timed out"); + (timeoutError as unknown as { killed: boolean }).killed = true; + const mockRunner = vi.fn().mockRejectedValue(timeoutError); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.timedOut).toBe(true); + expect(result.error).toContain("timed out"); + }); + + it("caps large stdout to max size", async () => { + const cfg: WarelayConfig = { + inbound: { + reply: { + mode: "command", + command: ["echo"], + session: { + heartbeatPreHook: ["script"], + }, + }, + }, + }; + const largeOutput = "x".repeat(10000); + const mockRunner = vi.fn().mockResolvedValue({ + stdout: largeOutput, + stderr: "", + code: 0, + signal: null, + killed: false, + } satisfies SpawnResult); + + const result = await runHeartbeatPreHook(cfg, mockRunner); + expect(result.context).toBeDefined(); + expect(result.context?.length).toBeLessThan(largeOutput.length); + expect(result.context).toContain("...[truncated]"); + }); +}); diff --git a/src/auto-reply/heartbeat-prehook.ts b/src/auto-reply/heartbeat-prehook.ts new file mode 100644 index 000000000..5ed44dbef --- /dev/null +++ b/src/auto-reply/heartbeat-prehook.ts @@ -0,0 +1,118 @@ +import type { WarelayConfig } from "../config/config.js"; +import { danger, logVerbose } from "../globals.js"; +import { logDebug, logWarn } from "../logger.js"; +import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js"; + +export type PreHookResult = { + context?: string; + durationMs: number; + error?: string; + timedOut?: boolean; +}; + +const DEFAULT_PREHOOK_TIMEOUT_SECONDS = 30; +const MAX_CONTEXT_CHARS = 8000; + +export function buildHeartbeatPrompt( + basePrompt: string, + preHookContext?: string, +): string { + if (!preHookContext?.trim()) { + return basePrompt; + } + return `${basePrompt}\n\n---\nContext from pre-hook:\n${preHookContext.trim()}`; +} + +function capContextSize(stdout: string): string { + const trimmed = stdout.trim(); + if (trimmed.length <= MAX_CONTEXT_CHARS) { + return trimmed; + } + return `${trimmed.slice(0, MAX_CONTEXT_CHARS)}...[truncated]`; +} + +export async function runHeartbeatPreHook( + cfg: WarelayConfig, + commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout, +): Promise { + const sessionCfg = cfg.inbound?.reply?.session; + const preHookCommand = sessionCfg?.heartbeatPreHook; + + if (!preHookCommand?.length) { + return { durationMs: 0 }; + } + + const timeoutSeconds = + sessionCfg?.heartbeatPreHookTimeoutSeconds ?? + DEFAULT_PREHOOK_TIMEOUT_SECONDS; + const timeoutMs = timeoutSeconds * 1000; + const started = Date.now(); + + logVerbose(`Running heartbeat pre-hook: ${preHookCommand.join(" ")}`); + + try { + const result: SpawnResult = await commandRunner(preHookCommand, { + timeoutMs, + }); + const durationMs = Date.now() - started; + + if (result.killed || result.signal === "SIGKILL") { + const stderrPreview = result.stderr?.trim().slice(0, 200) || "(empty)"; + logWarn(`Heartbeat pre-hook timed out after ${timeoutSeconds}s`); + logDebug(`Pre-hook stderr preview: ${stderrPreview}`); + return { + durationMs, + timedOut: true, + error: `Pre-hook timed out after ${timeoutSeconds}s`, + }; + } + + if ((result.code ?? 0) !== 0) { + const stderrPreview = result.stderr?.trim().slice(0, 200) || "(empty)"; + const errorMsg = `Pre-hook exited with code ${result.code}`; + logWarn(errorMsg); + logDebug(`Pre-hook stderr preview: ${stderrPreview}`); + return { + durationMs, + error: errorMsg, + }; + } + + const stdout = result.stdout?.trim(); + logVerbose( + `Pre-hook completed in ${durationMs}ms, output length: ${stdout?.length ?? 0}`, + ); + + if (stdout) { + logDebug( + `Pre-hook output: ${stdout.slice(0, 200)}${stdout.length > 200 ? "..." : ""}`, + ); + } + + const cappedContext = stdout ? capContextSize(stdout) : undefined; + + return { + context: cappedContext || undefined, + durationMs, + }; + } catch (err) { + const durationMs = Date.now() - started; + const anyErr = err as { killed?: boolean; signal?: string }; + + if (anyErr.killed || anyErr.signal === "SIGKILL") { + return { + durationMs, + timedOut: true, + error: `Pre-hook timed out after ${timeoutSeconds}s`, + }; + } + + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(danger(`Heartbeat pre-hook failed: ${errorMsg}`)); + + return { + durationMs, + error: errorMsg, + }; + } +} diff --git a/src/commands/send.ts b/src/commands/send.ts index 45b770f74..30b99f057 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -45,7 +45,9 @@ export async function sendCommand( const ipcResult = await sendViaIpc(opts.to, opts.message, opts.media); if (ipcResult) { if (ipcResult.success) { - runtime.log(success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`)); + runtime.log( + success(`✅ Sent via relay IPC. Message ID: ${ipcResult.messageId}`), + ); if (opts.json) { runtime.log( JSON.stringify( @@ -64,7 +66,11 @@ export async function sendCommand( return; } // IPC failed but relay is running - warn and fall back - runtime.log(info(`IPC send failed (${ipcResult.error}), falling back to direct connection`)); + runtime.log( + info( + `IPC send failed (${ipcResult.error}), falling back to direct connection`, + ), + ); } // Fall back to direct connection (creates new Baileys socket) diff --git a/src/config/config.ts b/src/config/config.ts index f0e46c6c4..6d191041f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -22,6 +22,8 @@ export type SessionConfig = { sessionIntro?: string; typingIntervalSeconds?: number; heartbeatMinutes?: number; + heartbeatPreHook?: string[]; + heartbeatPreHookTimeoutSeconds?: number; }; export type LoggingConfig = { @@ -102,6 +104,8 @@ const ReplySchema = z sendSystemOnce: z.boolean().optional(), sessionIntro: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), + heartbeatPreHook: z.array(z.string()).optional(), + heartbeatPreHookTimeoutSeconds: z.number().int().positive().optional(), }) .optional(), heartbeatMinutes: z.number().int().nonnegative().optional(), diff --git a/src/twilio/heartbeat.test.ts b/src/twilio/heartbeat.test.ts index 216f759da..4abf51fc7 100644 --- a/src/twilio/heartbeat.test.ts +++ b/src/twilio/heartbeat.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; -import { HEARTBEAT_TOKEN } from "../web/auto-reply.js"; +import { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN } from "../web/auto-reply.js"; import { runTwilioHeartbeatOnce } from "./heartbeat.js"; vi.mock("./send.js", () => ({ @@ -11,15 +11,34 @@ vi.mock("../auto-reply/reply.js", () => ({ getReplyFromConfig: vi.fn(), })); +vi.mock("../auto-reply/heartbeat-prehook.js", () => ({ + runHeartbeatPreHook: vi.fn(), + buildHeartbeatPrompt: vi.fn((base: string, ctx?: string) => + ctx ? `${base}\n\n---\nContext from pre-hook:\n${ctx}` : base, + ), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: vi.fn(() => ({})), +})); + +// eslint-disable-next-line import/first +import { runHeartbeatPreHook } from "../auto-reply/heartbeat-prehook.js"; // eslint-disable-next-line import/first import { getReplyFromConfig } from "../auto-reply/reply.js"; // eslint-disable-next-line import/first import { sendMessage } from "./send.js"; -const sendMessageMock = sendMessage as unknown as vi.Mock; -const replyResolverMock = getReplyFromConfig as unknown as vi.Mock; +const sendMessageMock = sendMessage as unknown as Mock; +const replyResolverMock = getReplyFromConfig as unknown as Mock; +const runHeartbeatPreHookMock = runHeartbeatPreHook as unknown as Mock; describe("runTwilioHeartbeatOnce", () => { + beforeEach(() => { + vi.clearAllMocks(); + runHeartbeatPreHookMock.mockResolvedValue({ durationMs: 0 }); + }); + it("sends manual override body and skips resolver", async () => { sendMessageMock.mockResolvedValue({}); await runTwilioHeartbeatOnce({ @@ -72,4 +91,80 @@ describe("runTwilioHeartbeatOnce", () => { expect.anything(), ); }); + + describe("pre-hook integration", () => { + it("runs pre-hook and includes context in prompt", async () => { + runHeartbeatPreHookMock.mockResolvedValue({ + context: "You have 3 unread emails", + durationMs: 100, + }); + replyResolverMock.mockResolvedValue({ text: "ALERT!" }); + sendMessageMock.mockResolvedValue({}); + + await runTwilioHeartbeatOnce({ to: "+1555" }); + + expect(runHeartbeatPreHookMock).toHaveBeenCalled(); + expect(replyResolverMock).toHaveBeenCalledWith( + expect.objectContaining({ + Body: expect.stringContaining("Context from pre-hook"), + }), + undefined, + ); + }); + + it("skips pre-hook when skipPreHook is true", async () => { + replyResolverMock.mockResolvedValue({ text: "ALERT!" }); + sendMessageMock.mockResolvedValue({}); + + await runTwilioHeartbeatOnce({ to: "+1555", skipPreHook: true }); + + expect(runHeartbeatPreHookMock).not.toHaveBeenCalled(); + expect(replyResolverMock).toHaveBeenCalledWith( + expect.objectContaining({ + Body: HEARTBEAT_PROMPT, + }), + undefined, + ); + }); + + it("continues with basic heartbeat on pre-hook failure", async () => { + runHeartbeatPreHookMock.mockResolvedValue({ + error: "Pre-hook failed", + durationMs: 50, + }); + replyResolverMock.mockResolvedValue({ text: "ALERT!" }); + sendMessageMock.mockResolvedValue({}); + + await runTwilioHeartbeatOnce({ to: "+1555" }); + + expect(replyResolverMock).toHaveBeenCalledWith( + expect.objectContaining({ + Body: HEARTBEAT_PROMPT, + }), + undefined, + ); + expect(sendMessage).toHaveBeenCalled(); + }); + + it("uses injected config to avoid loading real config in tests", async () => { + const testConfig = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo"], + session: { + heartbeatPreHook: ["test-script"], + }, + }, + }, + }; + runHeartbeatPreHookMock.mockResolvedValue({ durationMs: 0 }); + replyResolverMock.mockResolvedValue({ text: "OK" }); + sendMessageMock.mockResolvedValue({}); + + await runTwilioHeartbeatOnce({ to: "+1555", cfg: testConfig }); + + expect(runHeartbeatPreHookMock).toHaveBeenCalledWith(testConfig); + }); + }); }); diff --git a/src/twilio/heartbeat.ts b/src/twilio/heartbeat.ts index 3eb096d6f..6391e0824 100644 --- a/src/twilio/heartbeat.ts +++ b/src/twilio/heartbeat.ts @@ -1,4 +1,9 @@ +import { + buildHeartbeatPrompt, + runHeartbeatPreHook, +} from "../auto-reply/heartbeat-prehook.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; +import { loadConfig, type WarelayConfig } from "../config/config.js"; import { danger, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; @@ -14,6 +19,8 @@ export async function runTwilioHeartbeatOnce(opts: { replyResolver?: ReplyResolver; overrideBody?: string; dryRun?: boolean; + skipPreHook?: boolean; + cfg?: WarelayConfig; }) { const { to, @@ -21,8 +28,10 @@ export async function runTwilioHeartbeatOnce(opts: { runtime = defaultRuntime, overrideBody, dryRun = false, + skipPreHook = false, } = opts; const replyResolver = opts.replyResolver ?? getReplyFromConfig; + const cfg = opts.cfg ?? loadConfig(); if (overrideBody && overrideBody.trim().length === 0) { throw new Error("Override body must be non-empty when provided."); @@ -42,9 +51,25 @@ export async function runTwilioHeartbeatOnce(opts: { return; } + // Run pre-hook unless skipped + let heartbeatPrompt = HEARTBEAT_PROMPT; + if (!skipPreHook) { + const preHookResult = await runHeartbeatPreHook(cfg); + if (preHookResult.error) { + logInfo( + `Pre-hook failed: ${preHookResult.error} (continuing)`, + runtime, + ); + } + heartbeatPrompt = buildHeartbeatPrompt( + HEARTBEAT_PROMPT, + preHookResult.context, + ); + } + const replyResult = await replyResolver( { - Body: HEARTBEAT_PROMPT, + Body: heartbeatPrompt, From: to, To: to, MessageSid: undefined, diff --git a/src/twilio/send.ts b/src/twilio/send.ts index c6ca43405..e89cb9403 100644 --- a/src/twilio/send.ts +++ b/src/twilio/send.ts @@ -12,7 +12,10 @@ const failureTerminalStatuses = new Set(["failed", "undelivered", "canceled"]); const TWILIO_MAX_CHARS = 1600; // Split long messages into chunks, preferring to break at paragraph/sentence boundaries -export function splitMessage(text: string, maxChars = TWILIO_MAX_CHARS): string[] { +export function splitMessage( + text: string, + maxChars = TWILIO_MAX_CHARS, +): string[] { if (text.length <= maxChars) { return [text]; } diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index bfbfb5449..d0e67d2f8 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1,9 +1,4 @@ // Import test-helpers FIRST to set up mocks before other imports -import { - resetBaileysMocks, - resetLoadConfigMock, - setLoadConfigMock, -} from "./test-helpers.js"; import crypto from "node:crypto"; import fs from "node:fs/promises"; @@ -11,10 +6,9 @@ import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - import type { WarelayConfig } from "../config/config.js"; -import * as commandQueue from "../process/command-queue.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; +import * as commandQueue from "../process/command-queue.js"; import { HEARTBEAT_PROMPT, HEARTBEAT_TOKEN, @@ -25,6 +19,11 @@ import { stripHeartbeatToken, } from "./auto-reply.js"; import type { sendMessageWeb } from "./outbound.js"; +import { + resetBaileysMocks, + resetLoadConfigMock, + setLoadConfigMock, +} from "./test-helpers.js"; const makeSessionStore = async ( entries: Record = {}, @@ -533,9 +532,7 @@ describe("web auto-reply", () => { const storePath = path.join(tmpDir, "sessions.json"); await fs.writeFile(storePath, JSON.stringify({})); - const queueSpy = vi - .spyOn(commandQueue, "getQueueSize") - .mockReturnValue(2); + const queueSpy = vi.spyOn(commandQueue, "getQueueSize").mockReturnValue(2); const replyResolver = vi.fn(); const listenerFactory = vi.fn(async () => { const onClose = new Promise(() => { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index cc965dab0..4770e530e 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,3 +1,7 @@ +import { + buildHeartbeatPrompt, + runHeartbeatPreHook, +} from "../auto-reply/heartbeat-prehook.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; @@ -12,13 +16,13 @@ import { import { danger, info, isVerbose, logVerbose, success } from "../globals.js"; import { logInfo } from "../logger.js"; import { getChildLogger } from "../logging.js"; +import { getQueueSize } from "../process/command-queue.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.js"; import { monitorWebInbox } from "./inbound.js"; import { sendViaIpc, startIpcServer, stopIpcServer } from "./ipc.js"; import { loadWebMedia } from "./media.js"; import { sendMessageWeb } from "./outbound.js"; -import { getQueueSize } from "../process/command-queue.js"; import { computeBackoff, newConnectionId, @@ -105,6 +109,7 @@ export async function runWebHeartbeatOnce(opts: { sessionId?: string; overrideBody?: string; dryRun?: boolean; + skipPreHook?: boolean; }) { const { cfg: cfgOverride, @@ -113,6 +118,7 @@ export async function runWebHeartbeatOnce(opts: { sessionId, overrideBody, dryRun = false, + skipPreHook = false, } = opts; const _runtime = opts.runtime ?? defaultRuntime; const replyResolver = opts.replyResolver ?? getReplyFromConfig; @@ -181,9 +187,39 @@ export async function runWebHeartbeatOnce(opts: { return; } + // Run pre-hook unless skipped or overrideBody provided + let heartbeatPrompt = HEARTBEAT_PROMPT; + if (!skipPreHook) { + const preHookResult = await runHeartbeatPreHook(cfg); + if (preHookResult.error) { + heartbeatLogger.warn( + { + to, + error: preHookResult.error, + durationMs: preHookResult.durationMs, + timedOut: preHookResult.timedOut, + }, + "heartbeat pre-hook failed (continuing with basic heartbeat)", + ); + } else if (preHookResult.context) { + heartbeatLogger.info( + { + to, + contextLength: preHookResult.context.length, + durationMs: preHookResult.durationMs, + }, + "heartbeat pre-hook succeeded", + ); + } + heartbeatPrompt = buildHeartbeatPrompt( + HEARTBEAT_PROMPT, + preHookResult.context, + ); + } + const replyResult = await replyResolver( { - Body: HEARTBEAT_PROMPT, + Body: heartbeatPrompt, From: to, To: to, MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId, @@ -647,7 +683,11 @@ export async function monitorWebProvider( } // Apply response prefix if configured (skip for HEARTBEAT_OK to preserve exact match) const responsePrefix = cfg.inbound?.responsePrefix; - if (responsePrefix && replyResult.text && replyResult.text.trim() !== HEARTBEAT_TOKEN) { + if ( + responsePrefix && + replyResult.text && + replyResult.text.trim() !== HEARTBEAT_TOKEN + ) { // Only add prefix if not already present if (!replyResult.text.startsWith(responsePrefix)) { replyResult.text = `${responsePrefix} ${replyResult.text}`; @@ -711,7 +751,12 @@ export async function monitorWebProvider( mediaBuffer = media.buffer; mediaType = media.contentType; } - const result = await listener.sendMessage(to, message, mediaBuffer, mediaType); + const result = await listener.sendMessage( + to, + message, + mediaBuffer, + mediaType, + ); // Add to echo detection so we don't process our own message if (message) { recentlySent.add(message); @@ -720,7 +765,10 @@ export async function monitorWebProvider( if (firstKey) recentlySent.delete(firstKey); } } - logInfo(`📤 IPC send to ${to}: ${message.substring(0, 50)}...`, runtime); + logInfo( + `📤 IPC send to ${to}: ${message.substring(0, 50)}...`, + runtime, + ); // Show typing indicator after send so user knows more may be coming try { await listener.sendComposingTo(to); @@ -764,7 +812,10 @@ export async function monitorWebProvider( // Warn if no messages in 30+ minutes if (minutesSinceLastMessage && minutesSinceLastMessage > 30) { - heartbeatLogger.warn(logData, "⚠️ web relay heartbeat - no messages in 30+ minutes"); + heartbeatLogger.warn( + logData, + "⚠️ web relay heartbeat - no messages in 30+ minutes", + ); } else { heartbeatLogger.info(logData, "web relay heartbeat"); } @@ -775,7 +826,9 @@ export async function monitorWebProvider( if (lastMessageAt) { const timeSinceLastMessage = Date.now() - lastMessageAt; if (timeSinceLastMessage > MESSAGE_TIMEOUT_MS) { - const minutesSinceLastMessage = Math.floor(timeSinceLastMessage / 60000); + const minutesSinceLastMessage = Math.floor( + timeSinceLastMessage / 60000, + ); heartbeatLogger.warn( { connectionId, @@ -876,9 +929,37 @@ export async function monitorWebProvider( "reply heartbeat start", ); } + + // Run pre-hook to gather context + const preHookResult = await runHeartbeatPreHook(cfg); + if (preHookResult.error) { + heartbeatLogger.warn( + { + connectionId, + error: preHookResult.error, + durationMs: preHookResult.durationMs, + timedOut: preHookResult.timedOut, + }, + "heartbeat pre-hook failed (continuing with basic heartbeat)", + ); + } else if (preHookResult.context) { + heartbeatLogger.info( + { + connectionId, + contextLength: preHookResult.context.length, + durationMs: preHookResult.durationMs, + }, + "heartbeat pre-hook succeeded", + ); + } + const heartbeatPrompt = buildHeartbeatPrompt( + HEARTBEAT_PROMPT, + preHookResult.context, + ); + const replyResult = await (replyResolver ?? getReplyFromConfig)( { - Body: HEARTBEAT_PROMPT, + Body: heartbeatPrompt, From: lastInboundMsg.from, To: lastInboundMsg.to, MessageSid: snapshot.entry?.sessionId, @@ -930,7 +1011,11 @@ export async function monitorWebProvider( // Apply response prefix if configured (same as regular messages) let finalText = stripped.text; const responsePrefix = cfg.inbound?.responsePrefix; - if (responsePrefix && finalText && !finalText.startsWith(responsePrefix)) { + if ( + responsePrefix && + finalText && + !finalText.startsWith(responsePrefix) + ) { finalText = `${responsePrefix} ${finalText}`; } diff --git a/src/web/inbound.ts b/src/web/inbound.ts index 9d6a4df6a..f10a48247 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -103,8 +103,13 @@ export async function monitorWebInbox(options: { const isSamePhone = from === selfE164; if (!isSamePhone && Array.isArray(allowFrom) && allowFrom.length > 0) { - if (!allowFrom.includes("*") && !allowFrom.map(normalizeE164).includes(from)) { - logVerbose(`Blocked unauthorized sender ${from} (not in allowFrom list)`); + if ( + !allowFrom.includes("*") && + !allowFrom.map(normalizeE164).includes(from) + ) { + logVerbose( + `Blocked unauthorized sender ${from} (not in allowFrom list)`, + ); continue; // Skip processing entirely } } diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index b696d2513..829cade1f 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -251,7 +251,11 @@ describe("web monitor inbox", () => { type: "notify", messages: [ { - key: { id: "unauth1", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + key: { + id: "unauth1", + fromMe: false, + remoteJid: "999@s.whatsapp.net", + }, message: { conversation: "unauthorized message" }, messageTimestamp: 1_700_000_000, },