# Competitor Intel Monitor

Scheduled competitor URL monitor that diffs pages and delivers scored Slack or email digests.

- Install: `npx shadcn@latest add @evex/competitor-intel-monitor`
- Category: research
- Author: [TommyBez](https://www.evex.sh/authors/TommyBez)
- Updated: 2026-09-08
- Dependencies: @vercel/connect@^0.2.6, eve@^0.47.5, resend@^6.14.0, zod@4.3.6
- Web page: https://www.evex.sh/agents/competitor-intel-monitor
- This document: https://www.evex.sh/agents/competitor-intel-monitor.md

## Overview

Competitor Intel Monitor is a scheduled eve agent that re-reads a list of competitor pages you already chose. On each cron tick it checks robots.txt, pulls the HTML that is allowed, and compares normalized text to the last snapshot in a JSON file or Upstash Redis. You configure the list and thresholds once, then leave it running.

You interact with it through environment variables and your inbox or Slack. Set COMPETITOR_INTEL_URLS or a JSON or text file, pick Slack, Resend, or both, and trigger watch-competitor-pages. The agent previews every digest and only sends when confirmSend is true and a date-derived idempotency key is present.

It is useful when you want a quiet baseline plus alerts that ignore tiny edits. First-seen URLs are stored without a ping. Later diffs report a score from changed character weight, and only rows that clear both gates go out. Robots disallows and missing config stop that path instead of inventing a page.

## How it works

1. On the watch-competitor-pages schedule (cron from COMPETITOR_INTEL_CRON, default 08:00 UTC), the agent loads the competitor-intel-watch skill and, before any email, the email-best-practices skill.
2. It calls load_watch_config to read the https URL list, alert gates, store kind, and whether Slack or email is configured, and stops if COMPETITOR_INTEL_URLS or delivery settings are missing.
3. For each listed URL it calls fetch_competitor_page, which loads that origin's robots.txt with EveCompetitorIntelMonitor/1.0 and skips the page when robots disallows the path or the robots file cannot be reached.
4. Each successful fetch goes to diff_page_snapshot, which compares normalized text to the stored snapshot, writes the new snapshot, and returns score, changedChars, and clearsThreshold. First-seen URLs are baselines with score 0.
5. If any row clears both COMPETITOR_INTEL_ALERT_MIN_SCORE and COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS, the agent calls preview_digest, then send_digest with confirmSend true and an idempotency key such as competitor-intel-monitor-YYYY-MM-DD.
6. Seven evals cover the schedule path, robots skips, below-threshold silence, digest preview, send confirmation, missing config, and a failed send that must not be reported as delivered.

## Use cases

### Pricing page drift

Point the watch list at two competitor pricing URLs. After the first baseline run, a later seat-price rewrite that clears the score and character gates lands in Slack or email with the excerpt from diff_page_snapshot.

### Changelog without the noise

Watch a public changelog. A one-word typo stays under COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS and is stored only. A new version notes section that clears both gates is the row that gets previewed.

### Robots-respecting research crawl

A competitor blocks /compare in robots.txt. fetch_competitor_page returns blockedByRobots for that path, the agent skips it, and send_digest is never called for an invented compare table.

### Team alias on a weekday cron

Set COMPETITOR_INTEL_CRON to weekday mornings and COMPETITOR_INTEL_DIGEST_TO to a team alias. Operators get one Resend HTML mail with the URLs that cleared the gates, not a ping for every tiny HTML shuffle.

## Requirements

- `COMPETITOR_INTEL_URLS`: Comma-separated https URLs to watch. Required unless COMPETITOR_INTEL_URLS_FILE lists at least one https URL. Non-https values are dropped.
- `COMPETITOR_INTEL_URLS_FILE`: Optional JSON object with urls and alert, or a text file with one URL per line. Those URLs are unioned with COMPETITOR_INTEL_URLS.
- `COMPETITOR_INTEL_CRON`: 5-field cron for watch-competitor-pages. Defaults to 0 8 * * * (08:00 UTC on Vercel).
- `COMPETITOR_INTEL_ALERT_MIN_SCORE`: Minimum score from 0 to 100. Score is round(100 * changedChars / max(beforeLength, afterLength, 1)). Defaults to 25.
- `COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS`: Minimum added-plus-removed character weight before a change can clear the gate. Defaults to 40.
- `COMPETITOR_INTEL_STORE_PATH`: JSON snapshot file path. Defaults to .data/competitor-intel-store.json. Use a durable volume if the app filesystem is ephemeral.
- `UPSTASH_REDIS_REST_URL`: Optional Upstash Redis REST URL. When set with UPSTASH_REDIS_REST_TOKEN, snapshots are stored in Redis instead of the file.
- `UPSTASH_REDIS_REST_TOKEN`: Optional Upstash Redis REST token used with UPSTASH_REDIS_REST_URL.
- `COMPETITOR_INTEL_USER_AGENT`: User-Agent for page and robots.txt fetches. Defaults to EveCompetitorIntelMonitor/1.0.
- `COMPETITOR_INTEL_SLACK_CONNECT_UID`: Optional Vercel Connect Slack connector UID for the Eve Slack channel. Leave empty to skip Slack. At least one of Slack Connect or a complete email trio is required to send.
- `COMPETITOR_INTEL_SLACK_CHANNEL_ID`: Optional Slack channel id for the digest. Leave empty to skip Slack. Both this and COMPETITOR_INTEL_SLACK_CONNECT_UID must be set to post.
- `@vercel/connect`: Runtime dependency that supplies Slack credentials through connectSlackCredentials. send_digest posts with Eve callSlackApi after approval, not an incoming webhook.
- `RESEND_API_KEY`: Resend API key used by send_digest when email recipients are configured. The idempotency key is forwarded to Resend.
- `COMPETITOR_INTEL_DIGEST_FROM`: Verified Resend sender for the HTML digest. Recipients and sender cannot be overridden through tool input.
- `COMPETITOR_INTEL_DIGEST_TO`: Comma-separated recipient addresses. Optional COMPETITOR_INTEL_DIGEST_SUBJECT defaults to Competitor intel digest.

## FAQ

### How do I install and run a watch?

Install with npx shadcn@latest add @evex/competitor-intel-monitor, copy .env.example, set at least one https URL plus Slack or Resend, then POST to /eve/v1/dev/schedules/watch-competitor-pages while iterating.

### Will the first run email or Slack me?

No. diff_page_snapshot stores a first-seen URL as a baseline with score 0 and clearsThreshold false. Only a later run whose score and changedChars both clear the gates can call preview_digest and send_digest.

### What if robots.txt blocks a URL?

fetch_competitor_page returns blockedByRobots and skips the HTTP body. The agent records the skip, does not invent text, and does not send a digest for that URL.

### How is the score calculated?

changedChars is the character weight of words added or removed after HTML is stripped. score is round(100 * changedChars / max(beforeLength, afterLength, 1)), capped at 100. Both that score and minChangedChars must pass.

### Can a replayed run send twice?

send_digest refuses unless confirmSend is true, and it requires a stable idempotency key. Successful sends are cached on that key and the same key is sent to Resend, so a replayed eve step does not duplicate email.

## Files installed

- `.env.example`
- `agent/agent.ts`
- `agent/channels/slack.ts`
- `agent/instructions.md`
- `agent/lib/digest.ts`
- `agent/lib/deliver-digest.ts`
- `agent/lib/fetch-page.ts`
- `agent/lib/page-diff.ts`
- `agent/lib/robots.ts`
- `agent/lib/slack-post.ts`
- `agent/lib/snapshot-store.ts`
- `agent/lib/thresholds.ts`
- `agent/lib/watch-config.ts`
- `agent/schedules/watch-competitor-pages.ts`
- `agent/skills/competitor-intel-watch/SKILL.md`
- `agent/skills/email-best-practices/references/accessibility.md`
- `agent/skills/email-best-practices/references/sending-reliability.md`
- `agent/skills/email-best-practices/SKILL.md`
- `agent/tools/diff_page_snapshot.ts`
- `agent/tools/fetch_competitor_page.ts`
- `agent/tools/load_watch_config.ts`
- `agent/tools/preview_digest.ts`
- `agent/tools/send_digest.ts`
- `evals/digest-preview.eval.ts`
- `evals/evals.config.ts`
- `evals/failed-send-not-delivered.eval.ts`
- `evals/missing-config-does-not-send.eval.ts`
- `evals/robots-blocks-fetch.eval.ts`
- `evals/schedule-watch.eval.ts`
- `evals/send-confirmation.eval.ts`
- `evals/threshold-gating.eval.ts`
- `agent/README.md`

## File contents

### `.env.example`

```
# Comma-separated competitor page URLs to watch.
COMPETITOR_INTEL_URLS=

# Optional config file. JSON ({ "urls": [], "alert": { "minScore": 25, "minChangedChars": 40 } })
# or a text file with one URL per line. URLs here are unioned with COMPETITOR_INTEL_URLS.
COMPETITOR_INTEL_URLS_FILE=

# Recurring watch cron (UTC on Vercel). Default daily 08:00.
COMPETITOR_INTEL_CRON="0 8 * * *"

# Alert thresholds. A change is delivered only when both gates pass.
COMPETITOR_INTEL_ALERT_MIN_SCORE=25
COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS=40

# Persistent snapshot store (JSON file). Use a durable volume in production.
COMPETITOR_INTEL_STORE_PATH=.data/competitor-intel-store.json

# Optional Upstash Redis REST credentials. When both are set, snapshots live in Redis.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# User-Agent sent on page and robots.txt fetches.
COMPETITOR_INTEL_USER_AGENT=EveCompetitorIntelMonitor/1.0

# Optional Slack via Vercel Connect (eve add channel/slack).
# Create a Slack connector and attach triggers to /eve/v1/slack.
# Leave either empty to skip Slack delivery.
COMPETITOR_INTEL_SLACK_CONNECT_UID=
COMPETITOR_INTEL_SLACK_CHANNEL_ID=

# Email digest through Resend. Leave empty to skip email delivery.
RESEND_API_KEY=
COMPETITOR_INTEL_DIGEST_FROM=
COMPETITOR_INTEL_DIGEST_TO=
COMPETITOR_INTEL_DIGEST_SUBJECT="Competitor intel digest"

```

### `agent/agent.ts`

```ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "openai/gpt-5.4-mini",
});

```

### `agent/channels/slack.ts`

```ts
import { connectSlackCredentials } from "@vercel/connect/eve";
import { slackChannel } from "eve/channels/slack";

const SLACK_CONNECT_UID =
  process.env.COMPETITOR_INTEL_SLACK_CONNECT_UID || "slack/competitor-intel-monitor";

export default slackChannel({
  credentials: connectSlackCredentials(SLACK_CONNECT_UID),
});

```

### `agent/instructions.md`

```md
# Mission
Watch a configured list of competitor pages on a cron schedule. Fetch each URL only when robots.txt allows it, diff the page against a persistent snapshot store, and deliver a Slack or email digest only for scored changes that clear the alert thresholds.

# Workflow
1. Load the competitor-intel-watch skill for the watch, score, and delivery rules. Load the email-best-practices skill before drafting or sending email.
2. Call `load_watch_config` first. Use only the returned URL list, cron, and thresholds. If `missingEnv` is set or the URL list is empty, stop and report the missing configuration. Do not invent URLs, scores, excerpts, recipients, or Slack channels.
3. For each configured URL, call `fetch_competitor_page`. That tool checks robots.txt before the HTTP fetch. When it returns `blockedByRobots` or `ok: false`, record the skip and continue. Never fetch a URL that is not on the watch list.
4. For each successful fetch, call `diff_page_snapshot` with the tool's `text`, `hash`, and `fetchedAt`. A first-seen URL is a committed baseline (`isBaseline: true`) and is not an alert. Threshold-clearing changes stay pending until `send_digest` succeeds.
5. Keep only results where `clearsThreshold` is true. The score and `changedChars` come from the tool. Do not invent a different score or metric.
6. If nothing cleared the thresholds, say so and do not call `send_digest`.
7. If at least one change cleared the thresholds, call `preview_digest` with those changes. Recipients and Slack Connect settings come from configuration — never pass `to`, `from`, a Connect UID, or a channel id to a tool.
8. To send for real, call `send_digest` with `confirmSend: true`, the `idempotencyKey` returned by `preview_digest`, and the `runDate` returned by `preview_digest`. That key includes the run date and the logical change set. Never call `send_digest` without an idempotency key. Reuse the same key and `runDate` when retrying that same send so Slack and email stay on the same date. `send_digest` always pauses for Eve human approval before Slack or Resend; `confirmSend` is not that approval. If the tool returns `sent: false` with an `error`, report that the digest was not delivered and do not retry in the same run.

# Output contract
Return:
- the watch list and thresholds from `load_watch_config`
- per-URL fetch and robots outcomes
- scored diffs, including baselines and below-threshold changes
- the preview from `preview_digest` when an alert exists
- the send result, including the idempotency key, when `send_digest` was called
- any missing configuration that blocked a step

# Guardrails
- Respect robots.txt. Do not work around a disallow or an unreachable robots.txt.
- Do not fabricate URLs, hashes, excerpts, or scores. Every citation must come from a tool result.
- Do not send a digest when no change cleared the thresholds.
- If a tool reports `authRequired`, `notConfigured`, or `notOnWatchList`, stop that path and report it.

```

### `agent/lib/digest.ts`

```ts
import { createHash } from "node:crypto";

import type { ScoredChange } from "./thresholds.js";
import type { WatchConfig } from "./watch-config.js";

export type DigestDraft = {
  readonly subject: string;
  readonly html: string;
  readonly text: string;
  readonly slackText: string;
  readonly changeCount: number;
};

const escapeHtml = (value: string): string =>
  value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");

const changeLine = (change: ScoredChange): string =>
  `${change.url} — score ${change.score}/100, ${change.changedChars} changed characters. ${change.excerpt}`;

export const buildDigestDraft = (
  changes: readonly ScoredChange[],
  config: Pick<WatchConfig, "digest">,
  runDate: string,
): DigestDraft => {
  const subject = `${config.digest.subject} — ${runDate}`;
  const intro =
    changes.length === 1
      ? `1 competitor page cleared the alert thresholds on ${runDate}.`
      : `${changes.length} competitor pages cleared the alert thresholds on ${runDate}.`;

  const rows = changes
    .map((change) => {
      const excerpt = escapeHtml(change.excerpt || "No excerpt.");
      return `<tr>
  <td><a href="${escapeHtml(change.url)}">${escapeHtml(change.url)}</a></td>
  <td>${change.score}</td>
  <td>${change.changedChars}</td>
  <td>${excerpt}</td>
</tr>`;
    })
    .join("\n");

  const html = `<!doctype html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8" />
    <title>${escapeHtml(subject)}</title>
  </head>
  <body>
    <div lang="en" dir="ltr">
      <h1>${escapeHtml(subject)}</h1>
      <p>${escapeHtml(intro)}</p>
      <table>
        <thead>
          <tr>
            <th>URL</th>
            <th>Score</th>
            <th>Changed characters</th>
            <th>Excerpt</th>
          </tr>
        </thead>
        <tbody>
${rows}
        </tbody>
      </table>
    </div>
  </body>
</html>`;

  const text = [intro, ...changes.map((change) => changeLine(change))].join("\n\n");
  const slackText = [`Competitor intel digest (${runDate})`, intro, ...changes.map((change) => `• ${changeLine(change)}`)].join(
    "\n",
  );

  return {
    subject,
    html,
    text,
    slackText,
    changeCount: changes.length,
  };
};

export const utcDateStamp = (now: Date = new Date()): string => now.toISOString().slice(0, 10);

export const buildDigestIdempotencyKey = (
  changes: readonly Pick<ScoredChange, "url" | "fetchedAt" | "score" | "changedChars">[],
  runDate: string,
): string => {
  const fingerprint = changes
    .map((change) => `${change.url}\0${change.fetchedAt}\0${change.score}\0${change.changedChars}`)
    .sort()
    .join("\n");
  const digest = createHash("sha256").update(fingerprint).digest("hex").slice(0, 16);
  return `competitor-intel-monitor-${runDate}-${digest}`;
};

```

### `agent/lib/deliver-digest.ts`

```ts
import { buildDigestDraft, utcDateStamp, type DigestDraft } from "./digest.js";
import type { DeliveryState, SnapshotStore } from "./snapshot-store.js";
import { postSlackDigest, type SlackChannelSend } from "./slack-post.js";
import type { ScoredChange } from "./thresholds.js";
import type { WatchConfig } from "./watch-config.js";

export type EmailSendResult = {
  readonly id?: string;
  readonly error?: { readonly message: string; readonly name: string };
};

export type EmailSender = (input: {
  readonly from: string;
  readonly to: readonly string[];
  readonly subject: string;
  readonly html: string;
  readonly text: string;
  readonly idempotencyKey: string;
}) => Promise<EmailSendResult>;

export type SlackPoster = SlackChannelSend;

export type DeliverDigestInput = {
  readonly store: SnapshotStore;
  readonly alerts: readonly ScoredChange[];
  readonly digest: WatchConfig["digest"];
  readonly slackConnectUid?: string;
  readonly slackChannelId?: string;
  readonly runDate?: string;
  readonly idempotencyKey: string;
  readonly sendEmail?: EmailSender;
  readonly postSlack?: SlackPoster;
};

export type DeliverDigestResult = {
  readonly sent: boolean;
  readonly replayed?: boolean;
  readonly idempotencyKey: string;
  readonly slackSent?: boolean;
  readonly slackUncertain?: boolean;
  readonly emailMessageId?: string;
  readonly changeCount?: number;
  readonly runDate?: string;
  readonly inProgress?: boolean;
  readonly channel?: "slack" | "email" | "snapshots";
  readonly error?: { readonly message: string; readonly name: string };
};

export const deliveryChannelsComplete = (
  state: DeliveryState | null,
  slackConfigured: boolean,
  emailConfigured: boolean,
): boolean => {
  if (!state) {
    return false;
  }
  return (!slackConfigured || state.slackSent) && (!emailConfigured || Boolean(state.emailMessageId));
};

const uniqueUrls = (urls: readonly string[]): readonly string[] => [...new Set(urls)];

const withCommittedUrl = (state: DeliveryState, url: string): DeliveryState => {
  const committedUrls = uniqueUrls([...(state.committedUrls ?? []), url]);
  const alertUrls = state.alertUrls ?? [];
  const commitsDone = alertUrls.every((alertUrl) => committedUrls.includes(alertUrl));
  return {
    ...state,
    committedUrls,
    status: commitsDone && (state.slackSent || Boolean(state.emailMessageId)) ? "complete" : state.status,
  };
};

export const resumeSnapshotCommits = async (
  store: SnapshotStore,
  idempotencyKey: string,
  state: DeliveryState,
  urls: readonly string[],
): Promise<DeliveryState> => {
  const alertUrls = uniqueUrls(state.alertUrls ?? urls);
  let current: DeliveryState = {
    ...state,
    alertUrls,
  };
  const committed = new Set(current.committedUrls ?? []);
  for (const url of alertUrls) {
    if (committed.has(url)) {
      continue;
    }
    await store.commitPending(url);
    current = withCommittedUrl(current, url);
    await store.setDelivery(idempotencyKey, current);
    committed.add(url);
  }
  return current;
};

const persistDelivery = async (
  store: SnapshotStore,
  idempotencyKey: string,
  state: DeliveryState,
): Promise<DeliveryState> => {
  await store.setDelivery(idempotencyKey, state);
  return state;
};

export const deliverCompetitorDigest = async ({
  store,
  alerts,
  digest,
  slackConnectUid,
  slackChannelId,
  runDate,
  idempotencyKey,
  sendEmail,
  postSlack = postSlackDigest,
}: DeliverDigestInput): Promise<DeliverDigestResult> => {
  const slackConfigured = Boolean(slackConnectUid && slackChannelId);
  const emailConfigured = Boolean(digest.from && digest.to.length > 0 && sendEmail);
  const cached = await store.getDelivery(idempotencyKey);
  const resolvedDate = cached?.runDate ?? runDate ?? utcDateStamp();
  const alertUrls = uniqueUrls(cached?.alertUrls ?? alerts.map((alert) => alert.url));
  const draft: DigestDraft = buildDigestDraft(alerts, { digest }, resolvedDate);

  const finish = async (
    state: DeliveryState,
    extras: Omit<DeliverDigestResult, "idempotencyKey" | "runDate">,
  ): Promise<DeliverDigestResult> => {
    try {
      const committed = await resumeSnapshotCommits(store, idempotencyKey, state, alertUrls);
      return {
        ...extras,
        sent: extras.sent,
        idempotencyKey,
        runDate: committed.runDate ?? resolvedDate,
        slackSent: committed.slackSent,
        emailMessageId: committed.emailMessageId,
      };
    } catch (error) {
      return {
        sent: false,
        idempotencyKey,
        runDate: resolvedDate,
        slackSent: state.slackSent,
        emailMessageId: state.emailMessageId,
        channel: "snapshots",
        error: {
          name: "snapshot_commit_failed",
          message: error instanceof Error ? error.message : "Failed to commit pending snapshots.",
        },
      };
    }
  };

  if (cached?.slackUncertain && slackConfigured && !cached.slackSent) {
    return {
      sent: false,
      idempotencyKey,
      runDate: resolvedDate,
      slackSent: false,
      slackUncertain: true,
      error: {
        name: "slack_delivery_uncertain",
        message:
          "A previous Slack attempt may have been delivered. This key will not post to Slack again.",
      },
    };
  }

  if (deliveryChannelsComplete(cached, slackConfigured, emailConfigured) && cached) {
    return finish(cached, { sent: true, replayed: true, changeCount: draft.changeCount });
  }

  const initial: DeliveryState = {
    status: "in_progress",
    slackSent: cached?.slackSent ?? false,
    slackUncertain: cached?.slackUncertain,
    emailMessageId: cached?.emailMessageId,
    runDate: resolvedDate,
    alertUrls,
    committedUrls: cached?.committedUrls ?? [],
    claimedAt: new Date().toISOString(),
    claimOwner: String(process.pid),
  };
  const claim = await store.claimDelivery(idempotencyKey, initial);
  let state = claim.state;

  if (claim.inProgress && slackConfigured && !state.slackSent) {
    return {
      sent: false,
      idempotencyKey,
      runDate: resolvedDate,
      inProgress: true,
      error: {
        name: "delivery_in_progress",
        message: "Another send already claimed this idempotency key.",
      },
    };
  }

  let slackSent = Boolean(state.slackSent);
  let emailMessageId = state.emailMessageId;

  if (slackConnectUid && slackChannelId && !slackSent && !state.slackUncertain) {
    if (!claim.acquired) {
      return {
        sent: false,
        idempotencyKey,
        runDate: resolvedDate,
        inProgress: true,
        error: {
          name: "delivery_in_progress",
          message: "Another send already claimed this idempotency key.",
        },
      };
    }
    try {
      const slackResponse = await postSlack({
        connectUid: slackConnectUid,
        channelId: slackChannelId,
        text: draft.slackText,
      });
      if (!slackResponse.ok) {
        await persistDelivery(store, idempotencyKey, {
          ...state,
          slackSent: false,
          claimedAt: undefined,
          claimOwner: undefined,
        });
        return {
          sent: false,
          idempotencyKey,
          runDate: resolvedDate,
          channel: "slack",
          error: {
            message: slackResponse.error ?? "Slack chat.postMessage failed.",
            name: "slack_channel_failed",
          },
        };
      }
      slackSent = true;
      state = await persistDelivery(store, idempotencyKey, {
        ...state,
        slackSent: true,
        runDate: resolvedDate,
        alertUrls,
      });
    } catch {
      state = await persistDelivery(store, idempotencyKey, {
        ...state,
        slackSent: false,
        slackUncertain: true,
        runDate: resolvedDate,
        alertUrls,
      });
      return {
        sent: false,
        idempotencyKey,
        runDate: resolvedDate,
        slackSent: false,
        slackUncertain: true,
        channel: "slack",
        error: {
          name: "slack_delivery_uncertain",
          message:
            "Slack channel send failed after the request may have been delivered.",
        },
      };
    }
  }

  if (emailConfigured && digest.from && sendEmail && !emailMessageId) {
    const emailResult = await sendEmail({
      from: digest.from,
      to: digest.to,
      subject: draft.subject,
      html: draft.html,
      text: draft.text,
      idempotencyKey,
    });
    if (emailResult.error) {
      return {
        sent: false,
        idempotencyKey,
        runDate: resolvedDate,
        slackSent,
        channel: "email",
        error: { message: emailResult.error.message, name: emailResult.error.name },
      };
    }
    emailMessageId = emailResult.id;
    state = await persistDelivery(store, idempotencyKey, {
      ...state,
      slackSent,
      emailMessageId,
      runDate: resolvedDate,
      alertUrls,
    });
  }

  return finish(
    {
      ...state,
      slackSent,
      emailMessageId,
      runDate: resolvedDate,
      alertUrls,
    },
    { sent: true, changeCount: draft.changeCount },
  );
};

```

### `agent/lib/fetch-page.ts`

```ts
import { lookup as dnsLookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";

import { isHttpsUrl } from "./watch-config.js";
import { isUrlAllowedByRobots, parseRobotsTxt, type RobotsTxt } from "./robots.js";

export type FetchImpl = (
  input: string,
  init?: {
    headers?: Record<string, string>
    redirect?: "follow" | "error" | "manual"
    signal?: AbortSignal
  },
) => Promise<Response>;

export type HostLookup = (hostname: string) => Promise<readonly string[]>;

export type RobotsDecision =
  | { readonly ok: true; readonly robots: RobotsTxt; readonly status: number }
  | { readonly ok: false; readonly reason: "robots-unreachable" | "invalid-url"; readonly status?: number };

export type FetchedPage =
  | {
      readonly ok: true;
      readonly url: string;
      readonly finalUrl: string;
      readonly status: number;
      readonly contentType: string;
      readonly body: string;
    }
  | {
      readonly ok: false;
      readonly url: string;
      readonly blockedByRobots: true;
      readonly robotsStatus?: number;
      readonly matchedRule?: { readonly type: "allow" | "disallow"; readonly path: string };
      readonly reason: "robots-disallow" | "robots-unreachable" | "invalid-url";
    }
  | {
      readonly ok: false;
      readonly url: string;
      readonly blockedByRobots: false;
      readonly status?: number;
      readonly reason: "http-error" | "invalid-url" | "not-https" | "blocked-destination";
    };

export type FetchPageOptions = {
  readonly lookup?: HostLookup;
  readonly timeoutMs?: number;
};

const FETCH_TIMEOUT_MS = 15_000;
const MAX_REDIRECTS = 5;
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const robotsCache = new Map<string, RobotsDecision>();

const reservedDestinations = new BlockList();
reservedDestinations.addSubnet("0.0.0.0", 8, "ipv4");
reservedDestinations.addSubnet("10.0.0.0", 8, "ipv4");
reservedDestinations.addSubnet("100.64.0.0", 10, "ipv4");
reservedDestinations.addSubnet("127.0.0.0", 8, "ipv4");
reservedDestinations.addSubnet("169.254.0.0", 16, "ipv4");
reservedDestinations.addSubnet("172.16.0.0", 12, "ipv4");
reservedDestinations.addSubnet("192.0.0.0", 24, "ipv4");
reservedDestinations.addSubnet("192.168.0.0", 16, "ipv4");
reservedDestinations.addSubnet("198.18.0.0", 15, "ipv4");
reservedDestinations.addSubnet("224.0.0.0", 4, "ipv4");
reservedDestinations.addSubnet("240.0.0.0", 4, "ipv4");
reservedDestinations.addAddress("::", "ipv6");
reservedDestinations.addAddress("::1", "ipv6");
reservedDestinations.addSubnet("fe80::", 10, "ipv6");
reservedDestinations.addSubnet("fc00::", 7, "ipv6");
reservedDestinations.addSubnet("ff00::", 8, "ipv6");

export const isPrivateIp = (address: string): boolean => {
  const version = isIP(address);
  if (version === 4) {
    return reservedDestinations.check(address, "ipv4");
  }
  if (version === 6) {
    return reservedDestinations.check(address, "ipv6");
  }
  return false;
};

export const isBlockedHostname = (hostname: string): boolean => {
  const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
  if (host === "localhost" || host === "localhost." || host.endsWith(".localhost") || host.endsWith(".local")) {
    return true;
  }
  return isIP(host) === 4 || isIP(host) === 6 ? isPrivateIp(host) : false;
};

export const defaultHostLookup: HostLookup = async (hostname) => {
  const records = await dnsLookup(hostname, { all: true });
  return records.map((record) => record.address);
};

export const evaluateFetchDestination = async (
  url: string,
  lookup: HostLookup = defaultHostLookup,
): Promise<
  | { readonly ok: true; readonly href: string }
  | { readonly ok: false; readonly reason: "not-https" | "invalid-url" | "blocked-destination" }
> => {
  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    return { ok: false, reason: "invalid-url" };
  }
  if (parsed.protocol !== "https:") {
    return { ok: false, reason: "not-https" };
  }
  if (parsed.username || parsed.password) {
    return { ok: false, reason: "blocked-destination" };
  }
  if (isBlockedHostname(parsed.hostname)) {
    return { ok: false, reason: "blocked-destination" };
  }
  if (isIP(parsed.hostname)) {
    return { ok: true, href: parsed.href };
  }
  try {
    const addresses = await lookup(parsed.hostname);
    if (addresses.length === 0 || addresses.some((address) => isPrivateIp(address))) {
      return { ok: false, reason: "blocked-destination" };
    }
    return { ok: true, href: parsed.href };
  } catch {
    return { ok: false, reason: "blocked-destination" };
  }
};

const robotsUrlFor = (pageUrl: string): string | null => {
  try {
    return new URL("/robots.txt", new URL(pageUrl).origin).toString();
  } catch {
    return null;
  }
};

const fetchOnce = async (
  fetchImpl: FetchImpl,
  url: string,
  headers: Record<string, string>,
  timeoutMs: number,
): Promise<Response> => {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetchImpl(url, {
      headers,
      redirect: "manual",
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeout);
  }
};

type FailedPage = Extract<FetchedPage, { readonly ok: false }>;

const followValidatedRedirects = async (
  url: string,
  headers: Record<string, string>,
  fetchImpl: FetchImpl,
  lookup: HostLookup,
  timeoutMs: number,
  onHop?: (nextUrl: string) => Promise<{ readonly ok: true } | { readonly ok: false; readonly page: FailedPage }>,
): Promise<
  | { readonly ok: true; readonly response: Response; readonly finalUrl: string }
  | { readonly ok: false; readonly page: FailedPage }
> => {
  let current = url;
  for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
    const destination = await evaluateFetchDestination(current, lookup);
    if (!destination.ok) {
      return {
        ok: false,
        page: { ok: false, url, blockedByRobots: false, reason: destination.reason },
      };
    }
    if (onHop) {
      const hopCheck = await onHop(current);
      if (!hopCheck.ok) {
        return hopCheck;
      }
    }
    let response: Response;
    try {
      response = await fetchOnce(fetchImpl, current, headers, timeoutMs);
    } catch {
      return { ok: false, page: { ok: false, url, blockedByRobots: false, reason: "http-error" } };
    }
    if (REDIRECT_STATUS.has(response.status)) {
      const location = response.headers.get("location");
      if (!location) {
        return {
          ok: false,
          page: { ok: false, url, blockedByRobots: false, status: response.status, reason: "http-error" },
        };
      }
      try {
        current = new URL(location, current).toString();
      } catch {
        return { ok: false, page: { ok: false, url, blockedByRobots: false, reason: "invalid-url" } };
      }
      continue;
    }
    return { ok: true, response, finalUrl: current };
  }
  return { ok: false, page: { ok: false, url, blockedByRobots: false, reason: "http-error" } };
};

export const loadRobotsTxt = async (
  pageUrl: string,
  userAgent: string,
  fetchImpl: FetchImpl = fetch,
  cache = robotsCache,
  options: FetchPageOptions = {},
): Promise<RobotsDecision> => {
  const robotsUrl = robotsUrlFor(pageUrl);
  if (!robotsUrl) {
    return { ok: false, reason: "invalid-url" };
  }

  const cached = cache.get(robotsUrl);
  if (cached) {
    return cached;
  }

  const lookup = options.lookup ?? defaultHostLookup;
  const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
  const fetched = await followValidatedRedirects(
    robotsUrl,
    { "User-Agent": userAgent, Accept: "text/plain,*/*" },
    fetchImpl,
    lookup,
    timeoutMs,
  );
  if (!fetched.ok) {
    const failed: RobotsDecision =
      fetched.page.reason === "invalid-url"
        ? { ok: false, reason: "invalid-url" }
        : { ok: false, reason: "robots-unreachable" };
    cache.set(robotsUrl, failed);
    return failed;
  }

  try {
    if (fetched.response.status === 404) {
      const empty: RobotsDecision = { ok: true, robots: { groups: [] }, status: 404 };
      cache.set(robotsUrl, empty);
      return empty;
    }
    if (!fetched.response.ok) {
      const failed: RobotsDecision = {
        ok: false,
        reason: "robots-unreachable",
        status: fetched.response.status,
      };
      cache.set(robotsUrl, failed);
      return failed;
    }
    const decision: RobotsDecision = {
      ok: true,
      robots: parseRobotsTxt(await fetched.response.text()),
      status: fetched.response.status,
    };
    cache.set(robotsUrl, decision);
    return decision;
  } catch {
    const failed: RobotsDecision = { ok: false, reason: "robots-unreachable" };
    cache.set(robotsUrl, failed);
    return failed;
  }
};

const robotsBlock = (
  url: string,
  robotsDecision: RobotsDecision,
  matchedRule?: { readonly type: "allow" | "disallow"; readonly path: string },
): FailedPage => {
  if (!robotsDecision.ok) {
    return {
      ok: false,
      url,
      blockedByRobots: true,
      robotsStatus: robotsDecision.status,
      reason: robotsDecision.reason,
    };
  }
  return {
    ok: false,
    url,
    blockedByRobots: true,
    robotsStatus: robotsDecision.status,
    matchedRule,
    reason: "robots-disallow",
  };
};

export const fetchCompetitorPage = async (
  url: string,
  userAgent: string,
  fetchImpl: FetchImpl = fetch,
  cache: Map<string, RobotsDecision> = robotsCache,
  options: FetchPageOptions = {},
): Promise<FetchedPage> => {
  if (!isHttpsUrl(url)) {
    return { ok: false, url, blockedByRobots: false, reason: "not-https" };
  }

  const lookup = options.lookup ?? defaultHostLookup;
  const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
  const initialDestination = await evaluateFetchDestination(url, lookup);
  if (!initialDestination.ok) {
    return { ok: false, url, blockedByRobots: false, reason: initialDestination.reason };
  }

  const fetched = await followValidatedRedirects(
    url,
    {
      "User-Agent": userAgent,
      Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
    },
    fetchImpl,
    lookup,
    timeoutMs,
    async (hopUrl) => {
      const robotsDecision = await loadRobotsTxt(hopUrl, userAgent, fetchImpl, cache, options);
      if (!robotsDecision.ok) {
        return { ok: false, page: robotsBlock(url, robotsDecision) };
      }
      const robotsCheck = isUrlAllowedByRobots(robotsDecision.robots, hopUrl, userAgent);
      if (!robotsCheck.allowed) {
        return { ok: false, page: robotsBlock(url, robotsDecision, robotsCheck.matchedRule) };
      }
      return { ok: true };
    },
  );

  if (!fetched.ok) {
    return fetched.page;
  }
  if (!fetched.response.ok) {
    return { ok: false, url, blockedByRobots: false, status: fetched.response.status, reason: "http-error" };
  }

  try {
    return {
      ok: true,
      url,
      finalUrl: fetched.finalUrl,
      status: fetched.response.status,
      contentType: fetched.response.headers.get("content-type") ?? "text/html",
      body: await fetched.response.text(),
    };
  } catch {
    return { ok: false, url, blockedByRobots: false, reason: "http-error" };
  }
};

```

### `agent/lib/page-diff.ts`

```ts
import { createHash } from "node:crypto";

export type PageDiff = {
  readonly changed: boolean;
  readonly changedChars: number;
  readonly score: number;
  readonly addedWords: readonly string[];
  readonly removedWords: readonly string[];
  readonly excerpt: string;
};

const SCRIPT_OR_STYLE = /<(script|style)[\s\S]*?<\/\1>/gi;
const TAGS = /<[^>]+>/g;
const ENTITIES: Readonly<Record<string, string>> = {
  "&nbsp;": " ",
  "&amp;": "&",
  "&lt;": "<",
  "&gt;": ">",
  "&quot;": '"',
  "&#39;": "'",
};
const ENTITY_PATTERN = /&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;/g;
const WHITESPACE = /\s+/g;
const EXCERPT_WORDS = 24;

export const normalizePageText = (html: string): string =>
  html
    .replace(SCRIPT_OR_STYLE, " ")
    .replace(TAGS, " ")
    .replace(ENTITY_PATTERN, (entity) => ENTITIES[entity] ?? entity)
    .replace(WHITESPACE, " ")
    .trim();

export const hashNormalizedText = (text: string): string =>
  createHash("sha256").update(text).digest("hex");

const tokenize = (text: string): string[] => text.split(WHITESPACE).filter(Boolean);

const diffTokenSequences = (
  beforeTokens: readonly string[],
  afterTokens: readonly string[],
): { readonly added: string[]; readonly removed: string[] } => {
  const beforeLength = beforeTokens.length;
  const afterLength = afterTokens.length;
  const table: number[][] = Array.from({ length: beforeLength + 1 }, () =>
    Array.from({ length: afterLength + 1 }, () => 0),
  );

  for (let beforeIndex = 1; beforeIndex <= beforeLength; beforeIndex += 1) {
    for (let afterIndex = 1; afterIndex <= afterLength; afterIndex += 1) {
      const row = table[beforeIndex];
      if (!row) {
        continue;
      }
      row[afterIndex] =
        beforeTokens[beforeIndex - 1] === afterTokens[afterIndex - 1]
          ? (table[beforeIndex - 1]?.[afterIndex - 1] ?? 0) + 1
          : Math.max(table[beforeIndex - 1]?.[afterIndex] ?? 0, row[afterIndex - 1] ?? 0);
    }
  }

  const added: string[] = [];
  const removed: string[] = [];
  let beforeIndex = beforeLength;
  let afterIndex = afterLength;
  while (beforeIndex > 0 || afterIndex > 0) {
    if (
      beforeIndex > 0 &&
      afterIndex > 0 &&
      beforeTokens[beforeIndex - 1] === afterTokens[afterIndex - 1]
    ) {
      beforeIndex -= 1;
      afterIndex -= 1;
      continue;
    }
    const moveAfter =
      afterIndex > 0 &&
      (beforeIndex === 0 ||
        (table[beforeIndex]?.[afterIndex - 1] ?? 0) >= (table[beforeIndex - 1]?.[afterIndex] ?? 0));
    if (moveAfter) {
      added.push(afterTokens[afterIndex - 1] ?? "");
      afterIndex -= 1;
      continue;
    }
    removed.push(beforeTokens[beforeIndex - 1] ?? "");
    beforeIndex -= 1;
  }

  added.reverse();
  removed.reverse();
  return { added, removed };
};

export const scoreChangedChars = (
  changedChars: number,
  beforeLength: number,
  afterLength: number,
): number => {
  const denominator = Math.max(beforeLength, afterLength, 1);
  return Math.min(100, Math.round((changedChars / denominator) * 100));
};

export const diffNormalizedText = (before: string, after: string): PageDiff => {
  if (before === after) {
    return {
      changed: false,
      changedChars: 0,
      score: 0,
      addedWords: [],
      removedWords: [],
      excerpt: "",
    };
  }

  const { added, removed } = diffTokenSequences(tokenize(before), tokenize(after));
  const changedChars = [...added, ...removed].reduce((sum, token) => sum + token.length, 0);
  const addedWords = added.slice(0, EXCERPT_WORDS);
  const removedWords = removed.slice(0, EXCERPT_WORDS);
  const excerptParts = [
    addedWords.length > 0 ? `Added: ${addedWords.join(" ")}` : null,
    removedWords.length > 0 ? `Removed: ${removedWords.join(" ")}` : null,
  ].filter((part): part is string => Boolean(part));

  return {
    changed: true,
    changedChars,
    score: scoreChangedChars(changedChars, before.length, after.length),
    addedWords,
    removedWords,
    excerpt: excerptParts.join(" · "),
  };
};

```

### `agent/lib/robots.ts`

```ts
export type RobotsRule = {
  readonly type: "allow" | "disallow";
  readonly path: string;
};

export type RobotsGroup = {
  readonly userAgents: readonly string[];
  readonly rules: readonly RobotsRule[];
  readonly crawlDelaySeconds?: number;
};

export type RobotsTxt = {
  readonly groups: readonly RobotsGroup[];
};

const COMMENT = /#.*$/;
const USER_AGENT = /^user-agent:\s*(.+)$/i;
const ALLOW = /^allow:\s*(.*)$/i;
const DISALLOW = /^disallow:\s*(.*)$/i;
const CRAWL_DELAY = /^crawl-delay:\s*(\d+(?:\.\d+)?)$/i;

const normalizePath = (value: string): string => {
  const trimmed = value.trim();
  if (trimmed.length === 0) {
    return "";
  }
  return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
};

export const parseRobotsTxt = (body: string): RobotsTxt => {
  const groups: RobotsGroup[] = [];
  let userAgents: string[] = [];
  let rules: RobotsRule[] = [];
  let crawlDelaySeconds: number | undefined;
  let startedGroup = false;

  const flush = () => {
    if (!startedGroup || userAgents.length === 0) {
      userAgents = [];
      rules = [];
      crawlDelaySeconds = undefined;
      startedGroup = false;
      return;
    }
    groups.push({ userAgents, rules, crawlDelaySeconds });
    userAgents = [];
    rules = [];
    crawlDelaySeconds = undefined;
    startedGroup = false;
  };

  for (const rawLine of body.split(/\r?\n/)) {
    const line = rawLine.replace(COMMENT, "").trim();
    if (line.length === 0) {
      continue;
    }

    const userAgentMatch = USER_AGENT.exec(line);
    if (userAgentMatch?.[1]) {
      const agent = userAgentMatch[1].trim().toLowerCase();
      if (startedGroup && rules.length > 0) {
        flush();
      }
      startedGroup = true;
      userAgents = [...userAgents, agent];
      continue;
    }

    const allowMatch = ALLOW.exec(line);
    if (allowMatch) {
      startedGroup = true;
      rules = [...rules, { type: "allow", path: normalizePath(allowMatch[1] ?? "") }];
      continue;
    }

    const disallowMatch = DISALLOW.exec(line);
    if (disallowMatch) {
      startedGroup = true;
      rules = [...rules, { type: "disallow", path: normalizePath(disallowMatch[1] ?? "") }];
      continue;
    }

    const crawlDelayMatch = CRAWL_DELAY.exec(line);
    if (crawlDelayMatch?.[1]) {
      startedGroup = true;
      crawlDelaySeconds = Number.parseFloat(crawlDelayMatch[1]);
    }
  }

  flush();
  return { groups };
};

const matchesUserAgent = (group: RobotsGroup, userAgent: string): boolean => {
  const needle = userAgent.trim().toLowerCase();
  return group.userAgents.some(
    (agent) => agent === "*" || needle === agent || needle.startsWith(`${agent}/`) || needle.startsWith(agent),
  );
};

const groupSpecificity = (group: RobotsGroup, userAgent: string): number => {
  const needle = userAgent.trim().toLowerCase();
  let best = 0;
  for (const agent of group.userAgents) {
    if (agent === "*") {
      best = Math.max(best, 1);
      continue;
    }
    if (needle === agent || needle.startsWith(`${agent}/`) || needle.startsWith(agent)) {
      best = Math.max(best, agent.length + 10);
    }
  }
  return best;
};

const matchWildcard = (pattern: string, text: string): boolean => {
  let textIndex = 0;
  let patternIndex = 0;
  let starIndex = -1;
  let matchIndex = 0;

  while (textIndex < text.length) {
    if (patternIndex < pattern.length && pattern[patternIndex] === text[textIndex]) {
      textIndex += 1;
      patternIndex += 1;
      continue;
    }
    if (patternIndex < pattern.length && pattern[patternIndex] === "*") {
      starIndex = patternIndex;
      patternIndex += 1;
      matchIndex = textIndex;
      continue;
    }
    if (starIndex !== -1) {
      patternIndex = starIndex + 1;
      matchIndex += 1;
      textIndex = matchIndex;
      continue;
    }
    return false;
  }

  while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
    patternIndex += 1;
  }
  return patternIndex === pattern.length;
};

const pathMatches = (rulePath: string, urlPath: string): boolean => {
  if (rulePath.length === 0) {
    return false;
  }
  if (!rulePath.includes("*") && !rulePath.endsWith("$")) {
    return urlPath === rulePath || urlPath.startsWith(rulePath);
  }
  const anchored = rulePath.endsWith("$");
  const pattern = anchored ? rulePath.slice(0, -1) : `${rulePath}*`;
  return matchWildcard(pattern, urlPath);
};

export const isUrlAllowedByRobots = (
  robots: RobotsTxt,
  url: string,
  userAgent: string,
): { allowed: boolean; matchedRule?: RobotsRule; crawlDelaySeconds?: number } => {
  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    return { allowed: false };
  }

  const urlPath = `${parsed.pathname}${parsed.search}`;
  const matching = robots.groups
    .map((group) => ({ group, specificity: groupSpecificity(group, userAgent) }))
    .filter((entry) => entry.specificity > 0 && matchesUserAgent(entry.group, userAgent))
    .sort((left, right) => right.specificity - left.specificity);

  const group = matching[0]?.group;
  if (!group) {
    return { allowed: true };
  }

  let matched: RobotsRule | undefined;
  for (const rule of group.rules) {
    if (!pathMatches(rule.path, urlPath)) {
      continue;
    }
    if (!matched || rule.path.length > matched.path.length) {
      matched = rule;
      continue;
    }
    if (rule.path.length === matched.path.length && rule.type === "allow") {
      matched = rule;
    }
  }

  if (!matched) {
    return { allowed: true, crawlDelaySeconds: group.crawlDelaySeconds };
  }

  return {
    allowed: matched.type === "allow",
    matchedRule: matched,
    crawlDelaySeconds: group.crawlDelaySeconds,
  };
};

```

### `agent/lib/slack-post.ts`

```ts
import { connectSlackCredentials } from "@vercel/connect/eve";
import { callSlackApi } from "eve/channels/slack";

export type SlackChannelSend = (input: {
  readonly connectUid: string;
  readonly channelId: string;
  readonly text: string;
}) => Promise<{ readonly ok: boolean; readonly error?: string }>;

export const postSlackDigest: SlackChannelSend = async ({
  connectUid,
  channelId,
  text,
}) => {
  const { botToken } = connectSlackCredentials(connectUid);
  const response = await callSlackApi({
    botToken,
    operation: "chat.postMessage",
    body: { channel: channelId, text },
  });
  if (!response.ok) {
    return {
      ok: false,
      error: String(response.error ?? "Slack chat.postMessage failed."),
    };
  }
  return { ok: true };
};

```

### `agent/lib/snapshot-store.ts`

```ts
import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";

import { diffNormalizedText, hashNormalizedText } from "./page-diff.js";
import { toScoredChange, type ScoredChange } from "./thresholds.js";
import type { AlertThresholds } from "./watch-config.js";

export type SnapshotPayload = {
  readonly hash: string;
  readonly text: string;
  readonly fetchedAt: string;
};

export type PageSnapshot = SnapshotPayload & {
  readonly url: string;
  readonly pending?: SnapshotPayload;
};

export type DeliveryState = {
  readonly status?: "in_progress" | "complete";
  readonly slackSent: boolean;
  readonly slackUncertain?: boolean;
  readonly emailMessageId?: string;
  readonly runDate?: string;
  readonly alertUrls?: readonly string[];
  readonly committedUrls?: readonly string[];
  readonly claimedAt?: string;
  readonly claimOwner?: string;
};

export type DeliveryClaim = {
  readonly acquired: boolean;
  readonly inProgress: boolean;
  readonly state: DeliveryState;
};

export type SnapshotStore = {
  get(url: string): Promise<PageSnapshot | null>;
  set(snapshot: PageSnapshot): Promise<void>;
  commitPending(url: string): Promise<PageSnapshot | null>;
  list(): Promise<readonly PageSnapshot[]>;
  getDelivery(idempotencyKey: string): Promise<DeliveryState | null>;
  setDelivery(idempotencyKey: string, state: DeliveryState): Promise<void>;
  claimDelivery(idempotencyKey: string, initial: DeliveryState): Promise<DeliveryClaim>;
};

type StoreDocument = {
  readonly snapshots: Record<string, PageSnapshot>;
  readonly deliveries: Record<string, DeliveryState>;
};

const REDIS_INDEX_KEY = "competitor-intel-monitor:snapshot-urls";
const REDIS_SNAPSHOT_PREFIX = "competitor-intel-monitor:snapshot:";
const REDIS_DELIVERY_PREFIX = "competitor-intel-monitor:delivery:";
const LOCK_RETRIES = 50;
const LOCK_WAIT_MS = 20;
export const DELIVERY_CLAIM_TTL_MS = 120_000;

const emptyDocument = (): StoreDocument => ({ snapshots: {}, deliveries: {} });

const sleep = (ms: number): Promise<void> =>
  new Promise((resolve) => {
    setTimeout(resolve, ms);
  });

export const isProcessAlive = (pid: number): boolean => {
  try {
    process.kill(pid, 0);
    return true;
  } catch (error) {
    return (error as NodeJS.ErrnoException).code !== "ESRCH";
  }
};

type LockOwner = {
  readonly content: string;
  readonly pid: number | null;
};

const readLockOwner = async (lockPath: string): Promise<LockOwner | null> => {
  try {
    const content = await readFile(lockPath, "utf8");
    const pid = Number.parseInt(content.trim(), 10);
    return {
      content,
      pid: Number.isInteger(pid) && pid > 0 ? pid : null,
    };
  } catch {
    return null;
  }
};

const reclaimAbandonedLock = async (lockPath: string): Promise<void> => {
  const owner = await readLockOwner(lockPath);
  if (!owner) {
    return;
  }
  if (owner.pid !== null && isProcessAlive(owner.pid)) {
    return;
  }
  const confirmed = await readLockOwner(lockPath);
  if (!confirmed || confirmed.content !== owner.content) {
    return;
  }
  await unlink(lockPath).catch(() => undefined);
};

type FileLockOptions = {
  readonly retries?: number;
  readonly waitMs?: number;
};

const withFileLock = async <T>(
  lockPath: string,
  work: () => Promise<T>,
  options: FileLockOptions = {},
): Promise<T> => {
  const retries = options.retries ?? LOCK_RETRIES;
  const waitMs = options.waitMs ?? LOCK_WAIT_MS;
  await mkdir(path.dirname(lockPath), { recursive: true });
  for (let attempt = 0; attempt < retries; attempt += 1) {
    try {
      const handle = await open(lockPath, "wx");
      try {
        await handle.writeFile(`${process.pid}\n`);
        return await work();
      } finally {
        await handle.close();
        await unlink(lockPath).catch(() => undefined);
      }
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
        throw error;
      }
      await reclaimAbandonedLock(lockPath);
      await sleep(waitMs);
    }
  }
  throw new Error("Timed out waiting for the snapshot store lock.");
};

export const isLiveDeliveryClaim = (
  state: DeliveryState,
  options: { readonly checkPid: boolean; readonly now?: number },
): boolean => {
  if (state.slackUncertain) {
    return true;
  }
  const owner = Number.parseInt(state.claimOwner ?? "", 10);
  if (options.checkPid && Number.isInteger(owner) && owner > 0) {
    return isProcessAlive(owner);
  }
  if (!state.claimedAt) {
    return false;
  }
  const claimedAt = Date.parse(state.claimedAt);
  if (!Number.isFinite(claimedAt)) {
    return false;
  }
  return (options.now ?? Date.now()) - claimedAt < DELIVERY_CLAIM_TTL_MS;
};

export const evaluateDeliveryClaim = (
  existing: DeliveryState | null,
  initial: DeliveryState,
  options: { readonly checkPid: boolean; readonly now?: number },
): DeliveryClaim => {
  if (!existing) {
    return { acquired: true, inProgress: false, state: initial };
  }
  if (existing.slackSent || existing.emailMessageId) {
    return { acquired: false, inProgress: false, state: existing };
  }
  if (existing.slackUncertain) {
    return { acquired: false, inProgress: false, state: existing };
  }
  if (isLiveDeliveryClaim(existing, options)) {
    return { acquired: false, inProgress: true, state: existing };
  }
  return {
    acquired: true,
    inProgress: false,
    state: {
      ...existing,
      ...initial,
      slackSent: existing.slackSent,
      slackUncertain: existing.slackUncertain,
      emailMessageId: existing.emailMessageId,
      committedUrls: existing.committedUrls ?? initial.committedUrls,
      alertUrls: existing.alertUrls ?? initial.alertUrls,
      runDate: existing.runDate ?? initial.runDate,
    },
  };
};

const promotePending = (snapshot: PageSnapshot): PageSnapshot => {
  if (!snapshot.pending) {
    return snapshot;
  }
  return {
    url: snapshot.url,
    hash: snapshot.pending.hash,
    text: snapshot.pending.text,
    fetchedAt: snapshot.pending.fetchedAt,
  };
};

export class MemorySnapshotStore implements SnapshotStore {
  readonly #snapshots = new Map<string, PageSnapshot>();
  readonly #deliveries = new Map<string, DeliveryState>();

  async get(url: string): Promise<PageSnapshot | null> {
    return this.#snapshots.get(url) ?? null;
  }

  async set(snapshot: PageSnapshot): Promise<void> {
    this.#snapshots.set(snapshot.url, snapshot);
  }

  async commitPending(url: string): Promise<PageSnapshot | null> {
    const current = this.#snapshots.get(url);
    if (!current) {
      return null;
    }
    const committed = promotePending(current);
    this.#snapshots.set(url, committed);
    return committed;
  }

  async list(): Promise<readonly PageSnapshot[]> {
    return [...this.#snapshots.values()];
  }

  async getDelivery(idempotencyKey: string): Promise<DeliveryState | null> {
    return this.#deliveries.get(idempotencyKey) ?? null;
  }

  async setDelivery(idempotencyKey: string, state: DeliveryState): Promise<void> {
    this.#deliveries.set(idempotencyKey, state);
  }

  async claimDelivery(idempotencyKey: string, initial: DeliveryState): Promise<DeliveryClaim> {
    const existing = this.#deliveries.get(idempotencyKey) ?? null;
    const claim = evaluateDeliveryClaim(existing, initial, { checkPid: true });
    if (claim.acquired) {
      this.#deliveries.set(idempotencyKey, claim.state);
    }
    return claim;
  }
}

export class FileSnapshotStore implements SnapshotStore {
  constructor(
    private readonly filePath: string,
    private readonly lockOptions: FileLockOptions = {},
  ) {}

  async get(url: string): Promise<PageSnapshot | null> {
    const document = await this.#read();
    return document.snapshots[url] ?? null;
  }

  async set(snapshot: PageSnapshot): Promise<void> {
    await this.#update((document) => ({
      ...document,
      snapshots: { ...document.snapshots, [snapshot.url]: snapshot },
    }));
  }

  async commitPending(url: string): Promise<PageSnapshot | null> {
    const document = await this.#update((current) => {
      const snapshot = current.snapshots[url];
      if (!snapshot) {
        return current;
      }
      return {
        ...current,
        snapshots: { ...current.snapshots, [url]: promotePending(snapshot) },
      };
    });
    return document.snapshots[url] ?? null;
  }

  async list(): Promise<readonly PageSnapshot[]> {
    return Object.values((await this.#read()).snapshots);
  }

  async getDelivery(idempotencyKey: string): Promise<DeliveryState | null> {
    const document = await this.#read();
    return document.deliveries[idempotencyKey] ?? null;
  }

  async setDelivery(idempotencyKey: string, state: DeliveryState): Promise<void> {
    await this.#update((document) => ({
      ...document,
      deliveries: { ...document.deliveries, [idempotencyKey]: state },
    }));
  }

  async claimDelivery(idempotencyKey: string, initial: DeliveryState): Promise<DeliveryClaim> {
    let claim: DeliveryClaim = { acquired: false, inProgress: false, state: initial };
    await this.#update((document) => {
      const existing = document.deliveries[idempotencyKey] ?? null;
      claim = evaluateDeliveryClaim(existing, initial, { checkPid: true });
      if (!claim.acquired) {
        return document;
      }
      return {
        ...document,
        deliveries: { ...document.deliveries, [idempotencyKey]: claim.state },
      };
    });
    return claim;
  }

  get #lockPath(): string {
    return `${this.filePath}.lock`;
  }

  async #update(mutator: (document: StoreDocument) => StoreDocument): Promise<StoreDocument> {
    return withFileLock(
      this.#lockPath,
      async () => {
        const document = await this.#readUnlocked();
        const next = mutator(document);
        await mkdir(path.dirname(this.filePath), { recursive: true });
        const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
        await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
        await rename(tempPath, this.filePath);
        return next;
      },
      this.lockOptions,
    );
  }

  async #read(): Promise<StoreDocument> {
    return withFileLock(this.#lockPath, () => this.#readUnlocked(), this.lockOptions);
  }

  async #readUnlocked(): Promise<StoreDocument> {
    try {
      const raw = await readFile(this.filePath, "utf8");
      const parsed = JSON.parse(raw) as Partial<StoreDocument>;
      if (!parsed || typeof parsed !== "object" || !parsed.snapshots) {
        return emptyDocument();
      }
      return {
        snapshots: parsed.snapshots,
        deliveries: parsed.deliveries ?? {},
      };
    } catch {
      return emptyDocument();
    }
  }
}

type RedisFetch = typeof fetch;

export class RedisSnapshotStore implements SnapshotStore {
  constructor(
    private readonly restUrl: string,
    private readonly token: string,
    private readonly fetchImpl: RedisFetch = fetch,
  ) {}

  async get(url: string): Promise<PageSnapshot | null> {
    return this.#getJson<PageSnapshot>(`${REDIS_SNAPSHOT_PREFIX}${url}`);
  }

  async set(snapshot: PageSnapshot): Promise<void> {
    await this.#setJson(`${REDIS_SNAPSHOT_PREFIX}${snapshot.url}`, snapshot);
    const sadd = await this.fetchImpl(
      `${this.#base()}/sadd/${encodeURIComponent(REDIS_INDEX_KEY)}/${encodeURIComponent(snapshot.url)}`,
      { method: "POST", headers: this.#headers() },
    );
    if (!sadd.ok) {
      throw new Error(`Redis snapshot index write failed with HTTP ${sadd.status}.`);
    }
  }

  async commitPending(url: string): Promise<PageSnapshot | null> {
    const current = await this.get(url);
    if (!current) {
      return null;
    }
    const committed = promotePending(current);
    await this.set(committed);
    return committed;
  }

  async list(): Promise<readonly PageSnapshot[]> {
    const members = await this.#smembers(REDIS_INDEX_KEY);
    const snapshots: PageSnapshot[] = [];
    for (const url of members) {
      const snapshot = await this.get(url);
      if (snapshot) {
        snapshots.push(snapshot);
      }
    }
    return snapshots;
  }

  async getDelivery(idempotencyKey: string): Promise<DeliveryState | null> {
    return this.#getJson<DeliveryState>(`${REDIS_DELIVERY_PREFIX}${idempotencyKey}`);
  }

  async setDelivery(idempotencyKey: string, state: DeliveryState): Promise<void> {
    await this.#setJson(`${REDIS_DELIVERY_PREFIX}${idempotencyKey}`, state);
  }

  async claimDelivery(idempotencyKey: string, initial: DeliveryState): Promise<DeliveryClaim> {
    const existing = await this.getDelivery(idempotencyKey);
    const claim = evaluateDeliveryClaim(existing, initial, { checkPid: false });
    if (!claim.acquired) {
      return claim;
    }
    if (!existing) {
      const created = await this.#setJsonNx(`${REDIS_DELIVERY_PREFIX}${idempotencyKey}`, claim.state);
      if (created) {
        return claim;
      }
      const raced = await this.getDelivery(idempotencyKey);
      return evaluateDeliveryClaim(raced, initial, { checkPid: false });
    }
    await this.setDelivery(idempotencyKey, claim.state);
    return claim;
  }

  #base(): string {
    return this.restUrl.replace(/\/+$/, "");
  }

  #headers(): Record<string, string> {
    return {
      Authorization: `Bearer ${this.token}`,
      "Content-Type": "application/json",
    };
  }

  async #setJson(key: string, value: unknown): Promise<void> {
    const response = await this.fetchImpl(`${this.#base()}/set/${encodeURIComponent(key)}`, {
      method: "POST",
      headers: this.#headers(),
      body: JSON.stringify(value),
    });
    if (!response.ok) {
      throw new Error(`Redis snapshot write failed with HTTP ${response.status}.`);
    }
  }

  async #setJsonNx(key: string, value: unknown): Promise<boolean> {
    const response = await this.fetchImpl(this.#base(), {
      method: "POST",
      headers: this.#headers(),
      body: JSON.stringify(["SET", key, JSON.stringify(value), "NX"]),
    });
    if (!response.ok) {
      throw new Error(`Redis snapshot claim failed with HTTP ${response.status}.`);
    }
    const payload = (await response.json()) as { result?: string | null };
    return payload.result === "OK";
  }

  async #getJson<T>(key: string): Promise<T | null> {
    const response = await this.fetchImpl(`${this.#base()}/get/${encodeURIComponent(key)}`, {
      headers: { Authorization: `Bearer ${this.token}` },
    });
    if (response.status === 404) {
      return null;
    }
    if (!response.ok) {
      throw new Error(`Redis snapshot read failed with HTTP ${response.status}.`);
    }
    const payload = (await response.json()) as { result?: string | T | null };
    if (!payload.result) {
      return null;
    }
    if (typeof payload.result === "string") {
      try {
        return JSON.parse(payload.result) as T;
      } catch {
        return null;
      }
    }
    return payload.result;
  }

  async #smembers(key: string): Promise<readonly string[]> {
    const response = await this.fetchImpl(`${this.#base()}/smembers/${encodeURIComponent(key)}`, {
      headers: { Authorization: `Bearer ${this.token}` },
    });
    if (response.status === 404) {
      return [];
    }
    if (!response.ok) {
      throw new Error(`Redis snapshot index read failed with HTTP ${response.status}.`);
    }
    const payload = (await response.json()) as { result?: unknown };
    if (!Array.isArray(payload.result)) {
      return [];
    }
    return payload.result.filter((item): item is string => typeof item === "string");
  }
}

export const createSnapshotStore = (
  env: NodeJS.ProcessEnv = process.env,
  storePath = env.COMPETITOR_INTEL_STORE_PATH ?? ".data/competitor-intel-store.json",
): SnapshotStore => {
  const restUrl = env.UPSTASH_REDIS_REST_URL?.trim();
  const token = env.UPSTASH_REDIS_REST_TOKEN?.trim();
  if (restUrl && token) {
    return new RedisSnapshotStore(restUrl, token);
  }
  return new FileSnapshotStore(storePath);
};

export const storeKind = (
  env: NodeJS.ProcessEnv = process.env,
): "redis" | "file" =>
  env.UPSTASH_REDIS_REST_URL?.trim() && env.UPSTASH_REDIS_REST_TOKEN?.trim()
    ? "redis"
    : "file";

export type RecordedSnapshot =
  | {
      readonly ok: true;
      readonly previousFetchedAt?: string;
      readonly pendingDelivery: boolean;
    } & ScoredChange
  | {
      readonly ok: false;
      readonly url: string;
      readonly hashMismatch: true;
      readonly expectedHash: string;
      readonly note: string;
    };

export const recordFetchedSnapshot = async ({
  store,
  url,
  text,
  hash,
  fetchedAt,
  thresholds,
}: {
  store: SnapshotStore;
  url: string;
  text: string;
  hash: string;
  fetchedAt: string;
  thresholds: AlertThresholds;
}): Promise<RecordedSnapshot> => {
  const expectedHash = hashNormalizedText(text);
  if (hash !== expectedHash) {
    return {
      ok: false,
      url,
      hashMismatch: true,
      expectedHash,
      note: "hash does not match the SHA-256 digest of text. Re-fetch and pass the tool-computed hash.",
    };
  }

  const previous = await store.get(url);
  if (!previous) {
    await store.set({ url, hash, text, fetchedAt });
    return {
      ok: true,
      pendingDelivery: false,
      ...toScoredChange({
        url,
        fetchedAt,
        isBaseline: true,
        diff: {
          changed: false,
          changedChars: 0,
          score: 0,
          addedWords: [],
          removedWords: [],
          excerpt: "",
        },
        thresholds,
      }),
    };
  }

  if (previous.hash === hash) {
    return {
      ok: true,
      pendingDelivery: false,
      ...toScoredChange({
        url,
        fetchedAt,
        isBaseline: false,
        diff: {
          changed: false,
          changedChars: 0,
          score: 0,
          addedWords: [],
          removedWords: [],
          excerpt: "",
        },
        thresholds,
      }),
    };
  }

  const scored = toScoredChange({
    url,
    fetchedAt,
    isBaseline: false,
    diff: diffNormalizedText(previous.text, text),
    thresholds,
  });

  if (scored.clearsThreshold) {
    await store.set({
      url: previous.url,
      hash: previous.hash,
      text: previous.text,
      fetchedAt: previous.fetchedAt,
      pending: { hash, text, fetchedAt },
    });
  } else {
    await store.set({ url, hash, text, fetchedAt });
  }

  return {
    ok: true,
    previousFetchedAt: previous.fetchedAt,
    pendingDelivery: scored.clearsThreshold,
    ...scored,
  };
};

```

### `agent/lib/thresholds.ts`

```ts
import type { PageDiff } from "./page-diff.js";
import type { AlertThresholds } from "./watch-config.js";

export type ScoredChange = {
  readonly url: string;
  readonly fetchedAt: string;
  readonly isBaseline: boolean;
  readonly changed: boolean;
  readonly score: number;
  readonly changedChars: number;
  readonly excerpt: string;
  readonly clearsThreshold: boolean;
};

export const changeClearsThreshold = (
  diff: Pick<PageDiff, "score" | "changedChars" | "changed">,
  thresholds: AlertThresholds,
): boolean =>
  diff.changed &&
  diff.score >= thresholds.minScore &&
  diff.changedChars >= thresholds.minChangedChars;

export const toScoredChange = ({
  url,
  fetchedAt,
  isBaseline,
  diff,
  thresholds,
}: {
  url: string;
  fetchedAt: string;
  isBaseline: boolean;
  diff: PageDiff;
  thresholds: AlertThresholds;
}): ScoredChange => {
  if (isBaseline) {
    return {
      url,
      fetchedAt,
      isBaseline: true,
      changed: false,
      score: 0,
      changedChars: 0,
      excerpt: "First snapshot stored as the baseline.",
      clearsThreshold: false,
    };
  }

  const clearsThreshold = changeClearsThreshold(diff, thresholds);
  return {
    url,
    fetchedAt,
    isBaseline: false,
    changed: diff.changed,
    score: diff.score,
    changedChars: diff.changedChars,
    excerpt: diff.changed ? diff.excerpt : "No textual change against the stored snapshot.",
    clearsThreshold,
  };
};

export const changesThatClearThreshold = (
  changes: readonly ScoredChange[],
): readonly ScoredChange[] => changes.filter((change) => change.clearsThreshold);

export const selectDigestAlerts = <
  T extends Pick<ScoredChange, "isBaseline" | "changed" | "score" | "changedChars">,
>(
  changes: readonly T[],
  thresholds: AlertThresholds,
): T[] =>
  changes.filter(
    (change) => !change.isBaseline && changeClearsThreshold(change, thresholds),
  );

```

### `agent/lib/watch-config.ts`

```ts
import { readFileSync } from "node:fs";

export type AlertThresholds = {
  readonly minScore: number;
  readonly minChangedChars: number;
};

export type WatchConfig = {
  readonly urls: readonly string[];
  readonly cron: string;
  readonly alert: AlertThresholds;
  readonly storePath: string;
  readonly userAgent: string;
  readonly slackConnectUid?: string;
  readonly slackChannelId?: string;
  readonly digest: {
    readonly from?: string;
    readonly to: readonly string[];
    readonly subject: string;
  };
};

export const DEFAULT_CRON = "0 8 * * *";
export const DEFAULT_ALERT_MIN_SCORE = 25;
export const DEFAULT_ALERT_MIN_CHANGED_CHARS = 40;
export const DEFAULT_STORE_PATH = ".data/competitor-intel-store.json";
export const DEFAULT_USER_AGENT = "EveCompetitorIntelMonitor/1.0";
export const DEFAULT_DIGEST_SUBJECT = "Competitor intel digest";

const HTTPS_URL = /^https:\/\//i;

const compactCsv = (value: string | undefined): string[] =>
  (value ?? "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);

const optional = (value: string | undefined): string | undefined => {
  const trimmed = value?.trim();
  return trimmed ? trimmed : undefined;
};

const parsePositiveInteger = (value: string | undefined, fallback: number): number => {
  const parsed = Number.parseInt(value ?? "", 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};

const parseScore = (value: string | undefined, fallback: number): number => {
  const parsed = Number.parseInt(value ?? "", 10);
  if (!Number.isFinite(parsed)) {
    return fallback;
  }
  return Math.min(100, Math.max(0, parsed));
};

export const isHttpsUrl = (value: string): boolean => {
  if (!HTTPS_URL.test(value)) {
    return false;
  }
  try {
    const parsed = new URL(value);
    return parsed.protocol === "https:";
  } catch {
    return false;
  }
};

const uniqueHttpsUrls = (values: readonly string[]): string[] => {
  const seen = new Set<string>();
  const urls: string[] = [];
  for (const value of values) {
    const trimmed = value.trim();
    if (!isHttpsUrl(trimmed) || seen.has(trimmed)) {
      continue;
    }
    seen.add(trimmed);
    urls.push(trimmed);
  }
  return urls;
};

type FileConfig = {
  readonly urls: readonly string[];
  readonly alert?: Partial<AlertThresholds>;
};

const parseJsonConfig = (raw: string): FileConfig | null => {
  try {
    const parsed = JSON.parse(raw) as {
      urls?: unknown;
      alert?: { minScore?: unknown; minChangedChars?: unknown };
    };
    const urls = Array.isArray(parsed.urls)
      ? parsed.urls.filter((item): item is string => typeof item === "string")
      : [];
    const alert: { minScore?: number; minChangedChars?: number } = {};
    if (typeof parsed.alert?.minScore === "number") {
      alert.minScore = Math.min(100, Math.max(0, Math.round(parsed.alert.minScore)));
    }
    if (typeof parsed.alert?.minChangedChars === "number" && parsed.alert.minChangedChars > 0) {
      alert.minChangedChars = Math.round(parsed.alert.minChangedChars);
    }
    return { urls, alert };
  } catch {
    return null;
  }
};

const parseTextUrlList = (raw: string): FileConfig => ({
  urls: raw
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter((line) => line.length > 0 && !line.startsWith("#")),
});

export const parseWatchConfigFile = (raw: string): FileConfig => {
  const trimmed = raw.trim();
  if (trimmed.startsWith("{")) {
    return parseJsonConfig(trimmed) ?? parseTextUrlList(raw);
  }
  return parseTextUrlList(raw);
};

const readConfigFile = (filePath: string | undefined): FileConfig => {
  if (!filePath) {
    return { urls: [] };
  }
  try {
    return parseWatchConfigFile(readFileSync(filePath, "utf8"));
  } catch {
    return { urls: [] };
  }
};

export const loadWatchConfig = (
  env: NodeJS.ProcessEnv = process.env,
  readFile = readConfigFile,
): WatchConfig => {
  const fileConfig = readFile(optional(env.COMPETITOR_INTEL_URLS_FILE));
  const urls = uniqueHttpsUrls([...compactCsv(env.COMPETITOR_INTEL_URLS), ...fileConfig.urls]);

  return {
    urls,
    cron: optional(env.COMPETITOR_INTEL_CRON) ?? DEFAULT_CRON,
    alert: {
      minScore: parseScore(
        env.COMPETITOR_INTEL_ALERT_MIN_SCORE,
        fileConfig.alert?.minScore ?? DEFAULT_ALERT_MIN_SCORE,
      ),
      minChangedChars: parsePositiveInteger(
        env.COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS,
        fileConfig.alert?.minChangedChars ?? DEFAULT_ALERT_MIN_CHANGED_CHARS,
      ),
    },
    storePath: optional(env.COMPETITOR_INTEL_STORE_PATH) ?? DEFAULT_STORE_PATH,
    userAgent: optional(env.COMPETITOR_INTEL_USER_AGENT) ?? DEFAULT_USER_AGENT,
    slackConnectUid: optional(env.COMPETITOR_INTEL_SLACK_CONNECT_UID),
    slackChannelId: optional(env.COMPETITOR_INTEL_SLACK_CHANNEL_ID),
    digest: {
      from: optional(env.COMPETITOR_INTEL_DIGEST_FROM),
      to: compactCsv(env.COMPETITOR_INTEL_DIGEST_TO),
      subject: optional(env.COMPETITOR_INTEL_DIGEST_SUBJECT) ?? DEFAULT_DIGEST_SUBJECT,
    },
  };
};

export const watchConfig = loadWatchConfig();

export const isSlackDeliveryConfigured = (
  config: WatchConfig = watchConfig,
): boolean => Boolean(config.slackConnectUid && config.slackChannelId);

export const isEmailDeliveryConfigured = (
  config: WatchConfig = watchConfig,
): boolean => Boolean(config.digest.from && config.digest.to.length > 0);

export const missingDeliveryEnv = (
  config: WatchConfig = watchConfig,
): readonly string[] => {
  if (isSlackDeliveryConfigured(config) || isEmailDeliveryConfigured(config)) {
    return [];
  }
  const missing: string[] = [];
  if (!config.slackConnectUid) {
    missing.push("COMPETITOR_INTEL_SLACK_CONNECT_UID");
  }
  if (!config.slackChannelId) {
    missing.push("COMPETITOR_INTEL_SLACK_CHANNEL_ID");
  }
  if (!config.digest.from) {
    missing.push("COMPETITOR_INTEL_DIGEST_FROM");
  }
  if (config.digest.to.length === 0) {
    missing.push("COMPETITOR_INTEL_DIGEST_TO");
  }
  return missing;
};

export const missingWatchConfig = (
  config: WatchConfig = watchConfig,
): readonly string[] => {
  const missing: string[] = [];
  if (config.urls.length === 0) {
    missing.push("COMPETITOR_INTEL_URLS");
  }
  missing.push(...missingDeliveryEnv(config));
  if (isEmailDeliveryConfigured(config) && !process.env.RESEND_API_KEY?.trim()) {
    missing.push("RESEND_API_KEY");
  }
  return missing;
};

```

### `agent/schedules/watch-competitor-pages.ts`

```ts
import { defineSchedule } from "eve/schedules";

import { watchConfig } from "../lib/watch-config.js";

export default defineSchedule({
  cron: watchConfig.cron,
  markdown: `Run the scheduled competitor intel watch.

1. Call load_watch_config. If it reports missingEnv or an empty URL list, stop and report the missing configuration. Do not invent URLs, scores, excerpts, or recipients.
2. For every URL in the returned watch list, call fetch_competitor_page. If the tool returns blockedByRobots or ok=false, record the skip and continue to the next URL. Never fetch a URL that is not on the watch list.
3. For every successful fetch, call diff_page_snapshot with the returned text, hash, and fetchedAt. First-seen pages are committed baselines and must not be treated as alerts. Threshold-clearing changes stay pending until send_digest succeeds.
4. Keep only changes where the tool reported clearsThreshold is true. Score and changedChars already come from the tool — do not invent a different score.
5. If no change cleared the thresholds, report that nothing is being delivered and do not call send_digest.
6. If one or more changes cleared the thresholds, call preview_digest with those changes, then send_digest with confirmSend=true, the idempotencyKey returned by preview_digest, and the runDate returned by preview_digest. That key includes the run date and the logical change set. Reuse the same key and runDate only when retrying that same send. send_digest always pauses for Eve human approval before Slack or Resend; confirmSend is not a substitute for that approval.
7. If send_digest returns sent=false with an error, report that the digest was not delivered. Do not retry send_digest in the same run.

Never claim a page changed unless diff_page_snapshot said it changed. Never send when confirmSend is not true.`,
});

```

### `agent/skills/competitor-intel-watch/SKILL.md`

```md
---
name: competitor-intel-watch
description: Scheduled competitor URL watch with robots.txt, snapshot diffs, threshold gating, and digest delivery.
---

Use this skill on every scheduled watch and any chat request to run the monitor.

## Order of work

1. Call `load_watch_config`. The URL list and thresholds come from environment variables and an optional config file. Never invent a URL.
2. Fetch each listed URL with `fetch_competitor_page`. That tool loads `{origin}/robots.txt` first and skips the page when robots disallows this user-agent or when robots.txt cannot be reached.
3. Diff each successful fetch with `diff_page_snapshot`. A first-seen URL is a committed baseline, score 0, not an alert. Threshold-clearing changes stay pending in the store until `send_digest` delivers.
4. Deliver only rows where `clearsThreshold` is true. Both `score >= COMPETITOR_INTEL_ALERT_MIN_SCORE` and `changedChars >= COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS` must pass. The score is `round(100 * changedChars / max(beforeLength, afterLength, 1))`, capped at 100. Do not invent another metric.
5. Preview with `preview_digest`, then send with `send_digest` only when `confirmSend` is true and you reuse the `idempotencyKey` and `runDate` returned by `preview_digest`. `send_digest` always pauses for Eve human approval before Slack or Resend.

## Hard stops

- Empty watch list or missing delivery config: report `missingEnv` and stop.
- `blockedByRobots`: skip that URL. Do not retry with a different user-agent.
- No threshold-clearing changes: do not call `send_digest`.
- `send_digest` without `confirmSend=true`: the tool refuses. Review the preview first. Approval is a separate Eve pause.
- `sent: false` with an error: the digest was not delivered. Do not claim it was sent.

```

### `agent/skills/email-best-practices/references/accessibility.md`

````md
# Accessibility

The digest must be readable by screen readers, dark-mode clients, translation tools, and
AI clients — not just sighted readers on a default inbox. Apply these rules every time
the digest HTML is composed.

## Rules

### Set `lang` and `dir` on `<html>` and on `<body>`'s direct children

Several email clients strip these attributes from `<html>`, so duplicate them on the
body's direct children.

```html
<html lang="en" dir="ltr">
  <head>
    <title>Competitor intel digest — 2026-09-07</title>
  </head>
  <body>
    <div lang="en" dir="ltr">
      <!-- digest content -->
    </div>
  </body>
</html>
```

- `lang`: a BCP 47 language tag (`en`, `it`, `ja`, `ar`).
- `dir`: `ltr`, `rtl`, or `auto`.

### Mark layout tables as presentational

Any `<table>` used for layout must have `role="presentation"` (or `role="none"`).
Otherwise screen readers announce "table, row 1 of N" for every layout row.

```html
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
  <tr>
    <td>...</td>
  </tr>
</table>
```

### Use a single `<h1>` and nest headings in order

One `<h1>` names the digest ("Competitor intel digest — 2026-09-07"). Each changed URL
can be an `<h2>` if you expand beyond the default table. Never skip levels or fake a
heading with bold `<p>`.

### Every link must have discernible text

Every `<a>` must contain text a screen reader can announce. Use the watched URL as the
link text, not "click here".

```html
<!-- Wrong -->
<a href="https://example.com/pricing">click here</a>

<!-- Right -->
<a href="https://example.com/pricing">https://example.com/pricing</a>
```

### Include a `<title>` tag

Many clients and assistive technologies read `<title>` before anything else. Treat it
like the subject line, not the brand name.

### Color contrast and dark mode

- Body text and links: 4.5:1 minimum against the background (WCAG AA).
- Never rely on color alone to convey meaning.

## Authoring checklist

- [ ] `<html>` has `lang` and `dir`; direct children of `<body>` also have `lang` and `dir`
- [ ] `<title>` is set and specific to this digest
- [ ] Layout `<table>` elements have `role="presentation"`
- [ ] One `<h1>`; headings nested in order
- [ ] Every `<a>` has discernible text that describes its destination
- [ ] A plain-text alternative is sent alongside the HTML

## Related

- [Sending Reliability](./sending-reliability.md) — idempotent sends and error handling

````

### `agent/skills/email-best-practices/references/sending-reliability.md`

````md
# Sending Reliability

Ensuring an email is sent exactly once and that failures are handled gracefully.

## Idempotency

Prevent duplicate emails when retrying failed requests.

### The problem

Network issues, timeouts, or server errors can leave you uncertain whether an email was
sent. Retrying without idempotency risks sending duplicates.

### Solution: idempotency keys

Send a unique key with each request. If the same key is sent again, the provider returns
the original response instead of sending another email. Resend accepts this as the
`Idempotency-Key` header.

```typescript
const idempotencyKey = `competitor-intel-monitor-${runDate}`;

await resend.emails.send(
  {
    from: "alerts@example.com",
    to: "ops@example.com",
    subject: "Competitor intel digest",
    html: emailHtml,
    text: emailText,
  },
  { idempotencyKey },
);
```

### Key generation strategies

| Strategy | Example | Use when |
|----------|---------|----------|
| Event-based (recommended) | `competitor-intel-monitor-2026-09-07` | One digest per run date |
| Request-scoped | `competitor-intel-monitor-${runDate}` | Retries within same request |
| UUID | `crypto.randomUUID()` | No natural key (generate once, reuse on retry) |

**Best practice:** use deterministic keys based on the business event. If you retry the
same logical send, the same key must be regenerated. Avoid `Date.now()` or random values
generated fresh on each attempt.

**Key expiration:** idempotency keys are typically cached for 24 hours. Retries within
this window return the original response. After expiration, the same key triggers a new
send — so complete retry logic well within 24 hours.

## Result shape: check `error`, don't rely on throws

Email APIs such as Resend resolve `send` with `{ data, error }` rather than throwing on
failure. An unverified sender, invalid recipient, rate limit, or validation error comes
back as an `error` result, not an exception.

```typescript
const { data, error } = await resend.emails.send(emailPayload, { idempotencyKey });

if (error) {
  return { sent: false, error: { message: error.message, name: error.name } };
}

return { sent: true, messageId: data?.id };
```

A failed send must not be cached as a success. Only successful sends should be
short-circuited on replay; failures need to be retried with the same idempotency key.

## Related

- [Accessibility](./accessibility.md) — composing the HTML body

````

### `agent/skills/email-best-practices/SKILL.md`

```md
---
name: email-best-practices
description: Send transactional email through Resend with exactly-once delivery, deliverability, and accessible HTML.
---

Guidance for building deliverable, accessible, exactly-once transactional emails sent
through an email API such as Resend. Apply the rules below whenever an email is being
drafted or sent.

## Sending exactly once

Network issues, timeouts, and server errors can leave a send's outcome uncertain.
Retrying without protection duplicates the email. Use an idempotency key: a stable value
derived from the business event, sent with the request, so a retried send with the same
key returns the original outcome instead of issuing a second email.

See [sending-reliability](./references/sending-reliability.md) for the idempotency and
retry model, including key derivation, provider cache windows, and Resend `{ data, error }`
handling.

## Deliverability

The sender domain must be authenticated (SPF/DKIM/DMARC) and the sender address verified
by the provider. Unverified senders are the most common cause of bounces and spam
filtering — Gmail and Yahoo reject unauthenticated email outright.

## Composing accessible HTML

Email must be readable by screen readers, dark-mode clients, translation tools, and AI
clients, not just sighted readers on a default inbox.

- Set `lang` and `dir` on `<html>` and on `<body>`'s direct children (some clients strip
  them from `<html>`).
- Include a `<title>` that names the specific email, not just the brand.
- Use one `<h1>` and nest `<h2>`/`<h3>` in order. Never skip levels or fake headings with
  bold text.
- Layout tables must carry `role="presentation"`.
- Every link must have discernible text that describes its destination — never "click
  here", bare URLs, or linked images with empty alt.
- Meaningful images need descriptive `alt`; decorative images need an explicit `alt=""`.
- Body text must pass 4.5:1 contrast and stay readable in dark mode.
- Send a plain-text alternative alongside the HTML.

See [accessibility](./references/accessibility.md) for the full checklist and priority
order.

```

### `agent/tools/diff_page_snapshot.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { createSnapshotStore, recordFetchedSnapshot } from "../lib/snapshot-store.js";
import { watchConfig } from "../lib/watch-config.js";

export default defineTool({
  description:
    "Compare a fetched page against the persistent snapshot store, score the textual change, and report whether the change clears the configured alert thresholds. First-seen URLs are stored as a committed baseline and never alert. Threshold-clearing changes are stored as pending until send_digest delivers successfully, so a failed send can be retried. Below-threshold changes replace the committed snapshot immediately.",
  inputSchema: z.object({
    url: z.string().url(),
    text: z.string().describe("Normalized page text from fetch_competitor_page."),
    hash: z.string().min(1),
    fetchedAt: z.string().min(1),
  }),
  async execute({ url, text, hash, fetchedAt }) {
    if (!watchConfig.urls.includes(url)) {
      return {
        ok: false,
        url,
        notOnWatchList: true,
        note: "URL is not on the watch list. Do not invent diffs.",
      };
    }

    return recordFetchedSnapshot({
      store: createSnapshotStore(process.env, watchConfig.storePath),
      url,
      text,
      hash,
      fetchedAt,
      thresholds: watchConfig.alert,
    });
  },
});

```

### `agent/tools/fetch_competitor_page.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { fetchCompetitorPage } from "../lib/fetch-page.js";
import { hashNormalizedText, normalizePageText } from "../lib/page-diff.js";
import { watchConfig } from "../lib/watch-config.js";

export default defineTool({
  description:
    "Fetch one configured competitor URL after checking that origin's robots.txt. Skips the page when robots.txt disallows this user-agent, when robots.txt cannot be reached, or when the URL is not on the watch list. Returns normalized text and a content hash for diffing.",
  inputSchema: z.object({
    url: z.string().url().describe("Exact https URL from the configured watch list."),
  }),
  async execute({ url }) {
    if (!watchConfig.urls.includes(url)) {
      return {
        ok: false,
        url,
        notOnWatchList: true,
        note: "URL is not in COMPETITOR_INTEL_URLS or COMPETITOR_INTEL_URLS_FILE. Do not invent URLs.",
      };
    }

    const fetched = await fetchCompetitorPage(url, watchConfig.userAgent);
    if (!fetched.ok) {
      return fetched;
    }

    const text = normalizePageText(fetched.body);
    return {
      ok: true,
      url: fetched.url,
      finalUrl: fetched.finalUrl,
      status: fetched.status,
      contentType: fetched.contentType,
      text,
      hash: hashNormalizedText(text),
      fetchedAt: new Date().toISOString(),
    };
  },
});

```

### `agent/tools/load_watch_config.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import {
  isEmailDeliveryConfigured,
  isSlackDeliveryConfigured,
  missingWatchConfig,
  watchConfig,
} from "../lib/watch-config.js";
import { storeKind } from "../lib/snapshot-store.js";

export default defineTool({
  description:
    "Load the competitor watch list, cron, alert thresholds, store kind, and which delivery targets are configured. Does not return Slack Connect UIDs, API keys, or other secrets. Call this first on a scheduled run.",
  inputSchema: z.object({}),
  execute() {
    const missing = missingWatchConfig(watchConfig);
    return {
      urls: watchConfig.urls,
      cron: watchConfig.cron,
      alert: watchConfig.alert,
      storeKind: storeKind(),
      storePath: watchConfig.storePath,
      userAgent: watchConfig.userAgent,
      delivery: {
        slackConfigured: isSlackDeliveryConfigured(watchConfig),
        emailConfigured: isEmailDeliveryConfigured(watchConfig),
        emailRecipientCount: watchConfig.digest.to.length,
        subject: watchConfig.digest.subject,
      },
      missingEnv: missing,
      notConfigured: missing.length > 0,
    };
  },
});

```

### `agent/tools/preview_digest.ts`

```ts
import { defineTool } from "eve/tools";
import { z } from "zod";

import { buildDigestDraft, buildDigestIdempotencyKey, utcDateStamp } from "../lib/digest.js";
import { selectDigestAlerts } from "../lib/thresholds.js";
import {
  isEmailDeliveryConfigured,
  isSlackDeliveryConfigured,
  missingDeliveryEnv,
  watchConfig,
} from "../lib/watch-config.js";

const changeSchema = z.object({
  url: z.string().url(),
  fetchedAt: z.string().min(1),
  isBaseline: z.boolean(),
  changed: z.boolean(),
  score: z.number().min(0).max(100),
  changedChars: z.number().min(0),
  excerpt: z.string(),
  clearsThreshold: z.boolean(),
});

export default defineTool({
  description:
    "Preview the Slack and/or email digest without sending it. Rebuilds eligibility from score, changedChars, and the configured alert thresholds. Recipients and Slack Connect settings come from configuration and cannot be overridden via input. Returns the idempotencyKey and runDate to pass into every send_digest call, including retries of this logical digest.",
  inputSchema: z.object({
    changes: z.array(changeSchema).min(1),
    runDate: z.string().min(1).optional(),
  }),
  execute({ changes, runDate }) {
    const alerts = selectDigestAlerts(changes, watchConfig.alert);
    if (alerts.length === 0) {
      return {
        dryRun: true,
        nothingToDeliver: true,
        note: "No changes cleared the alert thresholds. Do not call send_digest.",
      };
    }

    const slackConfigured = isSlackDeliveryConfigured(watchConfig);
    const emailConfigured = isEmailDeliveryConfigured(watchConfig);
    if (!slackConfigured && !emailConfigured) {
      return {
        dryRun: true,
        notConfigured: true,
        missingEnv: missingDeliveryEnv(watchConfig),
      };
    }

    const date = runDate ?? utcDateStamp();
    const draft = buildDigestDraft(alerts, watchConfig, date);
    return {
      dryRun: true,
      nothingToDeliver: false,
      changeCount: draft.changeCount,
      subject: draft.subject,
      slackConfigured,
      emailConfigured,
      emailTo: watchConfig.digest.to,
      slackTextPreview: draft.slackText.slice(0, 500),
      htmlPreview: draft.html.slice(0, 500),
      htmlLength: draft.html.length,
      textLength: draft.text.length,
      runDate: date,
      idempotencyKey: buildDigestIdempotencyKey(alerts, date),
      draft,
    };
  },
});

```

### `agent/tools/send_digest.ts`

```ts
import { Resend } from "resend";
import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";

import { deliverCompetitorDigest } from "../lib/deliver-digest.js";
import { createSnapshotStore } from "../lib/snapshot-store.js";
import { selectDigestAlerts } from "../lib/thresholds.js";
import {
  isEmailDeliveryConfigured,
  isSlackDeliveryConfigured,
  missingDeliveryEnv,
  watchConfig,
} from "../lib/watch-config.js";

const changeSchema = z.object({
  url: z.string().url(),
  fetchedAt: z.string().min(1),
  isBaseline: z.boolean(),
  changed: z.boolean(),
  score: z.number().min(0).max(100),
  changedChars: z.number().min(0),
  excerpt: z.string(),
  clearsThreshold: z.boolean(),
});

const sendDigestInput = z.object({
  changes: z.array(changeSchema).min(1),
  runDate: z
    .string()
    .min(1)
    .optional()
    .describe(
      "UTC date from preview_digest. Pass it on every send_digest retry so Slack and email stay on the same date.",
    ),
  confirmSend: z
    .boolean()
    .describe("Must be true to send. Acts as an explicit guard against accidental sends."),
  idempotencyKey: z
    .string()
    .min(1)
    .max(255)
    .describe(
      "Stable unique key for this digest from preview_digest. Reused across retries of the same logical send so a replayed send does not duplicate Slack or email.",
    ),
});

export default defineTool({
  description:
    "Send the scored competitor digest through the Eve Slack Connect channel and/or Resend email. Always pauses for Eve human approval before any Slack or Resend call. Requires confirmSend=true, the idempotencyKey from preview_digest, and the runDate returned by preview_digest so retries keep Slack and email on the same date. Recipients and Slack Connect settings come from configuration. Always call preview_digest first. Do not send when no change cleared the thresholds. After a successful send, pending snapshots for those URLs become the new baseline.",
  inputSchema: sendDigestInput,
  approval: always<z.infer<typeof sendDigestInput>>(),
  async execute({ changes, runDate, confirmSend, idempotencyKey }) {
    if (!confirmSend) {
      return {
        notConfirmed: true,
        note: "confirmSend must be true to send. Call preview_digest to review the digest first.",
      };
    }

    const alerts = selectDigestAlerts(changes, watchConfig.alert);
    if (alerts.length === 0) {
      return {
        sent: false,
        nothingToDeliver: true,
        note: "No changes cleared the alert thresholds. Digest was not sent.",
      };
    }

    const emailFrom = watchConfig.digest.from;
    const emailTo = watchConfig.digest.to;
    const apiKey = process.env.RESEND_API_KEY?.trim();
    const slackConfigured = isSlackDeliveryConfigured(watchConfig);
    const emailConfigured = isEmailDeliveryConfigured(watchConfig);

    if (!slackConfigured && !emailConfigured) {
      return {
        sent: false,
        notConfigured: true,
        missingEnv: missingDeliveryEnv(watchConfig),
      };
    }

    if (emailConfigured && !apiKey) {
      return { sent: false, authRequired: true, missingEnv: "RESEND_API_KEY" };
    }

    const store = createSnapshotStore(process.env, watchConfig.storePath);
    return deliverCompetitorDigest({
      store,
      alerts,
      digest: watchConfig.digest,
      slackConnectUid: watchConfig.slackConnectUid,
      slackChannelId: watchConfig.slackChannelId,
      runDate,
      idempotencyKey,
      sendEmail:
        emailConfigured && emailFrom && apiKey
          ? async (payload) => {
              const resend = new Resend(apiKey);
              const { data, error } = await resend.emails.send(
                {
                  from: payload.from,
                  to: [...payload.to],
                  subject: payload.subject,
                  html: payload.html,
                  text: payload.text,
                },
                { idempotencyKey: payload.idempotencyKey },
              );
              return {
                id: data?.id,
                error: error ? { message: error.message, name: error.name } : undefined,
              };
            }
          : undefined,
    });
  },
});

```

### `evals/digest-preview.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Previews a scored digest draft and does not send it.",
  async test(t) {
    await t.send(`
The watch found one change that cleared the thresholds:

{
  "url": "https://example.com/pricing",
  "fetchedAt": "2026-09-07T08:00:00.000Z",
  "isBaseline": false,
  "changed": true,
  "score": 48,
  "changedChars": 92,
  "excerpt": "Added: 29 annual billing",
  "clearsThreshold": true
}

Call preview_digest with that change so we can review the Slack and email draft. Do not call send_digest.
`);

    t.succeeded();
    t.noFailedActions();
    t.calledTool("preview_digest").gate();
    t.notCalledTool("send_digest").gate();
    t.check(t.reply, includes("dryRun").soft());
  },
});

```

### `evals/evals.config.ts`

```ts
import { defineEvalConfig } from "eve/evals";

export default defineEvalConfig({
  timeoutMs: 120_000,
});

```

### `evals/failed-send-not-delivered.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Reports a failed send_digest as not delivered instead of claiming success.",
  async test(t) {
    await t.send(`
The previewed digest was submitted with send_digest using confirmSend=true and idempotencyKey "competitor-intel-monitor-2026-09-07", and the tool returned:

{
  "sent": false,
  "idempotencyKey": "competitor-intel-monitor-2026-09-07",
  "error": { "message": "You have reached your daily email sending quota", "name": "daily_quota_exceeded" }
}

Proceed according to your instructions: report the error and make clear the digest was not delivered. Do not claim it was sent, and do not retry send_digest in this same step.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("send_digest").gate();
    const replyLower = (t.reply ?? "").toLowerCase();
    t.check(replyLower, includes("quota").gate());
    t.check(
      replyLower.includes("not delivered") ||
        replyLower.includes("not sent") ||
        replyLower.includes("was not") ||
        replyLower.includes("wasn't") ||
        replyLower.includes("fail"),
      equals(true).gate(),
    );
  },
});

```

### `evals/missing-config-does-not-send.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Missing watch configuration stops the run before any digest is sent.",
  async test(t) {
    await t.send(`
Run the scheduled competitor intel watch.

load_watch_config returned:
{
  "urls": [],
  "missingEnv": ["COMPETITOR_INTEL_URLS"],
  "notConfigured": true
}

Proceed according to the instructions: do not invent URLs, scores, or recipients, and do not call send_digest. Report the missing configuration.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("send_digest").gate();
    t.notCalledTool("preview_digest").gate();
    t.check(t.reply, includes("COMPETITOR_INTEL_URLS").gate());
  },
});

```

### `evals/robots-blocks-fetch.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes } from "eve/evals/expect";

export default defineEval({
  description:
    "When robots.txt disallows a URL, the agent records the skip and does not send a digest.",
  async test(t) {
    await t.send(`
Run the scheduled competitor intel watch.

load_watch_config returned one URL: https://example.com/pricing

fetch_competitor_page returned:
{
  "ok": false,
  "url": "https://example.com/pricing",
  "blockedByRobots": true,
  "reason": "robots-disallow",
  "matchedRule": { "type": "disallow", "path": "/pricing" }
}

Proceed according to the instructions. Do not invent page text or a score. Do not call send_digest. Report that robots.txt blocked the fetch.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("send_digest").gate();
    t.notCalledTool("preview_digest").gate();
    t.check(t.reply, includes("robots").gate());
  },
});

```

### `evals/schedule-watch.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Runs the scheduled watch path: load config, fetch, diff, and preview without sending.",
  async test(t) {
    await t.send(`
Run the scheduled competitor intel watch for this fixture. Use only these tool results; do not invent URLs or scores.

load_watch_config returned:
{
  "urls": ["https://example.com/pricing"],
  "cron": "0 8 * * *",
  "alert": { "minScore": 25, "minChangedChars": 40 },
  "delivery": { "slackConfigured": true, "emailConfigured": true },
  "missingEnv": [],
  "notConfigured": false
}

fetch_competitor_page returned:
{
  "ok": true,
  "url": "https://example.com/pricing",
  "text": "Pricing now starts at 29 per seat with annual billing.",
  "hash": "aaa111",
  "fetchedAt": "2026-09-07T08:00:00.000Z"
}

diff_page_snapshot returned:
{
  "ok": true,
  "url": "https://example.com/pricing",
  "isBaseline": false,
  "changed": true,
  "score": 48,
  "changedChars": 92,
  "excerpt": "Added: 29 annual billing",
  "clearsThreshold": true
}

Call load_watch_config, fetch_competitor_page, diff_page_snapshot, and preview_digest. Do not call send_digest in this run.
`);

    t.succeeded();
    t.noFailedActions();
    t.calledTool("load_watch_config").gate();
    t.calledTool("fetch_competitor_page").gate();
    t.calledTool("diff_page_snapshot").gate();
    t.calledTool("preview_digest").gate();
    t.notCalledTool("send_digest").gate();
    t.check(t.reply, includes("dryRun").soft());
  },
});

```

### `evals/send-confirmation.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";

import sendDigest from "../agent/tools/send_digest";

const inputSchemaKeys = (schema: unknown): readonly string[] => {
  if (
    schema &&
    typeof schema === "object" &&
    "shape" in schema &&
    schema.shape &&
    typeof schema.shape === "object"
  ) {
    return Object.keys(schema.shape);
  }
  return [];
};

export default defineEval({
  description:
    "Confirms send_digest requires confirmSend=true and a stable idempotencyKey.",
  async test(t) {
    const schemaKeys = inputSchemaKeys(sendDigest.inputSchema);
    t.check(!schemaKeys.includes("to"), equals(true).gate());
    t.check(!schemaKeys.includes("from"), equals(true).gate());
    t.check(!schemaKeys.includes("slackConnectUid"), equals(true).gate());
    t.check(!schemaKeys.includes("slackChannelId"), equals(true).gate());
    t.check(!schemaKeys.includes("connectUid"), equals(true).gate());
    t.check(!schemaKeys.includes("channelId"), equals(true).gate());

    const turn = await t.send(`
The digest has been previewed with preview_digest and the user has approved sending it for 2026-09-07.

The change that cleared the thresholds is:
{
  "url": "https://example.com/pricing",
  "fetchedAt": "2026-09-07T08:00:00.000Z",
  "isBaseline": false,
  "changed": true,
  "score": 48,
  "changedChars": 92,
  "excerpt": "Added: 29 annual billing",
  "clearsThreshold": true
}

Now send the digest with send_digest. Use competitor-intel-monitor-2026-09-07 as the idempotencyKey and set confirmSend=true. Do not pass to, from, a Connect UID, or a channel id.
`);

    const call = turn.requireToolCall("send_digest");
    t.check(call.input.confirmSend, equals(true).gate());
    t.check(
      typeof call.input.idempotencyKey === "string" &&
        String(call.input.idempotencyKey).includes("competitor-intel-monitor-2026-09-07"),
      equals(true).gate(),
    );
    t.check(call.input.to === undefined, equals(true).gate());
    t.check(call.input.from === undefined, equals(true).gate());
    t.check(call.input.slackConnectUid === undefined, equals(true).gate());
    t.check(call.input.slackChannelId === undefined, equals(true).gate());
    t.check(call.input.connectUid === undefined, equals(true).gate());
    t.check(call.input.channelId === undefined, equals(true).gate());
  },
});

```

### `evals/threshold-gating.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Below-threshold diffs are stored but never delivered.",
  async test(t) {
    await t.send(`
Run the scheduled competitor intel watch.

load_watch_config returned:
{
  "urls": ["https://example.com/changelog"],
  "alert": { "minScore": 25, "minChangedChars": 40 },
  "missingEnv": []
}

fetch_competitor_page succeeded for https://example.com/changelog.

diff_page_snapshot returned:
{
  "ok": true,
  "url": "https://example.com/changelog",
  "isBaseline": false,
  "changed": true,
  "score": 8,
  "changedChars": 12,
  "excerpt": "Added: typo",
  "clearsThreshold": false
}

The change did not clear the alert thresholds. Report that nothing will be delivered. Do not call preview_digest or send_digest.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("preview_digest").gate();
    t.notCalledTool("send_digest").gate();
    t.check(t.reply, includes("threshold").soft());
  },
});

```

### `agent/README.md`

````md
# Competitor Intel Monitor

Scheduled competitor URL monitor that diffs pages and delivers scored Slack or email digests.

It runs on a cron schedule, fetches each configured URL only when that origin's `robots.txt` allows it, diffs the page against a persistent snapshot store, and delivers a digest only when a change clears your alert thresholds.

## What it does

1. **Watch on a schedule** — `watch-competitor-pages` fires on `COMPETITOR_INTEL_CRON` (default `0 8 * * *` UTC).
2. **Fetch with robots.txt** — `fetch_competitor_page` loads `{origin}/robots.txt` first and skips disallowed or unreachable robots files.
3. **Diff against a store** — `diff_page_snapshot` compares normalized text to the last snapshot (JSON file or optional Upstash Redis) and writes the new snapshot.
4. **Gate on thresholds** — delivery happens only when `score >= COMPETITOR_INTEL_ALERT_MIN_SCORE` and `changedChars >= COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS`. First-seen pages are baselines, not alerts.
5. **Preview, then send** — `preview_digest` builds the Slack and email draft. `send_digest` requires `confirmSend: true` and a stable `idempotencyKey` so a replayed step does not duplicate delivery.

## Installation

```bash
npx shadcn@latest add @evex/competitor-intel-monitor
```

## Configuration

Copy `.env.example` into your Eve app environment.

### URL list and schedule

- `COMPETITOR_INTEL_URLS` — comma-separated `https` URLs to watch.
- `COMPETITOR_INTEL_URLS_FILE` — optional JSON (`{ "urls": [], "alert": { "minScore": 25, "minChangedChars": 40 } }`) or a text file with one URL per line. URLs are unioned with the env list.
- `COMPETITOR_INTEL_CRON` — 5-field cron (UTC on Vercel). Defaults to `0 8 * * *`.
- `COMPETITOR_INTEL_USER_AGENT` — sent on page and robots.txt fetches. Defaults to `EveCompetitorIntelMonitor/1.0`.

### Alert thresholds

- `COMPETITOR_INTEL_ALERT_MIN_SCORE` — 0–100. Score is `round(100 * changedChars / max(beforeLength, afterLength, 1))`. Defaults to `25`.
- `COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS` — minimum added+removed character weight. Defaults to `40`.

### Persistent store

- `COMPETITOR_INTEL_STORE_PATH` — JSON file for snapshots. Defaults to `.data/competitor-intel-store.json`. Use a durable volume in production.
- `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` — when both are set, snapshots are stored in Redis instead of the file.

### Optional Slack (Vercel Connect)

Uses the Eve Slack channel (`agent/channels/slack.ts`) with Vercel Connect.
Create a Slack connector and attach triggers to `/eve/v1/slack`
(`vercel connect create slack --triggers`, or `eve add channel/slack`).

- `COMPETITOR_INTEL_SLACK_CONNECT_UID` — Connect Slack connector UID.
- `COMPETITOR_INTEL_SLACK_CHANNEL_ID` — Slack channel id for the digest.

Leave either empty to skip Slack. `send_digest` still pauses for Eve
approval before the channel send.

### Email digest (Resend)

- `RESEND_API_KEY` — Resend API key.
- `COMPETITOR_INTEL_DIGEST_FROM` — sender address verified in Resend.
- `COMPETITOR_INTEL_DIGEST_TO` — comma-separated recipient addresses.
- `COMPETITOR_INTEL_DIGEST_SUBJECT` — subject prefix. Defaults to `Competitor intel digest`.

At least one delivery target (Slack Connect UID + channel id, or a complete email trio) is required before `send_digest` will send. Sending is two-step: `preview_digest`, then `send_digest` with `confirmSend: true` and an `idempotencyKey` such as `competitor-intel-monitor-YYYY-MM-DD`. `send_digest` always pauses for Eve human approval before Slack or Resend.

## Smoke test

1. Set at least one URL in `COMPETITOR_INTEL_URLS` and either Slack Connect (UID + channel id) or Resend + from/to.
2. Trigger the schedule in dev:

   ```bash
   curl -X POST http://localhost:3000/eve/v1/dev/schedules/watch-competitor-pages
   ```

3. First run stores baselines and should not send. A later run with a real page change that clears the thresholds should call `preview_digest`. Sending still requires `confirmSend: true`.

## Troubleshooting

- **`notConfigured: missingEnv COMPETITOR_INTEL_URLS`** — no https URLs in env or the config file.
- **`blockedByRobots`** — that origin's robots.txt disallows this user-agent, or robots.txt could not be fetched. The agent skips the page.
- **`clearsThreshold: false`** — the page changed but stayed under `COMPETITOR_INTEL_ALERT_MIN_SCORE` or `COMPETITOR_INTEL_ALERT_MIN_CHANGED_CHARS`.
- **`notConfirmed: true`** — `send_digest` was called without `confirmSend: true`.
- **Slack skipped** — `COMPETITOR_INTEL_SLACK_CONNECT_UID` or `COMPETITOR_INTEL_SLACK_CHANNEL_ID` is empty. That is optional when email is configured.
- **No Slack or email arrives** — the agent only sends after `preview_digest` and `send_digest` with `confirmSend: true`, and after Eve approval. Confirm the Connect UID and channel id, or that `COMPETITOR_INTEL_DIGEST_FROM` is a verified Resend sender.

## Development

```bash
pnpm install
pnpm test
pnpm typecheck
```

Run `pnpm info` to inspect the Eve surface and `pnpm eve:build` before opening a PR.

````
