From 112e2ddcc942e97fd7563666e4d41e24444ab34e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 16:30:11 -0500 Subject: [PATCH 01/11] Add settings.json to enable ralph-wiggum plugin --- .claude/settings.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..5dea8809 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "ralph-wiggum@claude-plugins-official": true + } +} From 342ab7834a1a8b2325256aabb761806be0bb7572 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 16:31:02 -0500 Subject: [PATCH 02/11] API - Email - CC logic for reply determination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LLM-based logic to determine if Recoup should reply when only CC'd (not in TO). When Recoup is only in the CC array, uses an LLM call to analyze the email context and decide if a reply is expected or if Recoup is just being kept in the loop. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/emails/inbound/getFromWithName.ts | 14 ++++-- lib/emails/inbound/isRecoupOnlyInCC.ts | 16 ++++++ lib/emails/inbound/respondToInboundEmail.ts | 29 ++++++++++- lib/emails/inbound/shouldReplyToCcEmail.ts | 56 +++++++++++++++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 lib/emails/inbound/isRecoupOnlyInCC.ts create mode 100644 lib/emails/inbound/shouldReplyToCcEmail.ts diff --git a/lib/emails/inbound/getFromWithName.ts b/lib/emails/inbound/getFromWithName.ts index cbd3b917..5e227dad 100644 --- a/lib/emails/inbound/getFromWithName.ts +++ b/lib/emails/inbound/getFromWithName.ts @@ -2,17 +2,23 @@ * Gets a formatted "from" email address with a human-readable name. * * @param toEmails - Array of email addresses from the 'to' field + * @param ccEmails - Optional array of email addresses from the 'cc' field (fallback) * @returns Formatted email address with display name (e.g., "Support ") - * @throws Error if no email ending with "@mail.recoupable.com" is found + * @throws Error if no email ending with "@mail.recoupable.com" is found in either array */ -export function getFromWithName(toEmails: string[]): string { +export function getFromWithName(toEmails: string[], ccEmails: string[] = []): string { // Find the first email in the 'to' array that ends with "@mail.recoupable.com" - const customFromEmail = toEmails.find(email => + let customFromEmail = toEmails.find(email => email.toLowerCase().endsWith("@mail.recoupable.com"), ); + // If not found in 'to', check the 'cc' array as fallback if (!customFromEmail) { - throw new Error("No email found ending with @mail.recoupable.com in the 'to' array"); + customFromEmail = ccEmails.find(email => email.toLowerCase().endsWith("@mail.recoupable.com")); + } + + if (!customFromEmail) { + throw new Error("No email found ending with @mail.recoupable.com in the 'to' or 'cc' array"); } // Extract the name part (everything before the @ sign) for a human-readable from name diff --git a/lib/emails/inbound/isRecoupOnlyInCC.ts b/lib/emails/inbound/isRecoupOnlyInCC.ts new file mode 100644 index 00000000..fa72cc86 --- /dev/null +++ b/lib/emails/inbound/isRecoupOnlyInCC.ts @@ -0,0 +1,16 @@ +/** + * Checks if a Recoup email address is only in the CC array (not in the TO array). + * + * @param to - Array of email addresses in the TO field + * @param cc - Array of email addresses in the CC field + * @returns true if a Recoup email is in CC but not in TO, false otherwise + */ +export function isRecoupOnlyInCC(to: string[], cc: string[]): boolean { + const recoupDomain = "@mail.recoupable.com"; + + const hasRecoupInTo = to.some(email => email.toLowerCase().endsWith(recoupDomain)); + + const hasRecoupInCC = cc.some(email => email.toLowerCase().endsWith(recoupDomain)); + + return hasRecoupInCC && !hasRecoupInTo; +} diff --git a/lib/emails/inbound/respondToInboundEmail.ts b/lib/emails/inbound/respondToInboundEmail.ts index 1e188c97..babd7810 100644 --- a/lib/emails/inbound/respondToInboundEmail.ts +++ b/lib/emails/inbound/respondToInboundEmail.ts @@ -7,6 +7,8 @@ import insertMemories from "@/lib/supabase/memories/insertMemories"; import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; import { validateNewEmailMemory } from "@/lib/emails/inbound/validateNewEmailMemory"; import { generateEmailResponse } from "@/lib/emails/inbound/generateEmailResponse"; +import { isRecoupOnlyInCC } from "@/lib/emails/inbound/isRecoupOnlyInCC"; +import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; /** * Responds to an inbound email by sending a hard-coded reply in the same thread. @@ -24,7 +26,7 @@ export async function respondToInboundEmail( const messageId = original.message_id; const to = original.from; const toArray = [to]; - const from = getFromWithName(original.to); + const from = getFromWithName(original.to, original.cc); // Validate new memory and get chat request body (or early return if duplicate) const validationResult = await validateNewEmailMemory(event); @@ -32,7 +34,30 @@ export async function respondToInboundEmail( return validationResult.response; } - const { chatRequestBody } = validationResult; + const { chatRequestBody, emailText } = validationResult; + + // Check if Recoup is only CC'd (not in TO) - use LLM to determine if reply is expected + if (isRecoupOnlyInCC(original.to, original.cc)) { + const shouldReply = await shouldReplyToCcEmail({ + from: original.from, + to: original.to, + cc: original.cc, + subject: original.subject, + body: emailText, + }); + + if (!shouldReply) { + console.log( + "[respondToInboundEmail] Recoup is only CC'd and no reply expected, skipping response", + ); + return NextResponse.json( + { message: "CC'd for visibility only, no reply sent" }, + { status: 200 }, + ); + } + + console.log("[respondToInboundEmail] Recoup is only CC'd but reply is expected, continuing"); + } const { roomId } = chatRequestBody; const { text, html } = await generateEmailResponse(chatRequestBody); diff --git a/lib/emails/inbound/shouldReplyToCcEmail.ts b/lib/emails/inbound/shouldReplyToCcEmail.ts new file mode 100644 index 00000000..597cdb7a --- /dev/null +++ b/lib/emails/inbound/shouldReplyToCcEmail.ts @@ -0,0 +1,56 @@ +import generateText from "@/lib/ai/generateText"; +import { LIGHTWEIGHT_MODEL } from "@/lib/const"; + +interface CcEmailContext { + from: string; + to: string[]; + cc: string[]; + subject: string; + body: string; +} + +/** + * Uses an LLM to determine if a reply is expected when Recoup is only CC'd on an email. + * + * When Recoup is CC'd (not in TO), we need to determine if the sender expects a reply + * from Recoup or if they're just keeping Recoup informed/in the loop. + * + * @param context - The email context including from, to, cc, subject, and body + * @returns true if a reply is expected, false if Recoup is just being CC'd for visibility + */ +export async function shouldReplyToCcEmail(context: CcEmailContext): Promise { + const { from, to, cc, subject, body } = context; + + const prompt = `You are analyzing an email where a Recoup AI assistant (@mail.recoupable.com) was CC'd but NOT directly addressed in the TO field. + +Determine if the sender expects a reply from the Recoup AI assistant, or if they're just CC'ing Recoup for visibility/record-keeping. + +Email details: +- From: ${from} +- To: ${to.join(", ")} +- CC: ${cc.join(", ")} +- Subject: ${subject} +- Body: ${body} + +Signs that a reply IS expected: +- The email body directly addresses or mentions Recoup/the AI assistant +- The sender asks a question that Recoup should answer +- The sender requests action or information from Recoup +- The context suggests Recoup's input is needed + +Signs that NO reply is expected (just CC'd for visibility): +- The email is a conversation between other parties +- Recoup is just being kept in the loop for record-keeping +- The email is informational with no action required from Recoup +- The message is addressed specifically to the TO recipients + +Respond with ONLY "true" if a reply is expected, or "false" if no reply is expected.`; + + const response = await generateText({ + prompt, + model: LIGHTWEIGHT_MODEL, + }); + + const result = response.text.trim().toLowerCase(); + return result === "true"; +} From 79bded5cc3af3ef2d02cbf0b6dbadbc54265d974 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 16:50:14 -0500 Subject: [PATCH 03/11] API - Email - Add unit tests and CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vitest for unit testing - Add test script to package.json - Create GitHub Actions workflow to run tests on PRs - Add unit tests for isRecoupOnlyInCC function (8 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 31 + .../__tests__/isRecoupOnlyInCC.test.ts | 54 ++ package.json | 7 +- pnpm-lock.yaml | 872 ++++++++++++++++++ 4 files changed, 962 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..da09286a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm test diff --git a/lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts b/lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts new file mode 100644 index 00000000..bdb7d65f --- /dev/null +++ b/lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { isRecoupOnlyInCC } from "../isRecoupOnlyInCC"; + +describe("isRecoupOnlyInCC", () => { + const recoupEmail = "hi@mail.recoupable.com"; + const otherRecoupEmail = "support@mail.recoupable.com"; + const externalEmail = "user@example.com"; + + it("returns true when Recoup is only in CC (not in TO)", () => { + const to = [externalEmail]; + const cc = [recoupEmail]; + expect(isRecoupOnlyInCC(to, cc)).toBe(true); + }); + + it("returns false when Recoup is in TO (even if also in CC)", () => { + const to = [recoupEmail]; + const cc = [recoupEmail]; + expect(isRecoupOnlyInCC(to, cc)).toBe(false); + }); + + it("returns false when Recoup is in TO and not in CC", () => { + const to = [recoupEmail]; + const cc = []; + expect(isRecoupOnlyInCC(to, cc)).toBe(false); + }); + + it("returns false when Recoup is not in either TO or CC", () => { + const to = [externalEmail]; + const cc = ["another@example.com"]; + expect(isRecoupOnlyInCC(to, cc)).toBe(false); + }); + + it("returns true when multiple recipients but Recoup only in CC", () => { + const to = [externalEmail, "another@example.com"]; + const cc = [recoupEmail, "third@example.com"]; + expect(isRecoupOnlyInCC(to, cc)).toBe(true); + }); + + it("handles case-insensitive email matching", () => { + const to = [externalEmail]; + const cc = ["HI@MAIL.RECOUPABLE.COM"]; + expect(isRecoupOnlyInCC(to, cc)).toBe(true); + }); + + it("returns false when empty arrays", () => { + expect(isRecoupOnlyInCC([], [])).toBe(false); + }); + + it("handles different Recoup email addresses", () => { + const to = [externalEmail]; + const cc = [otherRecoupEmail]; + expect(isRecoupOnlyInCC(to, cc)).toBe(true); + }); +}); diff --git a/package.json b/package.json index 6d0dfcef..9834cb65 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", "lint": "eslint . --ext .ts --fix", - "lint:check": "eslint . --ext .ts" + "lint:check": "eslint . --ext .ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@ai-sdk/mcp": "^0.0.12", @@ -56,6 +58,7 @@ "postcss": "^8", "prettier": "3.5.2", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e2db238..63bae2a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(jiti@1.21.7) packages: @@ -273,6 +276,162 @@ packages: resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==} engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -964,11 +1123,136 @@ packages: '@reown/appkit@1.7.8': resolution: {integrity: sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==} + '@rollup/rollup-android-arm-eabi@4.55.1': + resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.55.1': + resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.53.3': resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.55.1': + resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.55.1': + resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.55.1': + resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.55.1': + resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.55.1': + resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.55.1': + resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.55.1': + resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.55.1': + resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.55.1': + resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.55.1': + resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.55.1': + resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.55.1': + resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1588,6 +1872,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -1600,6 +1887,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1805,6 +2095,35 @@ packages: resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@wagmi/connectors@6.2.0': resolution: {integrity: sha512-2NfkbqhNWdjfibb4abRMrn7u6rPjEGolMfApXss6HCDVt9AW2oVC6k8Q5FouzpJezElxLJSagWz9FW1zaRlanA==} peerDependencies: @@ -2119,6 +2438,10 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -2252,6 +2575,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2286,6 +2613,10 @@ packages: caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2297,6 +2628,10 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2501,6 +2836,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2636,6 +2975,9 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2661,6 +3003,11 @@ packages: es6-promisify@5.0.0: resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -2800,6 +3147,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2853,6 +3203,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@7.5.1: resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} @@ -3431,6 +3785,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -3560,6 +3917,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3571,6 +3931,9 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3965,6 +4328,13 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -4322,6 +4692,11 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + rollup@4.55.1: + resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4431,6 +4806,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -4489,6 +4867,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -4577,6 +4958,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -4646,6 +5030,9 @@ packages: thread-stream@0.15.2: resolution: {integrity: sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -4653,6 +5040,18 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -4940,6 +5339,79 @@ packages: typescript: optional: true + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + wagmi@2.19.5: resolution: {integrity: sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ==} peerDependencies: @@ -4988,6 +5460,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5457,6 +5934,84 @@ snapshots: esquery: 1.6.0 jsdoc-type-pratt-parser: 4.1.0 + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))': dependencies: eslint: 9.39.1(jiti@1.21.7) @@ -6417,9 +6972,84 @@ snapshots: - utf-8-validate - zod + '@rollup/rollup-android-arm-eabi@4.55.1': + optional: true + + '@rollup/rollup-android-arm64@4.55.1': + optional: true + '@rollup/rollup-darwin-arm64@4.53.3': optional: true + '@rollup/rollup-darwin-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-x64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.55.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.55.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.55.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.55.1': + optional: true + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.15.0': {} @@ -7416,6 +8046,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 20.19.25 @@ -7430,6 +8065,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -7625,6 +8262,48 @@ snapshots: '@vercel/oidc@3.0.5': {} + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@20.19.25)(jiti@1.21.7))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@20.19.25)(jiti@1.21.7) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@wagmi/connectors@6.2.0(f24b5967e73156fd3352de3935505300)': dependencies: '@base-org/account': 2.4.0(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.1))(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) @@ -8480,6 +9159,8 @@ snapshots: assert-plus@1.0.0: {} + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -8624,6 +9305,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -8653,6 +9336,14 @@ snapshots: caseless@0.12.0: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8662,6 +9353,8 @@ snapshots: charenc@0.0.2: {} + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -8827,6 +9520,8 @@ snapshots: decode-uri-component@0.2.2: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -9045,6 +9740,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -9074,6 +9771,35 @@ snapshots: dependencies: es6-promise: 4.2.8 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} @@ -9295,6 +10021,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -9365,6 +10095,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@1.3.0: {} + express-rate-limit@7.5.1(express@5.2.1): dependencies: express: 5.2.1 @@ -10010,6 +10742,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -10144,12 +10878,18 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.2.4: {} luxon@3.7.2: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} mcp-handler@1.0.4(@modelcontextprotocol/sdk@1.24.3(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)): @@ -10575,6 +11315,10 @@ snapshots: path-to-regexp@8.3.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + performance-now@2.1.0: {} picocolors@1.1.1: {} @@ -10935,6 +11679,37 @@ snapshots: dependencies: glob: 10.5.0 + rollup@4.55.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.55.1 + '@rollup/rollup-android-arm64': 4.55.1 + '@rollup/rollup-darwin-arm64': 4.55.1 + '@rollup/rollup-darwin-x64': 4.55.1 + '@rollup/rollup-freebsd-arm64': 4.55.1 + '@rollup/rollup-freebsd-x64': 4.55.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 + '@rollup/rollup-linux-arm-musleabihf': 4.55.1 + '@rollup/rollup-linux-arm64-gnu': 4.55.1 + '@rollup/rollup-linux-arm64-musl': 4.55.1 + '@rollup/rollup-linux-loong64-gnu': 4.55.1 + '@rollup/rollup-linux-loong64-musl': 4.55.1 + '@rollup/rollup-linux-ppc64-gnu': 4.55.1 + '@rollup/rollup-linux-ppc64-musl': 4.55.1 + '@rollup/rollup-linux-riscv64-gnu': 4.55.1 + '@rollup/rollup-linux-riscv64-musl': 4.55.1 + '@rollup/rollup-linux-s390x-gnu': 4.55.1 + '@rollup/rollup-linux-x64-gnu': 4.55.1 + '@rollup/rollup-linux-x64-musl': 4.55.1 + '@rollup/rollup-openbsd-x64': 4.55.1 + '@rollup/rollup-openharmony-arm64': 4.55.1 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-ia32-msvc': 4.55.1 + '@rollup/rollup-win32-x64-gnu': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.55.1 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -11121,6 +11896,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slug@6.1.0: {} @@ -11210,6 +11987,8 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.2: {} @@ -11317,6 +12096,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + styled-jsx@5.1.6(react@19.2.1): dependencies: client-only: 0.0.1 @@ -11411,6 +12194,8 @@ snapshots: dependencies: real-require: 0.1.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} tinyglobby@0.2.15: @@ -11418,6 +12203,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + tldts-core@6.1.86: {} tldts@6.1.86: @@ -11723,6 +12514,82 @@ snapshots: - utf-8-validate - zod + vite-node@3.2.4(@types/node@20.19.25)(jiti@1.21.7): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@20.19.25)(jiti@1.21.7) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.0(@types/node@20.19.25)(jiti@1.21.7): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.55.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.25 + fsevents: 2.3.3 + jiti: 1.21.7 + + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(jiti@1.21.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@20.19.25)(jiti@1.21.7)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@20.19.25)(jiti@1.21.7) + vite-node: 3.2.4(@types/node@20.19.25)(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.25 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.40.3(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13): dependencies: '@tanstack/react-query': 5.90.11(react@19.2.1) @@ -11827,6 +12694,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: From 818d6f26228eec94b2a2c29cd9b6f995e7ac2cc5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 16:55:21 -0500 Subject: [PATCH 04/11] API - Email - Extract CC validation to separate module (SRP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move CC reply validation logic from respondToInboundEmail into its own validateCcReplyExpected module, following the same pattern as validateNewEmailMemory. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/emails/inbound/respondToInboundEmail.ts | 35 +++++------- lib/emails/inbound/validateCcReplyExpected.ts | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 lib/emails/inbound/validateCcReplyExpected.ts diff --git a/lib/emails/inbound/respondToInboundEmail.ts b/lib/emails/inbound/respondToInboundEmail.ts index babd7810..1b5d2d3d 100644 --- a/lib/emails/inbound/respondToInboundEmail.ts +++ b/lib/emails/inbound/respondToInboundEmail.ts @@ -7,8 +7,7 @@ import insertMemories from "@/lib/supabase/memories/insertMemories"; import filterMessageContentForMemories from "@/lib/messages/filterMessageContentForMemories"; import { validateNewEmailMemory } from "@/lib/emails/inbound/validateNewEmailMemory"; import { generateEmailResponse } from "@/lib/emails/inbound/generateEmailResponse"; -import { isRecoupOnlyInCC } from "@/lib/emails/inbound/isRecoupOnlyInCC"; -import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; +import { validateCcReplyExpected } from "@/lib/emails/inbound/validateCcReplyExpected"; /** * Responds to an inbound email by sending a hard-coded reply in the same thread. @@ -36,28 +35,18 @@ export async function respondToInboundEmail( const { chatRequestBody, emailText } = validationResult; - // Check if Recoup is only CC'd (not in TO) - use LLM to determine if reply is expected - if (isRecoupOnlyInCC(original.to, original.cc)) { - const shouldReply = await shouldReplyToCcEmail({ - from: original.from, - to: original.to, - cc: original.cc, - subject: original.subject, - body: emailText, - }); - - if (!shouldReply) { - console.log( - "[respondToInboundEmail] Recoup is only CC'd and no reply expected, skipping response", - ); - return NextResponse.json( - { message: "CC'd for visibility only, no reply sent" }, - { status: 200 }, - ); - } - - console.log("[respondToInboundEmail] Recoup is only CC'd but reply is expected, continuing"); + // Check if Recoup is only CC'd - use LLM to determine if reply is expected + const ccValidation = await validateCcReplyExpected({ + from: original.from, + to: original.to, + cc: original.cc, + subject: original.subject, + emailText, + }); + if (ccValidation) { + return ccValidation.response; } + const { roomId } = chatRequestBody; const { text, html } = await generateEmailResponse(chatRequestBody); diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts new file mode 100644 index 00000000..6e9e57cc --- /dev/null +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { isRecoupOnlyInCC } from "@/lib/emails/inbound/isRecoupOnlyInCC"; +import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; + +interface CcValidationParams { + from: string; + to: string[]; + cc: string[]; + subject: string; + emailText: string; +} + +/** + * Validates whether a reply should be sent when Recoup is CC'd on an email. + * + * When Recoup is only in the CC array (not in TO), this function uses an LLM + * to determine if a reply is expected or if Recoup is just being kept in the loop. + * + * @param params - The email context for CC validation + * @returns Either a NextResponse to early return (no reply needed) or null to continue + */ +export async function validateCcReplyExpected( + params: CcValidationParams, +): Promise<{ response: NextResponse } | null> { + const { from, to, cc, subject, emailText } = params; + + // If Recoup is in the TO array, no CC validation needed - continue with reply + if (!isRecoupOnlyInCC(to, cc)) { + return null; + } + + // Recoup is only CC'd - use LLM to determine if reply is expected + const shouldReply = await shouldReplyToCcEmail({ + from, + to, + cc, + subject, + body: emailText, + }); + + if (!shouldReply) { + console.log("[validateCcReplyExpected] Recoup is only CC'd and no reply expected, skipping"); + return { + response: NextResponse.json( + { message: "CC'd for visibility only, no reply sent" }, + { status: 200 }, + ), + }; + } + + console.log("[validateCcReplyExpected] Recoup is only CC'd but reply is expected, continuing"); + return null; +} From df6bb536c36181ca4e826593b610916bf2e4bdcc Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 16:57:41 -0500 Subject: [PATCH 05/11] API - Email - Simplify validateCcReplyExpected params (KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass original email data object directly instead of destructuring individual properties at the call site. Export ResendEmailData type for reuse. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/emails/inbound/respondToInboundEmail.ts | 8 +----- lib/emails/inbound/validateCcReplyExpected.ts | 27 +++++++------------ lib/emails/validateInboundEmailEvent.ts | 1 + 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/lib/emails/inbound/respondToInboundEmail.ts b/lib/emails/inbound/respondToInboundEmail.ts index 1b5d2d3d..1502b9d4 100644 --- a/lib/emails/inbound/respondToInboundEmail.ts +++ b/lib/emails/inbound/respondToInboundEmail.ts @@ -36,13 +36,7 @@ export async function respondToInboundEmail( const { chatRequestBody, emailText } = validationResult; // Check if Recoup is only CC'd - use LLM to determine if reply is expected - const ccValidation = await validateCcReplyExpected({ - from: original.from, - to: original.to, - cc: original.cc, - subject: original.subject, - emailText, - }); + const ccValidation = await validateCcReplyExpected(original, emailText); if (ccValidation) { return ccValidation.response; } diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts index 6e9e57cc..0aa4d1a1 100644 --- a/lib/emails/inbound/validateCcReplyExpected.ts +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -1,40 +1,33 @@ import { NextResponse } from "next/server"; +import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; import { isRecoupOnlyInCC } from "@/lib/emails/inbound/isRecoupOnlyInCC"; import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; -interface CcValidationParams { - from: string; - to: string[]; - cc: string[]; - subject: string; - emailText: string; -} - /** * Validates whether a reply should be sent when Recoup is CC'd on an email. * * When Recoup is only in the CC array (not in TO), this function uses an LLM * to determine if a reply is expected or if Recoup is just being kept in the loop. * - * @param params - The email context for CC validation + * @param original - The original email data from the Resend webhook + * @param emailText - The parsed email body text * @returns Either a NextResponse to early return (no reply needed) or null to continue */ export async function validateCcReplyExpected( - params: CcValidationParams, + original: ResendEmailData, + emailText: string, ): Promise<{ response: NextResponse } | null> { - const { from, to, cc, subject, emailText } = params; - // If Recoup is in the TO array, no CC validation needed - continue with reply - if (!isRecoupOnlyInCC(to, cc)) { + if (!isRecoupOnlyInCC(original.to, original.cc)) { return null; } // Recoup is only CC'd - use LLM to determine if reply is expected const shouldReply = await shouldReplyToCcEmail({ - from, - to, - cc, - subject, + from: original.from, + to: original.to, + cc: original.cc, + subject: original.subject, body: emailText, }); diff --git a/lib/emails/validateInboundEmailEvent.ts b/lib/emails/validateInboundEmailEvent.ts index 136adbb7..eae5b34c 100644 --- a/lib/emails/validateInboundEmailEvent.ts +++ b/lib/emails/validateInboundEmailEvent.ts @@ -29,6 +29,7 @@ export const resendEmailReceivedEventSchema = z.object({ }); export type ResendEmailReceivedEvent = z.infer; +export type ResendEmailData = z.infer; /** * Validates the inbound Resend email webhook event against the expected schema. From b60e1fe24826455fef266cf4e1526f19409f2ed8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 17:00:02 -0500 Subject: [PATCH 06/11] API - Email - DRY CcEmailContext type using ResendEmailData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Pick instead of duplicating field definitions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/emails/inbound/shouldReplyToCcEmail.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/emails/inbound/shouldReplyToCcEmail.ts b/lib/emails/inbound/shouldReplyToCcEmail.ts index 597cdb7a..5e1fb5ed 100644 --- a/lib/emails/inbound/shouldReplyToCcEmail.ts +++ b/lib/emails/inbound/shouldReplyToCcEmail.ts @@ -1,13 +1,8 @@ import generateText from "@/lib/ai/generateText"; import { LIGHTWEIGHT_MODEL } from "@/lib/const"; +import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; -interface CcEmailContext { - from: string; - to: string[]; - cc: string[]; - subject: string; - body: string; -} +type CcEmailContext = Pick & { body: string }; /** * Uses an LLM to determine if a reply is expected when Recoup is only CC'd on an email. From 87c00930c2752d1e9e854f86c383e38500fd326a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 17:05:27 -0500 Subject: [PATCH 07/11] API - Email - Simplify CC validation with KISS principle (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove isRecoupOnlyInCC helper - inline logic into validateCcReplyExpected - Add comprehensive unit tests for validateCcReplyExpected - Add vitest.config.ts with path aliases and env setup - Simplified logic: - Recoup in TO → always reply (no LLM) - Recoup only in CC → use LLM to determine - Recoup not in TO/CC → no reply 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../__tests__/isRecoupOnlyInCC.test.ts | 54 --------- .../__tests__/validateCcReplyExpected.test.ts | 106 ++++++++++++++++++ lib/emails/inbound/isRecoupOnlyInCC.ts | 16 --- lib/emails/inbound/validateCcReplyExpected.ts | 64 +++++++---- vitest.config.ts | 16 +++ 5 files changed, 162 insertions(+), 94 deletions(-) delete mode 100644 lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts create mode 100644 lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts delete mode 100644 lib/emails/inbound/isRecoupOnlyInCC.ts create mode 100644 vitest.config.ts diff --git a/lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts b/lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts deleted file mode 100644 index bdb7d65f..00000000 --- a/lib/emails/inbound/__tests__/isRecoupOnlyInCC.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { isRecoupOnlyInCC } from "../isRecoupOnlyInCC"; - -describe("isRecoupOnlyInCC", () => { - const recoupEmail = "hi@mail.recoupable.com"; - const otherRecoupEmail = "support@mail.recoupable.com"; - const externalEmail = "user@example.com"; - - it("returns true when Recoup is only in CC (not in TO)", () => { - const to = [externalEmail]; - const cc = [recoupEmail]; - expect(isRecoupOnlyInCC(to, cc)).toBe(true); - }); - - it("returns false when Recoup is in TO (even if also in CC)", () => { - const to = [recoupEmail]; - const cc = [recoupEmail]; - expect(isRecoupOnlyInCC(to, cc)).toBe(false); - }); - - it("returns false when Recoup is in TO and not in CC", () => { - const to = [recoupEmail]; - const cc = []; - expect(isRecoupOnlyInCC(to, cc)).toBe(false); - }); - - it("returns false when Recoup is not in either TO or CC", () => { - const to = [externalEmail]; - const cc = ["another@example.com"]; - expect(isRecoupOnlyInCC(to, cc)).toBe(false); - }); - - it("returns true when multiple recipients but Recoup only in CC", () => { - const to = [externalEmail, "another@example.com"]; - const cc = [recoupEmail, "third@example.com"]; - expect(isRecoupOnlyInCC(to, cc)).toBe(true); - }); - - it("handles case-insensitive email matching", () => { - const to = [externalEmail]; - const cc = ["HI@MAIL.RECOUPABLE.COM"]; - expect(isRecoupOnlyInCC(to, cc)).toBe(true); - }); - - it("returns false when empty arrays", () => { - expect(isRecoupOnlyInCC([], [])).toBe(false); - }); - - it("handles different Recoup email addresses", () => { - const to = [externalEmail]; - const cc = [otherRecoupEmail]; - expect(isRecoupOnlyInCC(to, cc)).toBe(true); - }); -}); diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts new file mode 100644 index 00000000..f433223f --- /dev/null +++ b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { validateCcReplyExpected } from "../validateCcReplyExpected"; +import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; + +// Mock the generateText function +vi.mock("@/lib/ai/generateText", () => ({ + default: vi.fn(), +})); + +import generateText from "@/lib/ai/generateText"; + +const mockGenerateText = vi.mocked(generateText); + +describe("validateCcReplyExpected", () => { + const baseEmailData: ResendEmailData = { + email_id: "test-123", + created_at: "2024-01-01T00:00:00Z", + from: "sender@example.com", + to: [], + cc: [], + bcc: [], + message_id: "", + subject: "Test Subject", + attachments: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when Recoup is in TO array", () => { + it("returns null (should reply) without calling LLM", async () => { + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: [], + }; + + const result = await validateCcReplyExpected(emailData, "Hello"); + + expect(result).toBeNull(); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + + it("returns null when Recoup is in both TO and CC", async () => { + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: ["hi@mail.recoupable.com"], + }; + + const result = await validateCcReplyExpected(emailData, "Hello"); + + expect(result).toBeNull(); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + }); + + describe("when Recoup is only in CC array", () => { + it("calls LLM and returns null when reply is expected", async () => { + mockGenerateText.mockResolvedValue({ text: "true" } as never); + + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: ["hi@mail.recoupable.com"], + }; + + const result = await validateCcReplyExpected(emailData, "Hey Recoup, can you help?"); + + expect(result).toBeNull(); + expect(mockGenerateText).toHaveBeenCalledTimes(1); + }); + + it("calls LLM and returns response when no reply expected", async () => { + mockGenerateText.mockResolvedValue({ text: "false" } as never); + + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: ["hi@mail.recoupable.com"], + }; + + const result = await validateCcReplyExpected(emailData, "FYI - keeping you in the loop"); + + expect(result).not.toBeNull(); + expect(result?.response).toBeDefined(); + expect(mockGenerateText).toHaveBeenCalledTimes(1); + }); + }); + + describe("when Recoup is not in TO or CC", () => { + it("returns response (no reply) without calling LLM", async () => { + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: ["another@example.com"], + }; + + const result = await validateCcReplyExpected(emailData, "Hello"); + + expect(result).not.toBeNull(); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/emails/inbound/isRecoupOnlyInCC.ts b/lib/emails/inbound/isRecoupOnlyInCC.ts deleted file mode 100644 index fa72cc86..00000000 --- a/lib/emails/inbound/isRecoupOnlyInCC.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Checks if a Recoup email address is only in the CC array (not in the TO array). - * - * @param to - Array of email addresses in the TO field - * @param cc - Array of email addresses in the CC field - * @returns true if a Recoup email is in CC but not in TO, false otherwise - */ -export function isRecoupOnlyInCC(to: string[], cc: string[]): boolean { - const recoupDomain = "@mail.recoupable.com"; - - const hasRecoupInTo = to.some(email => email.toLowerCase().endsWith(recoupDomain)); - - const hasRecoupInCC = cc.some(email => email.toLowerCase().endsWith(recoupDomain)); - - return hasRecoupInCC && !hasRecoupInTo; -} diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts index 0aa4d1a1..03099b65 100644 --- a/lib/emails/inbound/validateCcReplyExpected.ts +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -1,13 +1,15 @@ import { NextResponse } from "next/server"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; -import { isRecoupOnlyInCC } from "@/lib/emails/inbound/isRecoupOnlyInCC"; import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; +const RECOUP_DOMAIN = "@mail.recoupable.com"; + /** - * Validates whether a reply should be sent when Recoup is CC'd on an email. + * Validates whether a reply should be sent based on Recoup's position in TO/CC. * - * When Recoup is only in the CC array (not in TO), this function uses an LLM - * to determine if a reply is expected or if Recoup is just being kept in the loop. + * - If Recoup is in TO → always reply (no LLM call) + * - If Recoup is only in CC → use LLM to determine if reply expected + * - If Recoup is not in TO or CC → no reply * * @param original - The original email data from the Resend webhook * @param emailText - The parsed email body text @@ -17,30 +19,44 @@ export async function validateCcReplyExpected( original: ResendEmailData, emailText: string, ): Promise<{ response: NextResponse } | null> { - // If Recoup is in the TO array, no CC validation needed - continue with reply - if (!isRecoupOnlyInCC(original.to, original.cc)) { + const isInTo = original.to.some(email => email.toLowerCase().endsWith(RECOUP_DOMAIN)); + const isInCc = original.cc.some(email => email.toLowerCase().endsWith(RECOUP_DOMAIN)); + + // Recoup is in TO → always reply + if (isInTo) { return null; } - // Recoup is only CC'd - use LLM to determine if reply is expected - const shouldReply = await shouldReplyToCcEmail({ - from: original.from, - to: original.to, - cc: original.cc, - subject: original.subject, - body: emailText, - }); + // Recoup is in CC (but not TO) → use LLM to determine if reply expected + if (isInCc) { + const shouldReply = await shouldReplyToCcEmail({ + from: original.from, + to: original.to, + cc: original.cc, + subject: original.subject, + body: emailText, + }); - if (!shouldReply) { - console.log("[validateCcReplyExpected] Recoup is only CC'd and no reply expected, skipping"); - return { - response: NextResponse.json( - { message: "CC'd for visibility only, no reply sent" }, - { status: 200 }, - ), - }; + if (!shouldReply) { + console.log("[validateCcReplyExpected] Recoup is only CC'd and no reply expected, skipping"); + return { + response: NextResponse.json( + { message: "CC'd for visibility only, no reply sent" }, + { status: 200 }, + ), + }; + } + + console.log("[validateCcReplyExpected] Recoup is only CC'd but reply is expected, continuing"); + return null; } - console.log("[validateCcReplyExpected] Recoup is only CC'd but reply is expected, continuing"); - return null; + // Recoup is not in TO or CC → no reply + console.log("[validateCcReplyExpected] Recoup not found in TO or CC, skipping"); + return { + response: NextResponse.json( + { message: "Recoup not addressed, no reply sent" }, + { status: 200 }, + ), + }; } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..9b3318a0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + globals: true, + env: { + PRIVY_PROJECT_SECRET: "test-secret", + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./"), + }, + }, +}); From 29ea1146b0d02289386ac38b2086bb63a02df759 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 17:07:18 -0500 Subject: [PATCH 08/11] API - Email - Always delegate to shouldReplyToCcEmail (KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify validateCcReplyExpected to always call shouldReplyToCcEmail. The LLM prompt now handles all cases: - Recoup in TO → always reply - Recoup only in CC → LLM decides based on context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateCcReplyExpected.test.ts | 117 ++++++++---------- lib/emails/inbound/shouldReplyToCcEmail.ts | 33 ++--- lib/emails/inbound/validateCcReplyExpected.ts | 58 +++------ 3 files changed, 78 insertions(+), 130 deletions(-) diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts index f433223f..0ecbeba1 100644 --- a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts +++ b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { validateCcReplyExpected } from "../validateCcReplyExpected"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; -// Mock the generateText function -vi.mock("@/lib/ai/generateText", () => ({ - default: vi.fn(), +// Mock the shouldReplyToCcEmail function +vi.mock("@/lib/emails/inbound/shouldReplyToCcEmail", () => ({ + shouldReplyToCcEmail: vi.fn(), })); -import generateText from "@/lib/ai/generateText"; +import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; -const mockGenerateText = vi.mocked(generateText); +const mockShouldReply = vi.mocked(shouldReplyToCcEmail); describe("validateCcReplyExpected", () => { const baseEmailData: ResendEmailData = { @@ -28,79 +28,68 @@ describe("validateCcReplyExpected", () => { vi.clearAllMocks(); }); - describe("when Recoup is in TO array", () => { - it("returns null (should reply) without calling LLM", async () => { - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: [], - }; + it("always calls shouldReplyToCcEmail regardless of TO/CC", async () => { + mockShouldReply.mockResolvedValue(true); - const result = await validateCcReplyExpected(emailData, "Hello"); + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: [], + }; - expect(result).toBeNull(); - expect(mockGenerateText).not.toHaveBeenCalled(); - }); - - it("returns null when Recoup is in both TO and CC", async () => { - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["hi@mail.recoupable.com"], - cc: ["hi@mail.recoupable.com"], - }; - - const result = await validateCcReplyExpected(emailData, "Hello"); + await validateCcReplyExpected(emailData, "Hello"); - expect(result).toBeNull(); - expect(mockGenerateText).not.toHaveBeenCalled(); - }); + expect(mockShouldReply).toHaveBeenCalledTimes(1); }); - describe("when Recoup is only in CC array", () => { - it("calls LLM and returns null when reply is expected", async () => { - mockGenerateText.mockResolvedValue({ text: "true" } as never); + it("returns null when shouldReplyToCcEmail returns true", async () => { + mockShouldReply.mockResolvedValue(true); - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["someone@example.com"], - cc: ["hi@mail.recoupable.com"], - }; + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["hi@mail.recoupable.com"], + cc: [], + }; - const result = await validateCcReplyExpected(emailData, "Hey Recoup, can you help?"); + const result = await validateCcReplyExpected(emailData, "Hello"); - expect(result).toBeNull(); - expect(mockGenerateText).toHaveBeenCalledTimes(1); - }); + expect(result).toBeNull(); + }); - it("calls LLM and returns response when no reply expected", async () => { - mockGenerateText.mockResolvedValue({ text: "false" } as never); + it("returns response when shouldReplyToCcEmail returns false", async () => { + mockShouldReply.mockResolvedValue(false); - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["someone@example.com"], - cc: ["hi@mail.recoupable.com"], - }; + const emailData: ResendEmailData = { + ...baseEmailData, + to: ["someone@example.com"], + cc: ["hi@mail.recoupable.com"], + }; - const result = await validateCcReplyExpected(emailData, "FYI - keeping you in the loop"); + const result = await validateCcReplyExpected(emailData, "FYI"); - expect(result).not.toBeNull(); - expect(result?.response).toBeDefined(); - expect(mockGenerateText).toHaveBeenCalledTimes(1); - }); + expect(result).not.toBeNull(); + expect(result?.response).toBeDefined(); }); - describe("when Recoup is not in TO or CC", () => { - it("returns response (no reply) without calling LLM", async () => { - const emailData: ResendEmailData = { - ...baseEmailData, - to: ["someone@example.com"], - cc: ["another@example.com"], - }; - - const result = await validateCcReplyExpected(emailData, "Hello"); - - expect(result).not.toBeNull(); - expect(mockGenerateText).not.toHaveBeenCalled(); + it("passes correct params to shouldReplyToCcEmail", async () => { + mockShouldReply.mockResolvedValue(true); + + const emailData: ResendEmailData = { + ...baseEmailData, + from: "test@example.com", + to: ["hi@mail.recoupable.com"], + cc: ["cc@example.com"], + subject: "Test", + }; + + await validateCcReplyExpected(emailData, "Email body"); + + expect(mockShouldReply).toHaveBeenCalledWith({ + from: "test@example.com", + to: ["hi@mail.recoupable.com"], + cc: ["cc@example.com"], + subject: "Test", + body: "Email body", }); }); }); diff --git a/lib/emails/inbound/shouldReplyToCcEmail.ts b/lib/emails/inbound/shouldReplyToCcEmail.ts index 5e1fb5ed..1bd48df8 100644 --- a/lib/emails/inbound/shouldReplyToCcEmail.ts +++ b/lib/emails/inbound/shouldReplyToCcEmail.ts @@ -2,23 +2,18 @@ import generateText from "@/lib/ai/generateText"; import { LIGHTWEIGHT_MODEL } from "@/lib/const"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; -type CcEmailContext = Pick & { body: string }; +type EmailContext = Pick & { body: string }; /** - * Uses an LLM to determine if a reply is expected when Recoup is only CC'd on an email. - * - * When Recoup is CC'd (not in TO), we need to determine if the sender expects a reply - * from Recoup or if they're just keeping Recoup informed/in the loop. + * Uses an LLM to determine if a reply is expected from the Recoup AI assistant. * * @param context - The email context including from, to, cc, subject, and body - * @returns true if a reply is expected, false if Recoup is just being CC'd for visibility + * @returns true if a reply is expected, false otherwise */ -export async function shouldReplyToCcEmail(context: CcEmailContext): Promise { +export async function shouldReplyToCcEmail(context: EmailContext): Promise { const { from, to, cc, subject, body } = context; - const prompt = `You are analyzing an email where a Recoup AI assistant (@mail.recoupable.com) was CC'd but NOT directly addressed in the TO field. - -Determine if the sender expects a reply from the Recoup AI assistant, or if they're just CC'ing Recoup for visibility/record-keeping. + const prompt = `You are analyzing an email to determine if a Recoup AI assistant (@mail.recoupable.com) should reply. Email details: - From: ${from} @@ -27,19 +22,13 @@ Email details: - Subject: ${subject} - Body: ${body} -Signs that a reply IS expected: -- The email body directly addresses or mentions Recoup/the AI assistant -- The sender asks a question that Recoup should answer -- The sender requests action or information from Recoup -- The context suggests Recoup's input is needed - -Signs that NO reply is expected (just CC'd for visibility): -- The email is a conversation between other parties -- Recoup is just being kept in the loop for record-keeping -- The email is informational with no action required from Recoup -- The message is addressed specifically to the TO recipients +Rules: +1. If a Recoup address (@mail.recoupable.com) is in the TO field → ALWAYS reply (return "true") +2. If a Recoup address is ONLY in CC (not in TO): + - Return "true" if the email directly addresses Recoup or asks for its input + - Return "false" if Recoup is just being kept in the loop for visibility -Respond with ONLY "true" if a reply is expected, or "false" if no reply is expected.`; +Respond with ONLY "true" or "false".`; const response = await generateText({ prompt, diff --git a/lib/emails/inbound/validateCcReplyExpected.ts b/lib/emails/inbound/validateCcReplyExpected.ts index 03099b65..25410b0a 100644 --- a/lib/emails/inbound/validateCcReplyExpected.ts +++ b/lib/emails/inbound/validateCcReplyExpected.ts @@ -2,14 +2,8 @@ import { NextResponse } from "next/server"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; -const RECOUP_DOMAIN = "@mail.recoupable.com"; - /** - * Validates whether a reply should be sent based on Recoup's position in TO/CC. - * - * - If Recoup is in TO → always reply (no LLM call) - * - If Recoup is only in CC → use LLM to determine if reply expected - * - If Recoup is not in TO or CC → no reply + * Validates whether a reply should be sent by delegating to shouldReplyToCcEmail. * * @param original - The original email data from the Resend webhook * @param emailText - The parsed email body text @@ -19,44 +13,20 @@ export async function validateCcReplyExpected( original: ResendEmailData, emailText: string, ): Promise<{ response: NextResponse } | null> { - const isInTo = original.to.some(email => email.toLowerCase().endsWith(RECOUP_DOMAIN)); - const isInCc = original.cc.some(email => email.toLowerCase().endsWith(RECOUP_DOMAIN)); - - // Recoup is in TO → always reply - if (isInTo) { - return null; - } - - // Recoup is in CC (but not TO) → use LLM to determine if reply expected - if (isInCc) { - const shouldReply = await shouldReplyToCcEmail({ - from: original.from, - to: original.to, - cc: original.cc, - subject: original.subject, - body: emailText, - }); - - if (!shouldReply) { - console.log("[validateCcReplyExpected] Recoup is only CC'd and no reply expected, skipping"); - return { - response: NextResponse.json( - { message: "CC'd for visibility only, no reply sent" }, - { status: 200 }, - ), - }; - } + const shouldReply = await shouldReplyToCcEmail({ + from: original.from, + to: original.to, + cc: original.cc, + subject: original.subject, + body: emailText, + }); - console.log("[validateCcReplyExpected] Recoup is only CC'd but reply is expected, continuing"); - return null; + if (!shouldReply) { + console.log("[validateCcReplyExpected] No reply expected, skipping"); + return { + response: NextResponse.json({ message: "No reply expected" }, { status: 200 }), + }; } - // Recoup is not in TO or CC → no reply - console.log("[validateCcReplyExpected] Recoup not found in TO or CC, skipping"); - return { - response: NextResponse.json( - { message: "Recoup not addressed, no reply sent" }, - { status: 200 }, - ), - }; + return null; } From 9606e636a06428380b8b0f3341443e15a2abeb1d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 17:14:38 -0500 Subject: [PATCH 09/11] API - Email - Use ToolLoopAgent with structured output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update shouldReplyToCcEmail to use ToolLoopAgent with Output.object() for structured output, consistent with other agents in the codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateCcReplyExpected.test.ts | 53 ++++++++++--------- lib/emails/inbound/shouldReplyToCcEmail.ts | 44 ++++++++------- 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts index 0ecbeba1..e23f98eb 100644 --- a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts +++ b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts @@ -2,15 +2,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { validateCcReplyExpected } from "../validateCcReplyExpected"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; -// Mock the shouldReplyToCcEmail function -vi.mock("@/lib/emails/inbound/shouldReplyToCcEmail", () => ({ - shouldReplyToCcEmail: vi.fn(), +const mockGenerate = vi.fn(); + +// Mock the ai module +vi.mock("ai", () => ({ + Output: { object: vi.fn() }, + ToolLoopAgent: vi.fn().mockImplementation(() => ({ + generate: mockGenerate, + })), + stepCountIs: vi.fn(), })); -import { shouldReplyToCcEmail } from "@/lib/emails/inbound/shouldReplyToCcEmail"; - -const mockShouldReply = vi.mocked(shouldReplyToCcEmail); - describe("validateCcReplyExpected", () => { const baseEmailData: ResendEmailData = { email_id: "test-123", @@ -28,8 +30,8 @@ describe("validateCcReplyExpected", () => { vi.clearAllMocks(); }); - it("always calls shouldReplyToCcEmail regardless of TO/CC", async () => { - mockShouldReply.mockResolvedValue(true); + it("always calls agent.generate regardless of TO/CC", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); const emailData: ResendEmailData = { ...baseEmailData, @@ -39,11 +41,11 @@ describe("validateCcReplyExpected", () => { await validateCcReplyExpected(emailData, "Hello"); - expect(mockShouldReply).toHaveBeenCalledTimes(1); + expect(mockGenerate).toHaveBeenCalledTimes(1); }); - it("returns null when shouldReplyToCcEmail returns true", async () => { - mockShouldReply.mockResolvedValue(true); + it("returns null when agent returns shouldReply: true", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); const emailData: ResendEmailData = { ...baseEmailData, @@ -56,8 +58,8 @@ describe("validateCcReplyExpected", () => { expect(result).toBeNull(); }); - it("returns response when shouldReplyToCcEmail returns false", async () => { - mockShouldReply.mockResolvedValue(false); + it("returns response when agent returns shouldReply: false", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: false } }); const emailData: ResendEmailData = { ...baseEmailData, @@ -71,25 +73,28 @@ describe("validateCcReplyExpected", () => { expect(result?.response).toBeDefined(); }); - it("passes correct params to shouldReplyToCcEmail", async () => { - mockShouldReply.mockResolvedValue(true); + it("passes email context in prompt to agent.generate", async () => { + mockGenerate.mockResolvedValue({ output: { shouldReply: true } }); const emailData: ResendEmailData = { ...baseEmailData, from: "test@example.com", to: ["hi@mail.recoupable.com"], cc: ["cc@example.com"], - subject: "Test", + subject: "Test Subject", }; await validateCcReplyExpected(emailData, "Email body"); - expect(mockShouldReply).toHaveBeenCalledWith({ - from: "test@example.com", - to: ["hi@mail.recoupable.com"], - cc: ["cc@example.com"], - subject: "Test", - body: "Email body", - }); + expect(mockGenerate).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("test@example.com"), + }), + ); + expect(mockGenerate).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("Email body"), + }), + ); }); }); diff --git a/lib/emails/inbound/shouldReplyToCcEmail.ts b/lib/emails/inbound/shouldReplyToCcEmail.ts index 1bd48df8..243094ca 100644 --- a/lib/emails/inbound/shouldReplyToCcEmail.ts +++ b/lib/emails/inbound/shouldReplyToCcEmail.ts @@ -1,11 +1,16 @@ -import generateText from "@/lib/ai/generateText"; +import { Output, ToolLoopAgent, stepCountIs } from "ai"; +import { z } from "zod"; import { LIGHTWEIGHT_MODEL } from "@/lib/const"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; type EmailContext = Pick & { body: string }; +const replyDecisionSchema = z.object({ + shouldReply: z.boolean().describe("Whether the Recoup AI assistant should reply to this email"), +}); + /** - * Uses an LLM to determine if a reply is expected from the Recoup AI assistant. + * Uses an agent to determine if a reply is expected from the Recoup AI assistant. * * @param context - The email context including from, to, cc, subject, and body * @returns true if a reply is expected, false otherwise @@ -13,28 +18,29 @@ type EmailContext = Pick & { export async function shouldReplyToCcEmail(context: EmailContext): Promise { const { from, to, cc, subject, body } = context; - const prompt = `You are analyzing an email to determine if a Recoup AI assistant (@mail.recoupable.com) should reply. - -Email details: -- From: ${from} -- To: ${to.join(", ")} -- CC: ${cc.join(", ")} -- Subject: ${subject} -- Body: ${body} + const instructions = `You analyze emails to determine if a Recoup AI assistant (@mail.recoupable.com) should reply. Rules: -1. If a Recoup address (@mail.recoupable.com) is in the TO field → ALWAYS reply (return "true") +1. If a Recoup address (@mail.recoupable.com) is in the TO field → ALWAYS reply 2. If a Recoup address is ONLY in CC (not in TO): - - Return "true" if the email directly addresses Recoup or asks for its input - - Return "false" if Recoup is just being kept in the loop for visibility - -Respond with ONLY "true" or "false".`; + - Reply if the email directly addresses Recoup or asks for its input + - Do NOT reply if Recoup is just being kept in the loop for visibility`; - const response = await generateText({ - prompt, + const agent = new ToolLoopAgent({ model: LIGHTWEIGHT_MODEL, + instructions, + output: Output.object({ schema: replyDecisionSchema }), + stopWhen: stepCountIs(1), }); - const result = response.text.trim().toLowerCase(); - return result === "true"; + const prompt = `Analyze this email: +- From: ${from} +- To: ${to.join(", ")} +- CC: ${cc.join(", ")} +- Subject: ${subject} +- Body: ${body}`; + + const { output } = await agent.generate({ prompt }); + + return output.shouldReply; } From ae8fca2ddc2b3ec88bf0ca9fa981428f3de2c210 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 5 Jan 2026 17:21:50 -0500 Subject: [PATCH 10/11] API - Email - Prioritize explicit opt-out over TO field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update LLM prompt to check body/subject for "don't reply" FIRST before considering TO/CC placement. When in doubt, return false. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/emails/inbound/shouldReplyToCcEmail.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/emails/inbound/shouldReplyToCcEmail.ts b/lib/emails/inbound/shouldReplyToCcEmail.ts index 243094ca..4ac260e8 100644 --- a/lib/emails/inbound/shouldReplyToCcEmail.ts +++ b/lib/emails/inbound/shouldReplyToCcEmail.ts @@ -20,11 +20,11 @@ export async function shouldReplyToCcEmail(context: EmailContext): Promise Date: Mon, 5 Jan 2026 17:30:46 -0500 Subject: [PATCH 11/11] API - Email - Extract EmailReplyAgent to lib/agents (SRP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move agent definition from shouldReplyToCcEmail to dedicated lib/agents/EmailReplyAgent module, following existing agent patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../EmailReplyAgent/createEmailReplyAgent.ts | 29 +++++++++++++++++++ lib/agents/EmailReplyAgent/index.ts | 1 + .../__tests__/validateCcReplyExpected.test.ts | 8 ++--- lib/emails/inbound/shouldReplyToCcEmail.ts | 23 ++------------- 4 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 lib/agents/EmailReplyAgent/createEmailReplyAgent.ts create mode 100644 lib/agents/EmailReplyAgent/index.ts diff --git a/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts new file mode 100644 index 00000000..a6b25cbd --- /dev/null +++ b/lib/agents/EmailReplyAgent/createEmailReplyAgent.ts @@ -0,0 +1,29 @@ +import { Output, ToolLoopAgent, stepCountIs } from "ai"; +import { z } from "zod"; +import { LIGHTWEIGHT_MODEL } from "@/lib/const"; + +const replyDecisionSchema = z.object({ + shouldReply: z.boolean().describe("Whether the Recoup AI assistant should reply to this email"), +}); + +const instructions = `You analyze emails to determine if a Recoup AI assistant (@mail.recoupable.com) should reply. + +Rules (check in this order): +1. FIRST check the body/subject: If the sender explicitly asks NOT to reply (e.g., "don't reply", "do not reply", "stop replying", "no response needed") → return false +2. If Recoup is in TO and the email asks a question or requests help → return true +3. If Recoup is ONLY in CC: return true only if directly addressed, otherwise return false +4. When in doubt, return false`; + +/** + * Creates a ToolLoopAgent configured for email reply decisions. + * + * @returns A configured ToolLoopAgent instance for determining if a reply is needed. + */ +export function createEmailReplyAgent() { + return new ToolLoopAgent({ + model: LIGHTWEIGHT_MODEL, + instructions, + output: Output.object({ schema: replyDecisionSchema }), + stopWhen: stepCountIs(1), + }); +} diff --git a/lib/agents/EmailReplyAgent/index.ts b/lib/agents/EmailReplyAgent/index.ts new file mode 100644 index 00000000..f4a3973d --- /dev/null +++ b/lib/agents/EmailReplyAgent/index.ts @@ -0,0 +1 @@ +export { createEmailReplyAgent } from "./createEmailReplyAgent"; diff --git a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts index e23f98eb..08eaacd8 100644 --- a/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts +++ b/lib/emails/inbound/__tests__/validateCcReplyExpected.test.ts @@ -4,13 +4,11 @@ import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; const mockGenerate = vi.fn(); -// Mock the ai module -vi.mock("ai", () => ({ - Output: { object: vi.fn() }, - ToolLoopAgent: vi.fn().mockImplementation(() => ({ +// Mock the EmailReplyAgent +vi.mock("@/lib/agents/EmailReplyAgent", () => ({ + createEmailReplyAgent: vi.fn().mockImplementation(() => ({ generate: mockGenerate, })), - stepCountIs: vi.fn(), })); describe("validateCcReplyExpected", () => { diff --git a/lib/emails/inbound/shouldReplyToCcEmail.ts b/lib/emails/inbound/shouldReplyToCcEmail.ts index 4ac260e8..c2829c0c 100644 --- a/lib/emails/inbound/shouldReplyToCcEmail.ts +++ b/lib/emails/inbound/shouldReplyToCcEmail.ts @@ -1,14 +1,8 @@ -import { Output, ToolLoopAgent, stepCountIs } from "ai"; -import { z } from "zod"; -import { LIGHTWEIGHT_MODEL } from "@/lib/const"; +import { createEmailReplyAgent } from "@/lib/agents/EmailReplyAgent"; import type { ResendEmailData } from "@/lib/emails/validateInboundEmailEvent"; type EmailContext = Pick & { body: string }; -const replyDecisionSchema = z.object({ - shouldReply: z.boolean().describe("Whether the Recoup AI assistant should reply to this email"), -}); - /** * Uses an agent to determine if a reply is expected from the Recoup AI assistant. * @@ -18,20 +12,7 @@ const replyDecisionSchema = z.object({ export async function shouldReplyToCcEmail(context: EmailContext): Promise { const { from, to, cc, subject, body } = context; - const instructions = `You analyze emails to determine if a Recoup AI assistant (@mail.recoupable.com) should reply. - -Rules (check in this order): -1. FIRST check the body/subject: If the sender explicitly asks NOT to reply (e.g., "don't reply", "do not reply", "stop replying", "no response needed") → return false -2. If Recoup is in TO and the email asks a question or requests help → return true -3. If Recoup is ONLY in CC: return true only if directly addressed, otherwise return false -4. When in doubt, return false`; - - const agent = new ToolLoopAgent({ - model: LIGHTWEIGHT_MODEL, - instructions, - output: Output.object({ schema: replyDecisionSchema }), - stopWhen: stepCountIs(1), - }); + const agent = createEmailReplyAgent(); const prompt = `Analyze this email: - From: ${from}