10 Commits

Author SHA1 Message Date
38c7e48a1d Merge pull request 'update prompt controller and loader to use new prompt files' (#5) from FEAT-update-prompt-config-loader into master
Reviewed-on: kjannette/deClawed-Assity-Kitty#5
2026-08-12 00:53:20 +00:00
KS Jannette
6817014b7f update prompt controller and loader to use new prompt files 2026-08-11 20:52:43 -04:00
dade7f1fb8 Merge pull request 'update tools and prompts to add functionality for 'starring' emails' (#4) from FEAT-adjust-prompts-and-tools into master
Reviewed-on: kjannette/deClawed-Assity-Kitty#4
2026-08-11 23:29:01 +00:00
KS Jannette
632b0029be update tools and prompts to add functionality for 'starring' emails 2026-08-11 18:52:28 -04:00
549f2b0949 Update .gitignore 2026-08-11 07:08:49 +00:00
c1f03a7cbe Delete 2026-08-11 07:07:43 +00:00
KS Jannette
b767a1f09c hotfix minor prompt ambiguity 2026-08-11 02:25:56 -04:00
76d4b490bf Merge pull request 'upped maximum number of emails that can be fetched for review' (#3) from FEAT-increase-max-email-fetch into master
Reviewed-on: kjannette/deClawed-Assity-Kitty#3
2026-08-11 06:17:17 +00:00
KS Jannette
31618b1acf upped maximum number of emails that can be fetched for review 2026-08-11 02:12:43 -04:00
087839d2bf Merge pull request 'update README' (#2) from FEAT-connector-refinements into master
Reviewed-on: kjannette/deClawed-Assity-Kitty#2
2026-07-30 22:49:31 +00:00
8 changed files with 434 additions and 388 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ build/
accountsAndCredentials/credentials.json
accountsAndCredentials/accounts.json
accountsAndCredentials/token*.json
accountsAndCredentials/token.json.bak
!accountsAndCredentials/*.example.json
# Runtime output

10
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"googleapis": "^171.4.0",
"zod": "^3.25.76"
},
@@ -501,12 +501,12 @@
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",

View File

@@ -19,7 +19,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"googleapis": "^171.4.0",
"zod": "^3.25.76"
},

View File

@@ -3,11 +3,13 @@ import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
import { OAuth2Client } from "google-auth-library";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
@@ -20,130 +22,130 @@ const ACTION_PROMPT_PATH = path.join(PROMPTS_DIR, "take-action-on-emails.txt");
// Account configuration
// ---------------------------------------------------------------------------
export interface AccountConfig {
label: string;
tokenFile: string;
spreadsheetId?: string;
calendarId?: string;
label: string;
tokenFile: string;
spreadsheetId?: string;
calendarId?: string;
}
export interface AccountsMap {
[key: string]: AccountConfig;
[key: string]: AccountConfig;
}
export const loadAccounts = (): AccountsMap => {
if (!fs.existsSync(ACCOUNTS_PATH)) {
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
}
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
if (!fs.existsSync(ACCOUNTS_PATH)) {
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
}
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
};
export const getTokenPath = (account: string): string => {
const accts = loadAccounts();
const acct = accts[account];
if (!acct) {
const available = Object.keys(accts).join(", ");
throw new Error(
`Unknown account "${account}". Available accounts: ${available}`
);
}
return path.join(SECRETS_DIR, acct.tokenFile);
const accts = loadAccounts();
const acct = accts[account];
if (!acct) {
const available = Object.keys(accts).join(", ");
throw new Error(
`Unknown account "${account}". Available accounts: ${available}`
);
}
return path.join(SECRETS_DIR, acct.tokenFile);
};
export const getSummaryPath = (account: string): string => {
if (account === "work") {
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
}
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
if (account === "work") {
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
}
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
};
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
export const accounts = loadAccounts();
const accountDescription = VALID_ACCOUNTS
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
.join(" or ");
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
.join(" or ");
export const accountSchema = z
.enum(VALID_ACCOUNTS)
.describe(`Which email account to use: ${accountDescription}`);
.enum(VALID_ACCOUNTS)
.describe(`Which email account to use: ${accountDescription}`);
// ---------------------------------------------------------------------------
// Prompt loaders
// ---------------------------------------------------------------------------
const loadPromptFile = (filePath: string, label: string): string => {
if (!fs.existsSync(filePath)) {
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
return "";
}
return fs.readFileSync(filePath, "utf-8");
if (!fs.existsSync(filePath)) {
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
return "";
}
return fs.readFileSync(filePath, "utf-8");
};
export const loadClassificationPrompt = (): string =>
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
export const loadActionPrompt = (): string =>
loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
loadPromptFile(ACTION_PROMPT_PATH, "Action instructions");
// ---------------------------------------------------------------------------
// Auth helpers — generic OAuth2 client, then service-specific factories
// ---------------------------------------------------------------------------
export const getOAuth2Client = (account: string): OAuth2Client => {
const tokenPath = getTokenPath(account);
const tokenPath = getTokenPath(account);
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error(
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error(
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
);
}
if (!fs.existsSync(tokenPath)) {
throw new Error(
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
);
}
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
const { client_id, client_secret, redirect_uris } =
credentials.installed || credentials.web;
const oAuth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
}
if (!fs.existsSync(tokenPath)) {
throw new Error(
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
);
}
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
const { client_id, client_secret, redirect_uris } =
credentials.installed || credentials.web;
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
oAuth2Client.setCredentials(token);
const oAuth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris[0]
);
oAuth2Client.on("tokens", (newTokens) => {
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
fs.writeFileSync(
tokenPath,
JSON.stringify({ ...current, ...newTokens }, null, 2)
);
console.error(`Token refreshed and saved for account "${account}".`);
});
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
oAuth2Client.setCredentials(token);
oAuth2Client.on("tokens", (newTokens) => {
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
fs.writeFileSync(
tokenPath,
JSON.stringify({ ...current, ...newTokens }, null, 2)
);
console.error(`Token refreshed and saved for account "${account}".`);
});
return oAuth2Client;
return oAuth2Client;
};
export const getGmailClient = (account: string): gmail_v1.Gmail =>
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
google.gmail({ version: "v1", auth: getOAuth2Client(account) });
export const getSheetsClient = (account: string): sheets_v4.Sheets =>
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
google.sheets({ version: "v4", auth: getOAuth2Client(account) });
export const getCalendarClient = (account: string): calendar_v3.Calendar =>
google.calendar({ version: "v3", auth: getOAuth2Client(account) });
google.calendar({ version: "v3", auth: getOAuth2Client(account) });
export const getSpreadsheetId = (account: string): string => {
const acct = accounts[account];
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
throw new Error(
`No spreadsheetId configured for account "${account}" in accounts.json.`
);
}
return acct.spreadsheetId;
const acct = accounts[account];
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
throw new Error(
`No spreadsheetId configured for account "${account}" in accounts.json.`
);
}
return acct.spreadsheetId;
};
export const getCalendarId = (account: string): string =>
accounts[account]?.calendarId ?? "primary";
accounts[account]?.calendarId ?? "primary";

View File

@@ -1,64 +1,56 @@
import { server } from "../McpServer.js";
import {
accounts,
loadClassificationPrompt,
loadActionPrompt,
accounts,
loadClassificationPrompt,
loadActionPrompt,
} from "../loaders/prompt-config-loaders.js";
// ---------------------------------------------------------------------------
// Prompt: review_emails (work account)
// Prompt: Classify Emails (Phase 1)
// ---------------------------------------------------------------------------
server.registerPrompt(
"review_emails",
{
description:
`Review WORK inbox (${accounts.work?.label ?? "work"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
},
() => {
const phase1 = loadClassificationPrompt();
const phase2 = loadActionPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
`ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
(phase1 || "Review my new emails and classify them by job application category.") +
(phase2 ? `\n\n${phase2}` : ""),
},
},
],
};
}
"classify-emails",
{
description: "Phase 1: Organize, prioritize, and summarize incoming recruiter emails.",
},
() => {
const phase1 = loadClassificationPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
(phase1 || "Review my new emails and classify them by job application category.")
},
},
],
};
}
);
// ---------------------------------------------------------------------------
// Prompt: review_secondary_emails (secondary account)
// Prompt: Take Action on Emails (Phase 2)
// ---------------------------------------------------------------------------
server.registerPrompt(
"review_secondary_emails",
{
description:
`Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
},
() => {
const phase1 = loadClassificationPrompt();
const phase2 = loadActionPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text:
`ACCOUNT: Use account = "secondary" for ALL tool calls in this session.\n\n` +
(phase1 || "Review my new emails and classify them by job application category.") +
(phase2 ? `\n\n${phase2}` : ""),
},
},
],
};
}
"take-action-on-emails",
{
description: "Phase 2: Log Category B recruiters to spreadsheet and create calendar events.",
},
() => {
const phase2 = loadActionPrompt();
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
(phase2 || "Process remaining action items for flagged recruiters.")
},
},
],
};
}
);

View File

@@ -1,14 +1,11 @@
You are an expert Executive Assistant AI specialized in email management and productivity. Your goal is to organize, prioritize, and summarize incoming emails to maximize user efficiency.
You are an expert Executive Assistant AI specialized in email management and productivity. Your goal is to organize, prioritize, and summarize incoming emails to maximize user efficiency. I have been applying for jobs, which generates a large volume of responses.
I have been applying for jobs, which generates a large volume of responses.
Review, in reverse chronoloical order, the first 150 emails in my inbox. Apply the following instructions to those emails.
Review the first 200 emails in my inbox. Apply the following instructions to those emails.
STEP 1: Constraints
- Do not make up information.
STEP 2: CLASSIFY each email into exactly one category.
Category A - Acknowledgement Only
The employer confirms receipt of my application but requires no action from me. Typical language: "We received your application," "Your application is under review," "We will contact you if selected." No next steps are requested.
@@ -21,13 +18,15 @@ The employer declines to move forward. Typical language: "We have decided to pur
Category D - Other
The email does not fit into categories A, B, or C. This includes non-job-application emails, newsletters, promotional content, or ambiguous messages that do not clearly match another category.
STEP 3: DELETE emails classified as Category A and Category C.
STEP 3: STAR REVIEWED EMAILS
- For EVERY single email processed and reviewed in this run (Categories A, B, C, and D), apply the Gmail "STARRED" system label or use your email tool to add a star to the message.
STEP 4: ("Anti step") - NEVER DELETE Category B emails.
STEP 4: Confirm, via natural language output in the chat window, the total number of emails fetched for review and successfully starred.
STEP 5: SUMMARIZE emails classified as Category B and Category D. For each, include:
STEP 5: SUMMARIZE emails classified as Category B and Category D.
For each, state:
1. Sender name and email address
2. Email subject line
3. Date and time received
4. Category (B or D)
5. Suggested action I should take
5. Suggested action I should take

View File

@@ -1,12 +1,9 @@
PHASE 2: TAKE ACTION ON CATEGORY B EMAILS
After completing classification (Phase 1), perform the following actions for each email classified as Category B (Advancement to Next Step).
STEP 1: LOG TO SPREADSHEET
For each Category B email, extract as much of the following information as possible from the email contents and log it using the log_recruiter_contact tool:
A column exists for:
- Recruiter Name
- Recruiter Email
- Recruiter Phone/Tel
@@ -15,7 +12,7 @@ A column exists for:
- Subsequent Contacts: any follow-up communication with this recruiter about this role
- Recruiter Call Scheduled: if the email contains scheduling information, extract ALL details — date, time, platform (Zoom, MS Teams, Google Meet, etc.), meeting link, phone number to call — and note whether they confirm having your cell number or might not have it
A row should already exists for the same recruiter email + company/role combination (from a previous email), the tool will UPDATE the existing row. Provide all available fields and the tool handles merging.
If a row already exists for the same recruiter email + company/role combination (from a previous email), the tool will UPDATE the existing row. Provide all available fields and the tool handles merging.
STEP 2: CREATE CALENDAR EVENTS
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
@@ -34,11 +31,11 @@ STEP 3: COMPANY INTERVIEW TRACKING
When an email indicates advancement beyond the recruiter screen to a company interview:
- Populate the Company First Interview or Company Second Interview field with details
- Those details should include company contact info (interviewer name, email, phone)
- Then immeidately create a calendar event for the new interview date/time including interviewer name, email, phone
- Then immediately create a calendar event for the new interview date/time including interviewer name, email, phone
STRICT CONSTRAINTS:
- Do not make up phone numbers, links, dates, or anything at all. If it does not exist in text wihtin a verifiable email, IT DOES NOT EXIST.
- PAST-DATE CHECK: Before creating a calendar event. 1) verify today's date from an external, reliable source; 2) compare the proposed meeting date/time against today's date; 3) iIf the meeting date has already passed, do NOT create a calendar event for it. Still log it to the spreadsheet, but skip the calendar step. The tool will also enforce this server-side.
- Do not make up phone numbers, links, dates, or anything at all. If it does not exist in text within a verifiable email, IT DOES NOT EXIST.
- PAST-DATE CHECK: Before creating a calendar event: 1) verify today's date from an external, reliable source; 2) compare the proposed meeting date/time against today's date; 3) if the meeting date has already passed, do NOT create a calendar event for it. Still log it to the spreadsheet, but skip the calendar step. The tool will also enforce this server-side.
- As described in more detail below, each new date/time value in a scheduling column = one new calendar event. Never skip this.
- Always use the same account for calendar events as was used for email fetching.
- Do not create duplicate calendar events for the same meeting (same company representative && same job description | same company && same date time)

View File

@@ -3,303 +3,358 @@ import { gmail_v1 } from "googleapis";
import fs from "fs";
import { server } from "../McpServer.js";
import {
accountSchema,
getGmailClient,
getSummaryPath,
loadClassificationPrompt,
loadActionPrompt,
accountSchema,
getGmailClient,
getSummaryPath,
loadClassificationPrompt,
loadActionPrompt,
} from "../loaders/prompt-config-loaders.js";
// ---------------------------------------------------------------------------
// Email parsing helpers
// ---------------------------------------------------------------------------
export const getHeader = (
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
name: string
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
name: string
): string => {
if (!headers) return "";
const header = headers.find(
(h) => h.name?.toLowerCase() === name.toLowerCase()
);
return header?.value ?? "";
if (!headers) return "";
const header = headers.find(
(h) => h.name?.toLowerCase() === name.toLowerCase()
);
return header?.value ?? "";
};
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
const parts = message.payload?.parts;
let encoded = "";
const parts = message.payload?.parts;
let encoded = "";
if (parts) {
const textPart = parts.find((p) => p.mimeType === "text/plain");
encoded = textPart?.body?.data ?? "";
if (parts) {
const textPart = parts.find((p) => p.mimeType === "text/plain");
encoded = textPart?.body?.data ?? "";
if (!encoded) {
const htmlPart = parts.find((p) => p.mimeType === "text/html");
encoded = htmlPart?.body?.data ?? "";
}
} else {
encoded = message.payload?.body?.data ?? "";
}
if (!encoded) {
const htmlPart = parts.find((p) => p.mimeType === "text/html");
encoded = htmlPart?.body?.data ?? "";
return message.snippet ?? "";
}
} else {
encoded = message.payload?.body?.data ?? "";
}
if (!encoded) {
return message.snippet ?? "";
}
return Buffer.from(encoded, "base64url").toString("utf-8");
return Buffer.from(encoded, "base64url").toString("utf-8");
};
// ---------------------------------------------------------------------------
// Tool: fetch_new_emails
// ---------------------------------------------------------------------------
server.registerTool(
"fetch_new_emails",
{
description:
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
"subject, message ID, and body text for each message. The message IDs " +
"can be passed to delete_emails later. Specify which account to fetch from.",
inputSchema: {
account: accountSchema,
maxResults: z
.number()
.min(1)
.max(100)
.describe("Maximum number of unread emails to fetch (1-100)"),
"fetch_new_emails",
{
description:
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
"subject, message ID, and body text for each message. The message IDs " +
"must be passed to star_emails after evaluation. Specify which account to fetch from.",
inputSchema: {
account: accountSchema,
maxResults: z
.number()
.min(1)
.max(200)
.describe("Maximum number of unread emails to fetch (1-200)"),
},
},
},
async ({ account, maxResults }) => {
try {
const gmail = getGmailClient(account);
async ({ account, maxResults }) => {
try {
const gmail = getGmailClient(account);
const listResponse = await gmail.users.messages.list({
userId: "me",
q: "is:unread",
maxResults,
});
const listResponse = await gmail.users.messages.list({
userId: "me",
q: "is:unread",
maxResults,
});
const messageIds = listResponse.data.messages ?? [];
const messageIds = listResponse.data.messages ?? [];
if (messageIds.length === 0) {
return {
content: [{ type: "text" as const, text: "No unread emails found." }],
};
}
if (messageIds.length === 0) {
return {
content: [{ type: "text" as const, text: "No unread emails found." }],
};
}
const emails: string[] = [];
const emails: string[] = [];
for (const msg of messageIds) {
const detail = await gmail.users.messages.get({
userId: "me",
id: msg.id!,
format: "full",
});
for (const msg of messageIds) {
const detail = await gmail.users.messages.get({
userId: "me",
id: msg.id!,
format: "full",
});
const headers = detail.data.payload?.headers;
const from = getHeader(headers, "From");
const subject = getHeader(headers, "Subject");
const date = getHeader(headers, "Date");
const body = decodeBody(detail.data);
const headers = detail.data.payload?.headers;
const from = getHeader(headers, "From");
const subject = getHeader(headers, "Subject");
const date = getHeader(headers, "Date");
const body = decodeBody(detail.data);
const truncatedBody =
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
const truncatedBody =
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
emails.push(
[
`MESSAGE_ID: ${msg.id}`,
`FROM: ${from}`,
`DATE: ${date}`,
`SUBJECT: ${subject}`,
`BODY:\n${truncatedBody}`,
].join("\n")
);
}
emails.push(
[
`MESSAGE_ID: ${msg.id}`,
`FROM: ${from}`,
`DATE: ${date}`,
`SUBJECT: ${subject}`,
`BODY:\n${truncatedBody}`,
].join("\n")
);
}
const classificationInstructions = loadClassificationPrompt();
const actionInstructions = loadActionPrompt();
const instructionsBlock =
(classificationInstructions
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
: "") +
(actionInstructions
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
: "");
const classificationInstructions = loadClassificationPrompt();
const actionInstructions = loadActionPrompt();
const instructionsBlock =
(classificationInstructions
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
: "") +
(actionInstructions
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
: "");
return {
content: [
{
type: "text" as const,
text:
`Found ${emails.length} unread email(s):\n\n` +
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
instructionsBlock,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error fetching emails: ${errMsg}`,
},
],
};
return {
content: [
{
type: "text" as const,
text:
`Found ${emails.length} unread email(s):\n\n` +
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
instructionsBlock,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error fetching emails: ${errMsg}`,
},
],
};
}
}
}
);
// ---------------------------------------------------------------------------
// Tool: delete_emails
// ---------------------------------------------------------------------------
server.registerTool(
"delete_emails",
{
description:
"Move emails to trash by their Gmail message IDs. Use this for " +
"category A (acknowledgements) and category C (rejections) emails. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
messageIds: z
.array(z.string())
.describe("Array of Gmail message IDs to move to trash"),
"delete_emails",
{
description:
"Move emails to trash by their Gmail message IDs. Use this for " +
"category A (acknowledgements) and category C (rejections) emails. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
messageIds: z
.array(z.string())
.describe("Array of Gmail message IDs to move to trash"),
},
},
},
async ({ account, messageIds }) => {
try {
const gmail = getGmailClient(account);
const results: string[] = [];
for (const id of messageIds) {
async ({ account, messageIds }) => {
try {
await gmail.users.messages.trash({
userId: "me",
id,
});
results.push(`Trashed: ${id}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
results.push(`Failed to trash ${id}: ${errMsg}`);
}
}
const gmail = getGmailClient(account);
const results: string[] = [];
return {
content: [
{
type: "text" as const,
text: `Delete results:\n${results.join("\n")}`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error deleting emails: ${errMsg}`,
},
],
};
for (const id of messageIds) {
try {
await gmail.users.messages.trash({
userId: "me",
id,
});
results.push(`Trashed: ${id}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
results.push(`Failed to trash ${id}: ${errMsg}`);
}
}
return {
content: [
{
type: "text" as const,
text: `Delete results:\n${results.join("\n")}`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error deleting emails: ${errMsg}`,
},
],
};
}
}
}
);
// ---------------------------------------------------------------------------
// Tool: append_to_summary
// ---------------------------------------------------------------------------
interface SummaryEntry {
senderName: string;
senderEmail: string;
dateReceived: string;
subject: string;
category: "B" | "D";
addedAt: string;
senderName: string;
senderEmail: string;
dateReceived: string;
subject: string;
category: "B" | "D";
addedAt: string;
}
server.registerTool(
"append_to_summary",
{
description:
"Append classified email entries to the local summary file. Use this " +
"for category B (advancement to next step) and category D (other) emails. " +
"Each entry records the sender, date, subject, and category. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
entries: z
.array(
z.object({
senderName: z.string().describe("Name of the sender"),
senderEmail: z.string().describe("Email address of the sender"),
dateReceived: z
.string()
.describe("Date and time the email was received"),
subject: z.string().describe("Email subject line"),
category: z
.enum(["B", "D"])
.describe(
"Category: B = advancement to next step, D = other/uncategorized"
),
})
)
.describe("Array of email summary entries to append"),
"append_to_summary",
{
description:
"Append classified email entries to the local summary file. Use this " +
"for category B (advancement to next step) and category D (other) emails. " +
"Each entry records the sender, date, subject, and category. " +
"Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
entries: z
.array(
z.object({
senderName: z.string().describe("Name of the sender"),
senderEmail: z.string().describe("Email address of the sender"),
dateReceived: z
.string()
.describe("Date and time the email was received"),
subject: z.string().describe("Email subject line"),
category: z
.enum(["B", "D"])
.describe(
"Category: B = advancement to next step, D = other/uncategorized"
),
})
)
.describe("Array of email summary entries to append"),
},
},
},
async ({ account, entries }) => {
try {
const summaryPath = getSummaryPath(account);
async ({ account, entries }) => {
try {
const summaryPath = getSummaryPath(account);
let summary: SummaryEntry[] = [];
if (fs.existsSync(summaryPath)) {
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
}
let summary: SummaryEntry[] = [];
if (fs.existsSync(summaryPath)) {
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
}
const now = new Date();
const nowIso = now.toISOString();
const newEntries: SummaryEntry[] = entries.map((e) => ({
...e,
addedAt: nowIso,
}));
const now = new Date();
const nowIso = now.toISOString();
const newEntries: SummaryEntry[] = entries.map((e) => ({
...e,
addedAt: nowIso,
}));
summary.push(...newEntries);
summary.push(...newEntries);
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const cutoff = now.getTime() - RETENTION_MS;
const beforePurge = summary.length;
summary = summary.filter(
(entry) => new Date(entry.addedAt).getTime() >= cutoff
);
const purged = beforePurge - summary.length;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const cutoff = now.getTime() - RETENTION_MS;
const beforePurge = summary.length;
summary = summary.filter(
(entry) => new Date(entry.addedAt).getTime() >= cutoff
);
const purged = beforePurge - summary.length;
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
const purgeNote = purged > 0
? `\nPurged ${purged} entry/entries older than 30 days.`
: "";
const purgeNote = purged > 0
? `\nPurged ${purged} entry/entries older than 30 days.`
: "";
return {
content: [
{
type: "text" as const,
text:
`Appended ${newEntries.length} entry/entries to summary.\n` +
`Total entries in summary: ${summary.length}\n` +
`Summary file: ${summaryPath}` +
purgeNote,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error appending to summary: ${errMsg}`,
},
],
};
return {
content: [
{
type: "text" as const,
text:
`Appended ${newEntries.length} entry/entries to summary.\n` +
`Total entries in summary: ${summary.length}\n` +
`Summary file: ${summaryPath}` +
purgeNote,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error appending to summary: ${errMsg}`,
},
],
};
}
}
);
// --------------------------------------------------------------------------- //
// Tool: star_emails
// --------------------------------------------------------------------------- //
server.registerTool(
"star_emails",
{
description: "Mark reviewed emails with a star and mark them as read in Gmail. " +
"Use this for all reviewed messages (Categories A, B, C, and D) " +
"to signal they have been processed. Specify which account the emails belong to.",
inputSchema: {
account: accountSchema,
messageIds: z
.array(z.string())
.describe("Array of Gmail message IDs to star and mark read"),
},
},
async ({ account, messageIds }) => {
try {
if (messageIds.length === 0) {
return { content: [{ type: "text" as const, text: "No message IDs provided." }] };
}
const gmail = getGmailClient(account);
// Batch modify allows updating up to 1000 messages in a single API call
await gmail.users.messages.batchModify({
userId: "me",
requestBody: {
ids: messageIds,
addLabelIds: ["STARRED"],
removeLabelIds: ["UNREAD"]
}
});
return {
content: [
{
type: "text" as const,
text: `Successfully starred and marked read ${messageIds.length} email(s).`,
},
],
};
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text" as const,
text: `Error starring emails: ${errMsg}`,
},
],
};
}
}
}
);