Compare commits
15 Commits
dbfa913e5e
...
FEAT-refin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d17c409be | ||
|
|
f96a8541c6 | ||
|
|
8ad47f1d27 | ||
| 38c7e48a1d | |||
|
|
6817014b7f | ||
| dade7f1fb8 | |||
|
|
632b0029be | ||
| 549f2b0949 | |||
| c1f03a7cbe | |||
|
|
b767a1f09c | ||
| 76d4b490bf | |||
|
|
31618b1acf | ||
| 087839d2bf | |||
| b9337d1b0c | |||
|
|
ee12a44ec1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,7 +5,10 @@ build/
|
|||||||
accountsAndCredentials/credentials.json
|
accountsAndCredentials/credentials.json
|
||||||
accountsAndCredentials/accounts.json
|
accountsAndCredentials/accounts.json
|
||||||
accountsAndCredentials/token*.json
|
accountsAndCredentials/token*.json
|
||||||
|
accountsAndCredentials/token.json.bak
|
||||||
!accountsAndCredentials/*.example.json
|
!accountsAndCredentials/*.example.json
|
||||||
|
|
||||||
# Runtime output
|
# Runtime output
|
||||||
mailSummaries/
|
mailSummaries/
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
187
README.md
187
README.md
@@ -2,130 +2,182 @@
|
|||||||
<img src="assets/logo.png" alt="Declawed" width="120" />
|
<img src="assets/logo.png" alt="Declawed" width="120" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
# Declawed: your kuddly assisty-kitty
|
# Declawed: your kuddly assisty-kitty
|
||||||
|
|
||||||
Inbox brimming with stank, algorithmically-generated logs? Wanna cut the crap and get the stuff that matters into your calendars, Atlassian, Jira, Asana, G sheets (etc., etc.) ... in two clicks?
|
|
||||||
|
|
||||||
### (Cat) nip it in the bud!!
|
Inbox brimming with stank? Wanna cut the crap and get the messaging that matters into calendars, Atlassian, Jira, Asana, Google Calendar/Sheets (etc.) ... in two clicks? (Cat) nip slop in the bud with Assisty-Kitty!!
|
||||||
|
|
||||||
|
|
||||||
# The "why":
|
# The "why":
|
||||||
|
|
||||||
### Declawed is a configurable, prompt purrr-fectable LLM mail and task automation assisty-kitty.
|
|
||||||
### More private than other options (not purrfect *yet* - but privacy is our number one goal with every release).
|
### Eliminates that frustrating feeling you'll never get your paws around the dangling, catnip-stuffed mouse through haunch-high mail from list you're never signed up for or were re-sold to from ones you have, bot-generated marketing slop, and, of course, nigerians princes that need help claim their lottery jackpot.
|
||||||
### More accurate actions/results (read on).
|
|
||||||
### Eliminates the frustrating feeling you'll never get the dangling, catnip-stuffed mouse you've been chasing all week
|
|
||||||
|
"deClawed" because: no one should be beholden to one LLM api or local model. Have you seen that baddie Kimi K3?
|
||||||
|
|
||||||
|
|
||||||
|
The platform princes are rent extractors.
|
||||||
|
|
||||||
|
|
||||||
|
Open weights are heavy weight champions.
|
||||||
|
|
||||||
|
|
||||||
|
# The "how"
|
||||||
|
|
||||||
|
|
||||||
|
Because It's soooo easy.
|
||||||
|
deClawed is configurable, prompt purrr-fectable, LLM-driven mail and task automation for anyone that can cut n' paste a few CLI commands and can type in short declarative sentences.
|
||||||
|
More private than other options (not purrfect *yet* - but privacy is our number one goal with every release).
|
||||||
|
More accurate actions/results (read on).
|
||||||
|
|
||||||
|
|
||||||
# Privacy: more than the blue-plate crustacean
|
# Privacy: more than the blue-plate crustacean
|
||||||
|
|
||||||
Your own local Model Context Protocol (MCP) server integrates with your preferred LLM API.
|
|
||||||
Each step offers greater control, configurability and transparency over permissions.
|
|
||||||
|
|
||||||
Avoid the flakey (molty?) black-box installs that are loaded with more Trojans than an Olympic Village. Silent/dark processes, data harvesting phone-homes, behavioral analytics, telemetry backdoors - the nasties lurking in the dark detritus.
|
Your own local Model Context Protocol (MCP) server integrates with your preferred LLM API. Each step in the install, and setup offers greater control, and transparency. Permissions are 100% configurable.
|
||||||
|
|
||||||
|
|
||||||
|
Escape black-box installs stocked with more Trojans than an Olympic Village - silent/dark processes, data harvesting phone-homes, behavioral analytics, telemetry backdoors, corporate surveillance - the nasties lurking in the dark discoverable only by secops edgelords that are masters of the the CLI.
|
||||||
|
|
||||||
💩 You know lobsters eat poop, right? 💩
|
|
||||||
|
|
||||||
# Simplicity
|
# Simplicity
|
||||||
|
|
||||||
|
|
||||||
### NLP prompts are **actually** easy and **demonstrably** more effective
|
### NLP prompts are **actually** easy and **demonstrably** more effective
|
||||||
|
|
||||||
Stop drilling down byzantine menus in mail, scheduling and workflow platforms to configure filters and actions that 1. only ever work 20% of the time 2. change in functionality and scope every two months.
|
|
||||||
|
Stop drilling down byzantine menus in mail, scheduling and workflow to configure filters and actions that 1. only ever work 20% of the time 2. change in functionality and scope every two months because a project manager needs to justify their salary.
|
||||||
|
|
||||||
|
|
||||||
Write short, declarative prompts instead. It's like wrapping busywork's paws in tinfoil and tossing it in a bathtub.
|
Write short, declarative prompts instead. It's like wrapping busywork's paws in tinfoil and tossing it in a bathtub.
|
||||||
|
|
||||||
|
|
||||||
Prompts
|
Prompts
|
||||||
|
|
||||||
|
|
||||||
1. Use semantic grouping, organizing by message intent, purpose, and context.
|
1. Use semantic grouping, organizing by message intent, purpose, and context.
|
||||||
2. The prompt templates in our "built in" library are evaluated using, for example, cosine similarity, ROUGE scoring and real human feedback.
|
2. The prompt templates in Kitty's "built in" library are evaluated using, for example, cosine similarity, ROUGE scoring and real human eval in the SDLC dev loop.
|
||||||
3. For example: you'll consistently keep the treasure and trash the junk when you task deClawed with reviewing forums/feeds where actionable insights are gold, but only 10% of the signal.
|
3. For example: you'll consistently keep the treasure and trash the junk when you task deClawed with reviewing forums/feeds where actionable insights are gold, but only 10% of the signal.
|
||||||
|
|
||||||
### Prompting: easily configured to cron: cleaning the litter(in)box before the stank wafts into the whole house.
|
|
||||||
### Imagine your morning inbox or summaries brimming with a fresh, spring-meadow aroma of opportunity and insight -- not last night's litter.
|
### Prompting: easily configured to cron: cleaning the litter(in)box before the stank wafts through the whole work house.
|
||||||
|
### Imagine your morning inbox or summaries brimming with a fresh, spring-meadow aroma of opportunity and insight -- not last night's Kitty kibble.
|
||||||
|
|
||||||
|
|
||||||
# -- You just might feel like it' s freakin' 1998 again.
|
# -- You just might feel like it' s freakin' 1998 again.
|
||||||
|
|
||||||
|
|
||||||
# Wiring up your kitty
|
# Wiring up your kitty
|
||||||
|
|
||||||
|
|
||||||
Assisty-kitty easily interfaces with mail and calendar and other app APIs (about any other service you want to plug in) ... it keeps things moving so you can go chase laser pointers or enjoy a 19-hour nap in a sunbeam.
|
Assisty-kitty easily interfaces with mail and calendar and other app APIs (about any other service you want to plug in) ... it keeps things moving so you can go chase laser pointers or enjoy a 19-hour nap in a sunbeam.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
# Scope and Architecture
|
# Scope and Architecture
|
||||||
|
|
||||||
|
|
||||||
## Current implementation requires building/using:
|
## Current implementation requires building/using:
|
||||||
-- Claude Desktop (as UI - proprietary UI coming soonish)
|
-- Claude Desktop (as UI only - proprietary UI coming soonish)
|
||||||
-- Building/Connecting a Local Model Context Protocol Server
|
-- Building/Connecting a Model API or Local Model Context Protocol Server
|
||||||
-- A DNS-config’d domain, with MX records pointing to:
|
-- A DNS-config’d domain, with MX records pointing to:
|
||||||
-- A commercial or self-hosted SMTP Server
|
-- A commercial or self-hosted SMTP Server
|
||||||
|
|
||||||
|
|
||||||
## Coming soonish:
|
## Coming soonish:
|
||||||
-- Proprietary custom UI.
|
-- Proprietary, custom UI.
|
||||||
-- Support for Hugging Face models galore.
|
-- Support for all stripes of models on HugginFFace.
|
||||||
- Support for wiring to local LLMs.
|
- Support for local LLMs.
|
||||||
|
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
|
|
||||||
### 1. Install Node.js (v16+ required).
|
### 1. Install Node.js (v16+ required).
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --version
|
node --version
|
||||||
npm --version
|
npm --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
If not installed, grab it from [nodejs.org](https://nodejs.org/).
|
If not installed, grab it from [nodejs.org](https://nodejs.org/).
|
||||||
|
|
||||||
|
|
||||||
### 2. Clone and Install
|
### 2. Clone and Install
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/kjannette/deClawed-Assisty-Kitty.git
|
|
||||||
cd deClawed-Assity-Kitty
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.sjdev.online/kjannette/deClawed-Assity-Kitty
|
||||||
|
cd deClawed-Assity-Kitty
|
||||||
|
npm i
|
||||||
|
```
|
||||||
### 3. Set Up Google Cloud Credentials
|
### 3. Set Up Google Cloud Credentials
|
||||||
|
|
||||||
|
|
||||||
#### 3a. Create a Google Cloud Project
|
#### 3a. Create a Google Cloud Project
|
||||||
|
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
2. Sign in with the Google account that owns the target Gmail
|
2. Sign in with the Google account that owns the target Gmail
|
||||||
3. Click the project dropdown (top-left) > **New Project**
|
3. Click the project dropdown (top-left) > **New Project**
|
||||||
4. Name it (e.g., `assistant-mcp`) and click **Create**
|
4. Name it (e.g., `assistant-mcp`) and click **Create**
|
||||||
5. Select the new project from the dropdown
|
5. Select the new project from the dropdown
|
||||||
|
|
||||||
|
|
||||||
#### 3b. Enable APIs
|
#### 3b. Enable APIs
|
||||||
|
|
||||||
|
|
||||||
In **APIs & Services > Library**, enable:
|
In **APIs & Services > Library**, enable:
|
||||||
- **Gmail API**
|
- **Gmail API**
|
||||||
- **Google Sheets API** (if mapping mail to sheets)
|
- **Google Sheets API** (if mapping mail to sheets)
|
||||||
- **Google Calendar API** (if using calendar event creation, etc.s)
|
- **Google Calendar API** (if using calendar event creation, etc.s)
|
||||||
|
|
||||||
|
|
||||||
#### 3c. Configure the OAuth Consent Screen
|
#### 3c. Configure the OAuth Consent Screen
|
||||||
|
|
||||||
|
|
||||||
1. Go to **Google Auth Platform > Branding** (or **APIs & Services > OAuth consent screen**)
|
1. Go to **Google Auth Platform > Branding** (or **APIs & Services > OAuth consent screen**)
|
||||||
2. Set user type to **External**, click **Create**
|
2. Set user type to **External**, click **Create**
|
||||||
3. Fill in app name, support email, and developer contact email
|
3. Fill in app name, support email, and developer contact email
|
||||||
4. Save and continue
|
4. Save and continue
|
||||||
|
|
||||||
|
|
||||||
#### 3d. Add OAuth Scopes
|
#### 3d. Add OAuth Scopes
|
||||||
|
|
||||||
|
|
||||||
In **Google Auth Platform > Data Access**, add:
|
In **Google Auth Platform > Data Access**, add:
|
||||||
- `https://www.googleapis.com/auth/gmail.modify`
|
- `https://www.googleapis.com/auth/gmail.modify`
|
||||||
- `https://www.googleapis.com/auth/spreadsheets` (for Sheets integration)
|
- `https://www.googleapis.com/auth/spreadsheets` (for Sheets integration)
|
||||||
- `https://www.googleapis.com/auth/calendar.events` (for Calendar integration)
|
- `https://www.googleapis.com/auth/calendar.events` (for Calendar integration)
|
||||||
|
|
||||||
|
|
||||||
#### 3e. Add Yourself as a Test User
|
#### 3e. Add Yourself as a Test User
|
||||||
|
|
||||||
|
|
||||||
Go to **Google Auth Platform > Audience** and add each Gmail address you'll use.
|
Go to **Google Auth Platform > Audience** and add each Gmail address you'll use.
|
||||||
|
|
||||||
|
|
||||||
#### 3f. Create OAuth Client Credentials
|
#### 3f. Create OAuth Client Credentials
|
||||||
|
|
||||||
|
|
||||||
1. Go to **Google Auth Platform > Clients** (or **APIs & Services > Credentials**)
|
1. Go to **Google Auth Platform > Clients** (or **APIs & Services > Credentials**)
|
||||||
2. Click **Create Client** > Application type: **Desktop app**
|
2. Click **Create Client** > Application type: **Desktop app**
|
||||||
3. **Download the JSON**, rename it to `credentials.json`
|
3. **Download the JSON**, rename it to `credentials.json`
|
||||||
4. Place it in the project root
|
4. Place it in the project root
|
||||||
|
|
||||||
|
|
||||||
### 4. Configure Accounts
|
### 4. Configure Accounts
|
||||||
|
|
||||||
|
|
||||||
Create file `accounts.json` in the project root. Each key is an account alias with its own token file and optional Sheets/Calendar config:
|
Create file `accounts.json` in the project root. Each key is an account alias with its own token file and optional Sheets/Calendar config:
|
||||||
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"work": {
|
"work": {
|
||||||
@@ -141,113 +193,147 @@ Create file `accounts.json` in the project root. Each key is an account alias wi
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
- **`label`** -- display name (typically the email address)
|
- **`label`** -- display name (typically the email address)
|
||||||
- **`tokenFile`** -- per-account OAuth token (auto-generated during auth)
|
- **`tokenFile`** -- per-account OAuth token (auto-generated during auth)
|
||||||
- **`spreadsheetId`** -- Google Sheets ID for recruiter contact logging (optional)
|
- **`spreadsheetId`** -- Google Sheets ID for recruiter contact logging (optional)
|
||||||
- **`calendarId`** -- Google Calendar ID for event creation (optional, `"primary"` uses the default calendar)
|
- **`calendarId`** -- Google Calendar ID for event creation (optional, `"primary"` uses the default calendar)
|
||||||
|
|
||||||
|
|
||||||
### 5. Authorize Gmail Accounts
|
### 5. Authorize Gmail Accounts
|
||||||
|
|
||||||
|
|
||||||
Build and run the auth script for each account:
|
Build and run the auth script for each account:
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run auth # authorizes the "work" account
|
npm run auth # authorizes the "work" account
|
||||||
npm run auth -- secondary # authorizes the "secondary" account
|
npm run auth -- secondary # authorizes the "secondary" account
|
||||||
```
|
```
|
||||||
|
|
||||||
Each run will:
|
|
||||||
|
Each run required steps:
|
||||||
|
|
||||||
|
|
||||||
1. Print a URL -- open it in your browser
|
1. Print a URL -- open it in your browser
|
||||||
2. Sign in and click **Allow**
|
2. Sign in and click **Allow**
|
||||||
3. You'll land on a "localhost refused to connect" page (normal)
|
3. You'll land on a "localhost refused to connect" page (normal)
|
||||||
4. Copy the **entire URL** from the address bar and paste it back into the terminal
|
4. Copy the **entire URL** from the address bar and paste it back into the terminal
|
||||||
5. The script saves the token file (e.g., `token.json` or `token-secondary.json`)
|
5. The script saves the token file (e.g., `token.json` or `token-secondary.json`)
|
||||||
|
|
||||||
|
|
||||||
You only need to do this once per account. Tokens auto-refresh.
|
You only need to do this once per account. Tokens auto-refresh.
|
||||||
|
|
||||||
|
|
||||||
### 6. Write the Prompts
|
### 6. Write the Prompts
|
||||||
|
|
||||||
|
|
||||||
Two plain-text prompt files in `src/prompts/` control the workflow:
|
Two plain-text prompt files in `src/prompts/` control the workflow:
|
||||||
|
|
||||||
|
|
||||||
| File | Phase | Purpose |
|
| File | Phase | Purpose |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| `src/prompts/classify-emails.txt` | 1 -- Classification | Defines categories A/B/C/D and how to sort emails |
|
| `src/prompts/classify-emails.txt` | 1 -- Classification | Defines categories A/B/C/D and how to sort emails |
|
||||||
| `src/prompts/take-action-on-emails.txt` | 2 -- Action | Tells the LLM what to do with each category (delete, log, schedule, etc.) |
|
| `src/prompts/take-action-on-emails.txt` | 2 -- Action | Tell the LLM what to do with each category (delete, log, schedule, etc.) |
|
||||||
|
|
||||||
|
|
||||||
**Tips:**
|
**Tips:**
|
||||||
|
|
||||||
|
|
||||||
- Use clear, explicit category definitions with example language
|
- Use clear, explicit category definitions with example language
|
||||||
- Handle ambiguous cases (e.g., "If an email both acknowledges receipt AND requests action, classify as B")
|
- Handle ambiguous cases (e.g., "If an email both acknowledges receipt AND requests action, classify as B")
|
||||||
- Prompt files are loaded at runtime -- edit them anytime, no rebuild required
|
- Prompt files are loaded at runtime -- edit them anytime, no rebuild required
|
||||||
|
|
||||||
|
|
||||||
### 7. Build
|
### 7. Build
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
Compiles `src/**/*.ts` into `build/`.
|
Compiles `src/**/*.ts` into `build/`.
|
||||||
|
|
||||||
|
|
||||||
## 8. Configure Claude Desktop
|
## 8. Configure Claude Desktop
|
||||||
|
|
||||||
|
|
||||||
Edit your Claude Desktop config:
|
Edit your Claude Desktop config:
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
code ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
Add the server:
|
Add the server:
|
||||||
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"assistant": {
|
"assistant": {
|
||||||
"command": "/ABSOLUTE/PATH/TO/node",
|
"command": "/ABSOLUTE/PATH/TO/node",
|
||||||
"args": [
|
"args": [
|
||||||
"/ABSOLUTE/PATH/TO/deClawed-Assity-Kitty/build/index.js"
|
"/ABSOLUTE/PATH/TO/deClawed-Assity-Kitty/build/index.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
Replace paths with the output of `which node` and your actual project location.
|
Replace paths with the output of `which node` and your actual project location.
|
||||||
|
|
||||||
|
|
||||||
## 9. Add/Configure API Keys, Credentials, and Other Secrets
|
## 9. Add/Configure API Keys, Credentials, and Other Secrets
|
||||||
|
|
||||||
|
|
||||||
Secret files are **gitignored** -- they never leave your machine. Example templates are provided so you know what shape each file needs to be in.
|
Secret files are **gitignored** -- they never leave your machine. Example templates are provided so you know what shape each file needs to be in.
|
||||||
|
|
||||||
|
|
||||||
#### 9a. `credentials.json`
|
#### 9a. `credentials.json`
|
||||||
|
|
||||||
|
|
||||||
This file holds your Google OAuth client credentials. **You do not write this by hand** -- it is downloaded from the Google Cloud Console (see Step 3f above). Copy the example and then replace it with the real download:
|
This file holds your Google OAuth client credentials. **You do not write this by hand** -- it is downloaded from the Google Cloud Console (see Step 3f above). Copy the example and then replace it with the real download:
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp credentials.example.json credentials.json
|
cp credentials.example.json credentials.json
|
||||||
# Now replace credentials.json with the file downloaded from Google Cloud Console.
|
# Now replace credentials.json with the file downloaded from Google Cloud Console.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
The structure looks like this (the example file ships with empty values):
|
The structure looks like this (the example file ships with empty values):
|
||||||
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"installed": {
|
"installed": {
|
||||||
"client_id": "",
|
"client_id": "",
|
||||||
"project_id": "",
|
"project_id": "",
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
"client_secret": "",
|
"client_secret": "",
|
||||||
"redirect_uris": ["http://localhost"]
|
"redirect_uris": ["http://localhost"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
#### 9b. `accounts.json`
|
#### 9b. `accounts.json`
|
||||||
|
|
||||||
|
|
||||||
Defines each Gmail account the server manages. Copy the example and fill in your values:
|
Defines each Gmail account the server manages. Copy the example and fill in your values:
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp accounts.example.json accounts.json
|
cp accounts.example.json accounts.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
| Field | What to put here |
|
| Field | What to put here |
|
||||||
|-------|-----------------|
|
|-------|-----------------|
|
||||||
| `label` | The email address for this account (display only) |
|
| `label` | The email address for this account (display only) |
|
||||||
@@ -255,28 +341,38 @@ cp accounts.example.json accounts.json
|
|||||||
| `spreadsheetId` | The ID from your Google Sheet URL: `docs.google.com/spreadsheets/d/<THIS_PART>/edit` (optional) |
|
| `spreadsheetId` | The ID from your Google Sheet URL: `docs.google.com/spreadsheets/d/<THIS_PART>/edit` (optional) |
|
||||||
| `calendarId` | `"primary"` for your default calendar, or a specific calendar ID (optional) |
|
| `calendarId` | `"primary"` for your default calendar, or a specific calendar ID (optional) |
|
||||||
|
|
||||||
|
|
||||||
Add as many accounts as you need. Each key (e.g., `"work"`, `"secondary"`) becomes the account name used in tool calls and auth commands.
|
Add as many accounts as you need. Each key (e.g., `"work"`, `"secondary"`) becomes the account name used in tool calls and auth commands.
|
||||||
|
|
||||||
|
|
||||||
#### 9c. `token*.json` (auto-generated)
|
#### 9c. `token*.json` (auto-generated)
|
||||||
|
|
||||||
|
|
||||||
Token files are **created automatically** when you run `npm run auth` (Step 5). You do not need to create or edit them manually. A `token.example.json` is provided for reference only -- it shows the structure but the values are populated by the OAuth flow.
|
Token files are **created automatically** when you run `npm run auth` (Step 5). You do not need to create or edit them manually. A `token.example.json` is provided for reference only -- it shows the structure but the values are populated by the OAuth flow.
|
||||||
|
|
||||||
|
|
||||||
#### Summary of secret files
|
#### Summary of secret files
|
||||||
|
|
||||||
|
|
||||||
| Example template | Actual file (gitignored) | How to create |
|
| Example template | Actual file (gitignored) | How to create |
|
||||||
|-----------------|--------------------------|---------------|
|
|-----------------|--------------------------|---------------|
|
||||||
| `credentials.example.json` | `credentials.json` | Download from Google Cloud Console |
|
| `credentials.example.json` | `credentials.json` | Download from Google Cloud Console |
|
||||||
| `accounts.example.json` | `accounts.json` | Copy example, fill in your email/sheet/calendar IDs |
|
| `accounts.example.json` | `accounts.json` | Copy example, fill in your email/sheet/calendar IDs |
|
||||||
| `token.example.json` | `token.json`, `token-secondary.json`, etc. | Auto-generated by `npm run auth` |
|
| `token.example.json` | `token.json`, `token-secondary.json`, etc. | Auto-generated by `npm run auth` |
|
||||||
|
|
||||||
|
|
||||||
### 10. Restart Claude Desktop
|
### 10. Restart Claude Desktop
|
||||||
|
|
||||||
|
|
||||||
Fully quit (**Cmd+Q**, not just close the window) and reopen. The `assistant` server should appear under **Connectors**.
|
Fully quit (**Cmd+Q**, not just close the window) and reopen. The `assistant` server should appear under **Connectors**.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `fetch_new_emails` | Fetches unread emails for a given account. Classification and action prompts are automatically appended to the response. |
|
| `fetch_new_emails` | Fetches unread emails for a given account. Classification and action prompts are automatically appended to the response. |
|
||||||
@@ -285,24 +381,32 @@ Fully quit (**Cmd+Q**, not just close the window) and reopen. The `assistant` se
|
|||||||
| `log_contact` | Logs or updates contact info in a Google Sheet. Merges rows by email + role. |
|
| `log_contact` | Logs or updates contact info in a Google Sheet. Merges rows by email + role. |
|
||||||
| `create_calendar_event` | Creates a Google Calendar event for scheduled calls/meetings. Includes meeting links, attendees and their contact info. |
|
| `create_calendar_event` | Creates a Google Calendar event for scheduled calls/meetings. Includes meeting links, attendees and their contact info. |
|
||||||
|
|
||||||
|
|
||||||
## MCP Prompts (built in as of now-ish - add your own)
|
## MCP Prompts (built in as of now-ish - add your own)
|
||||||
### BETTER YET - DO A PR OR FORK
|
### BETTER YET - DO A PR OR FORK
|
||||||
|
|
||||||
|
|
||||||
| Prompt | Account | Description |
|
| Prompt | Account | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `review_emails` | work | Loads the classification + action prompts for the work inbox |
|
| `review_emails` | work | Loads the classification + action prompts for the work inbox |
|
||||||
| `review_secondary_emails` | secondary | Same workflow, but for a second email account inbox |
|
| `review_secondary_emails` | secondary | Same workflow, but for a second email account inbox |
|
||||||
|
|
||||||
|
|
||||||
Invoke these from Claude Desktop's Connectors menu, or just type "Review my inbox" / "Review my secondary inbox."
|
Invoke these from Claude Desktop's Connectors menu, or just type "Review my inbox" / "Review my secondary inbox."
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Two-Phase Workflow
|
## Two-Phase Workflow
|
||||||
|
|
||||||
|
|
||||||
**Phase 1 -- Classify**
|
**Phase 1 -- Classify**
|
||||||
|
|
||||||
|
|
||||||
The LLM calls `fetch_new_emails`, which returns email data with the classification instructions from `classify-emails.txt` appended. Each email is sorted into:
|
The LLM calls `fetch_new_emails`, which returns email data with the classification instructions from `classify-emails.txt` appended. Each email is sorted into:
|
||||||
|
|
||||||
|
|
||||||
| Category | Meaning | Action |
|
| Category | Meaning | Action |
|
||||||
|----------|---------|--------|
|
|----------|---------|--------|
|
||||||
| **A** | Acknowledgement / auto-reply | Delete |
|
| **A** | Acknowledgement / auto-reply | Delete |
|
||||||
@@ -310,18 +414,23 @@ The LLM calls `fetch_new_emails`, which returns email data with the classificati
|
|||||||
| **C** | Rejection | Delete |
|
| **C** | Rejection | Delete |
|
||||||
| **D** | Other / uncategorized | Summarize + log |
|
| **D** | Other / uncategorized | Summarize + log |
|
||||||
|
|
||||||
|
|
||||||
**Phase 2 -- Act**
|
**Phase 2 -- Act**
|
||||||
|
|
||||||
|
|
||||||
Using `take-action-on-emails.txt`, the LLM:
|
Using `take-action-on-emails.txt`, the LLM:
|
||||||
- Calls `delete_emails` for A + C
|
- Calls `delete_emails` for A + C
|
||||||
- Calls `append_to_summary` for B + D
|
- Calls `append_to_summary` for B + D
|
||||||
- Calls `log_recruiter_contact` to track contacts in Sheets (if configured)
|
- Calls `log_recruiter_contact` to track contacts in Sheets (if configured)
|
||||||
- Calls `create_calendar_event` for any scheduled interviews/calls (if configured)
|
- Calls `create_calendar_event` for any scheduled interviews/calls (if configured)
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Key Commands
|
## Key Commands
|
||||||
|
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `npm run build` | Recompile after editing source files |
|
| `npm run build` | Recompile after editing source files |
|
||||||
@@ -330,10 +439,13 @@ Using `take-action-on-emails.txt`, the LLM:
|
|||||||
| `npm test` | Run the test suite |
|
| `npm test` | Run the test suite |
|
||||||
| `npm run test:watch` | Run tests in watch mode |
|
| `npm run test:watch` | Run tests in watch mode |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
deClawed-Assity-Kitty/
|
deClawed-Assity-Kitty/
|
||||||
├── src/
|
├── src/
|
||||||
@@ -376,3 +488,4 @@ deClawed-Assity-Kitty/
|
|||||||
├── .gitignore
|
├── .gitignore
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"work": {
|
"work": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tokenFile": "token.json",
|
"tokenFile": "token.json",
|
||||||
"spreadsheetId": "",
|
"spreadsheetId": "",
|
||||||
"calendarId": "primary"
|
"calendarId": "primary"
|
||||||
},
|
},
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"tokenFile": "token-secondary.json"
|
"tokenFile": "token-secondary.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"installed": {
|
"installed": {
|
||||||
"client_id": "",
|
"client_id": "",
|
||||||
"project_id": "",
|
"project_id": "",
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
"client_secret": "",
|
"client_secret": "",
|
||||||
"redirect_uris": ["http://localhost"]
|
"redirect_uris": [
|
||||||
}
|
"http://localhost"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"refresh_token": "",
|
"refresh_token": "",
|
||||||
"scope": "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
|
"scope": "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expiry_date": 0
|
"expiry_date": 0
|
||||||
}
|
}
|
||||||
6742
package-lock.json
generated
6742
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
58
package.json
58
package.json
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "assistant",
|
"name": "assistant",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "MCP server for Gmail job application management",
|
"description": "MCP server for Gmail job application management",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"assistant": "./build/index.js"
|
"assistant": "./build/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
"auth": "npm run build && node build/auth.js",
|
"auth": "npm run build && node build/auth.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"build"
|
"build"
|
||||||
],
|
],
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,3 +4,5 @@ export const server = new McpServer({
|
|||||||
name: "assistant",
|
name: "assistant",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -117,3 +117,5 @@ authorize().catch((err) => {
|
|||||||
console.error("Authorization failed:", err);
|
console.error("Authorization failed:", err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ main().catch((error) => {
|
|||||||
console.error("Fatal error in main():", error);
|
console.error("Fatal error in main():", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { google, gmail_v1, sheets_v4, calendar_v3 } from "googleapis";
|
|||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Paths
|
// 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, "..", "..");
|
export const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
const SECRETS_DIR = path.join(PROJECT_ROOT, "accountsAndCredentials");
|
||||||
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
const CREDENTIALS_PATH = path.join(SECRETS_DIR, "credentials.json");
|
||||||
@@ -20,130 +22,132 @@ const ACTION_PROMPT_PATH = path.join(PROMPTS_DIR, "take-action-on-emails.txt");
|
|||||||
// Account configuration
|
// Account configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export interface AccountConfig {
|
export interface AccountConfig {
|
||||||
label: string;
|
label: string;
|
||||||
tokenFile: string;
|
tokenFile: string;
|
||||||
spreadsheetId?: string;
|
spreadsheetId?: string;
|
||||||
calendarId?: string;
|
calendarId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountsMap {
|
export interface AccountsMap {
|
||||||
[key: string]: AccountConfig;
|
[key: string]: AccountConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadAccounts = (): AccountsMap => {
|
export const loadAccounts = (): AccountsMap => {
|
||||||
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
||||||
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
|
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
|
||||||
}
|
}
|
||||||
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTokenPath = (account: string): string => {
|
export const getTokenPath = (account: string): string => {
|
||||||
const accts = loadAccounts();
|
const accts = loadAccounts();
|
||||||
const acct = accts[account];
|
const acct = accts[account];
|
||||||
if (!acct) {
|
if (!acct) {
|
||||||
const available = Object.keys(accts).join(", ");
|
const available = Object.keys(accts).join(", ");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown account "${account}". Available accounts: ${available}`
|
`Unknown account "${account}". Available accounts: ${available}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return path.join(SECRETS_DIR, acct.tokenFile);
|
return path.join(SECRETS_DIR, acct.tokenFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSummaryPath = (account: string): string => {
|
export const getSummaryPath = (account: string): string => {
|
||||||
if (account === "work") {
|
if (account === "work") {
|
||||||
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
|
return path.join(PROJECT_ROOT, "mailSummaries", "summary.json");
|
||||||
}
|
}
|
||||||
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
|
return path.join(PROJECT_ROOT, "mailSummaries", `summary-${account}.json`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
export const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
||||||
export const accounts = loadAccounts();
|
export const accounts = loadAccounts();
|
||||||
|
|
||||||
const accountDescription = VALID_ACCOUNTS
|
const accountDescription = VALID_ACCOUNTS
|
||||||
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
|
.map((key) => `"${key}" (${accounts[key]?.label ?? key})`)
|
||||||
.join(" or ");
|
.join(" or ");
|
||||||
|
|
||||||
export const accountSchema = z
|
export const accountSchema = z
|
||||||
.enum(VALID_ACCOUNTS)
|
.enum(VALID_ACCOUNTS)
|
||||||
.describe(`Which email account to use: ${accountDescription}`);
|
.describe(`Which email account to use: ${accountDescription}`);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt loaders
|
// Prompt loaders
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const loadPromptFile = (filePath: string, label: string): string => {
|
const loadPromptFile = (filePath: string, label: string): string => {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
|
console.error(`Warning: ${filePath} not found. ${label} will be missing.`);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return fs.readFileSync(filePath, "utf-8");
|
return fs.readFileSync(filePath, "utf-8");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadClassificationPrompt = (): string =>
|
export const loadClassificationPrompt = (): string =>
|
||||||
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
|
loadPromptFile(CLASSIFY_PROMPT_PATH, "Classification instructions");
|
||||||
|
|
||||||
export const loadActionPrompt = (): string =>
|
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
|
// Auth helpers — generic OAuth2 client, then service-specific factories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const getOAuth2Client = (account: string): OAuth2Client => {
|
export const getOAuth2Client = (account: string): OAuth2Client => {
|
||||||
const tokenPath = getTokenPath(account);
|
const tokenPath = getTokenPath(account);
|
||||||
|
|
||||||
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
|
`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 token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
const { client_id, client_secret, redirect_uris } =
|
oAuth2Client.setCredentials(token);
|
||||||
credentials.installed || credentials.web;
|
|
||||||
|
|
||||||
const oAuth2Client = new google.auth.OAuth2(
|
oAuth2Client.on("tokens", (newTokens) => {
|
||||||
client_id,
|
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
client_secret,
|
fs.writeFileSync(
|
||||||
redirect_uris[0]
|
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"));
|
return oAuth2Client;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGmailClient = (account: string): gmail_v1.Gmail =>
|
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 =>
|
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 =>
|
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 => {
|
export const getSpreadsheetId = (account: string): string => {
|
||||||
const acct = accounts[account];
|
const acct = accounts[account];
|
||||||
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
|
if (!acct?.spreadsheetId || acct.spreadsheetId === "PASTE_YOUR_SHEET_ID_HERE") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No spreadsheetId configured for account "${account}" in accounts.json.`
|
`No spreadsheetId configured for account "${account}" in accounts.json.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return acct.spreadsheetId;
|
return acct.spreadsheetId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCalendarId = (account: string): string =>
|
export const getCalendarId = (account: string): string =>
|
||||||
accounts[account]?.calendarId ?? "primary";
|
accounts[account]?.calendarId ?? "primary";
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,64 +1,58 @@
|
|||||||
import { server } from "../McpServer.js";
|
import { server } from "../McpServer.js";
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
loadClassificationPrompt,
|
loadClassificationPrompt,
|
||||||
loadActionPrompt,
|
loadActionPrompt,
|
||||||
} from "../loaders/prompt-config-loaders.js";
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt: review_emails (work account)
|
// Prompt: Classify Emails (Phase 1)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerPrompt(
|
server.registerPrompt(
|
||||||
"review_emails",
|
"classify-emails",
|
||||||
{
|
{
|
||||||
description:
|
description: "Phase 1: Organize, prioritize, and summarize incoming recruiter emails.",
|
||||||
`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 phase1 = loadClassificationPrompt();
|
return {
|
||||||
const phase2 = loadActionPrompt();
|
messages: [
|
||||||
return {
|
{
|
||||||
messages: [
|
role: "user" as const,
|
||||||
{
|
content: {
|
||||||
role: "user" as const,
|
type: "text" as const,
|
||||||
content: {
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
type: "text" as const,
|
(phase1 || "Review my new emails and classify them by job application category.")
|
||||||
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}` : ""),
|
};
|
||||||
},
|
}
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt: review_secondary_emails (secondary account)
|
// Prompt: Take Action on Emails (Phase 2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerPrompt(
|
server.registerPrompt(
|
||||||
"review_secondary_emails",
|
"take-action-on-emails",
|
||||||
{
|
{
|
||||||
description:
|
description: "Phase 2: Log Category B recruiters to spreadsheet and create calendar events.",
|
||||||
`Review SECONDARY inbox (${accounts.secondary?.label ?? "secondary"}): classify emails, delete A+C, summarize B+D, log B to spreadsheet, create calendar events.`,
|
},
|
||||||
},
|
() => {
|
||||||
() => {
|
const phase2 = loadActionPrompt();
|
||||||
const phase1 = loadClassificationPrompt();
|
return {
|
||||||
const phase2 = loadActionPrompt();
|
messages: [
|
||||||
return {
|
{
|
||||||
messages: [
|
role: "user" as const,
|
||||||
{
|
content: {
|
||||||
role: "user" as const,
|
type: "text" as const,
|
||||||
content: {
|
text: `ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
type: "text" as const,
|
(phase2 || "Process remaining action items for flagged recruiters.")
|
||||||
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}` : ""),
|
};
|
||||||
},
|
}
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
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 the first 200 emails in my inbox. Apply the following instructions to those emails.
|
||||||
|
|
||||||
Review, in reverse chronoloical order, the first 150 emails in my inbox. Apply the following instructions to those emails.
|
|
||||||
|
|
||||||
STEP 1: Constraints
|
STEP 1: Constraints
|
||||||
- Do not make up information.
|
- Do not make up information.
|
||||||
|
|
||||||
STEP 2: CLASSIFY each email into exactly one category.
|
STEP 2: CLASSIFY each email into exactly one category.
|
||||||
|
|
||||||
Category A - Acknowledgement Only
|
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.
|
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.
|
||||||
|
|
||||||
Category B - Advancement to Next Step
|
Category B - Advancement to Next Step
|
||||||
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
The employer wants to move forward. This includes: invitations to schedule a phone screen or interview, requests to complete an assessment or assignment, requests for additional information or documents, or any communication that requires a response or action from me. If an email both acknowledges receipt AND requests action, classify it as B.
|
||||||
|
This does *not* include Greenhouse "security code" emails, other "confirm identity codes" or "identity-confirmation" type emails, "verify candidate account" type emails,
|
||||||
|
|
||||||
Category C - Rejection
|
Category C - Rejection
|
||||||
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
The employer declines to move forward. Typical language: "We have decided to pursue other candidates," "Unfortunately, you were not selected," "We will not be moving forward with your application."
|
||||||
@@ -21,13 +19,44 @@ The employer declines to move forward. Typical language: "We have decided to pur
|
|||||||
Category D - Other
|
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.
|
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 email processed and reviewed in this run that is in Category A, C, and D (NOT B), 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 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, under separate headers, in the chat.
|
||||||
|
For each, state:
|
||||||
1. Sender name and email address
|
1. Sender name and email address
|
||||||
2. Email subject line
|
2. Email subject line
|
||||||
3. Date and time received
|
3. Date and time received
|
||||||
4. Category (B or D)
|
4. Category (B or D)
|
||||||
5. Suggested action I should take
|
5. Suggested action I should take
|
||||||
|
|
||||||
|
STEP 6: **STRICT COMPLIANCE REQUIRED** **DO NOT FORGET***:
|
||||||
|
Summarize all "Category B" emails in the chat.
|
||||||
|
Star all reviewed email
|
||||||
|
|
||||||
|
Next, perform the following actions for each "Category B" email:
|
||||||
|
|
||||||
|
STEP 7: CREATE CALENDAR EVENTS
|
||||||
|
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
||||||
|
- Recruiter Call Scheduled
|
||||||
|
- Company First Interview
|
||||||
|
- Company Second Interview
|
||||||
|
|
||||||
|
For EACH of the above fields that contains a date/time, create a Google Calendar event using the create_calendar_event tool:
|
||||||
|
- Title format: "[<Recruiter Name> Call] Company - Role" or "[First Interview] Company - Role" or "[Second Interview] Company - Role"
|
||||||
|
- Start date/time: extracted from the email (convert to ISO 8601)
|
||||||
|
- Duration: 30 minutes unless otherwise specified in the email
|
||||||
|
- Location: the video meeting link, phone number, or platform name
|
||||||
|
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
||||||
|
|
||||||
|
STRICT CONSTRAINTS:
|
||||||
|
- 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)
|
||||||
|
- If information is not present in the email, leave that field empty (it's OK!) - do not guess or make up information.
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
PHASE 2: TAKE ACTION ON CATEGORY B EMAILS
|
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).
|
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
|
STEP 1: CREATE CALENDAR EVENTS
|
||||||
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
|
|
||||||
- Company/Role the recruiter seeks to fill
|
|
||||||
- First Contact: the date and time the email was received
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
STEP 2: CREATE CALENDAR EVENTS
|
|
||||||
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
Immediately after logging to the spreadsheet, check whether ANY of these fields were populated or updated:
|
||||||
- Recruiter Call Scheduled
|
- Recruiter Call Scheduled
|
||||||
- Company First Interview
|
- Company First Interview
|
||||||
@@ -30,16 +14,12 @@ For EACH of the above fields that contains a date/time, create a Google Calendar
|
|||||||
- Location: the video meeting link, phone number, or platform name
|
- Location: the video meeting link, phone number, or platform name
|
||||||
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
- Description: include recruiter or company contact name/email/phone, and note whether they have your cell number
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
STRICT CONSTRAINTS:
|
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.
|
- 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) 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.
|
- 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.
|
- 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.
|
- 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)
|
- Do not create duplicate calendar events for the same meeting (same company representative && same job description | same company && same date time)
|
||||||
- If information is not present in the email, leave that field empty (it's OK!) - do not guess or make up information.
|
- If information is not present in the email, leave that field empty (it's OK!) - do not guess or make up information.
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -109,3 +109,5 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -3,303 +3,360 @@ import { gmail_v1 } from "googleapis";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { server } from "../McpServer.js";
|
import { server } from "../McpServer.js";
|
||||||
import {
|
import {
|
||||||
accountSchema,
|
accountSchema,
|
||||||
getGmailClient,
|
getGmailClient,
|
||||||
getSummaryPath,
|
getSummaryPath,
|
||||||
loadClassificationPrompt,
|
loadClassificationPrompt,
|
||||||
loadActionPrompt,
|
loadActionPrompt,
|
||||||
} from "../loaders/prompt-config-loaders.js";
|
} from "../loaders/prompt-config-loaders.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Email parsing helpers
|
// Email parsing helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const getHeader = (
|
export const getHeader = (
|
||||||
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
||||||
name: string
|
name: string
|
||||||
): string => {
|
): string => {
|
||||||
if (!headers) return "";
|
if (!headers) return "";
|
||||||
const header = headers.find(
|
const header = headers.find(
|
||||||
(h) => h.name?.toLowerCase() === name.toLowerCase()
|
(h) => h.name?.toLowerCase() === name.toLowerCase()
|
||||||
);
|
);
|
||||||
return header?.value ?? "";
|
return header?.value ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
||||||
const parts = message.payload?.parts;
|
const parts = message.payload?.parts;
|
||||||
let encoded = "";
|
let encoded = "";
|
||||||
|
|
||||||
if (parts) {
|
if (parts) {
|
||||||
const textPart = parts.find((p) => p.mimeType === "text/plain");
|
const textPart = parts.find((p) => p.mimeType === "text/plain");
|
||||||
encoded = textPart?.body?.data ?? "";
|
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) {
|
if (!encoded) {
|
||||||
const htmlPart = parts.find((p) => p.mimeType === "text/html");
|
return message.snippet ?? "";
|
||||||
encoded = htmlPart?.body?.data ?? "";
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
encoded = message.payload?.body?.data ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!encoded) {
|
return Buffer.from(encoded, "base64url").toString("utf-8");
|
||||||
return message.snippet ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.from(encoded, "base64url").toString("utf-8");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tool: fetch_new_emails
|
// Tool: fetch_new_emails
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"fetch_new_emails",
|
"fetch_new_emails",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
||||||
"subject, message ID, and body text for each message. The message IDs " +
|
"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.",
|
"must be passed to star_emails after evaluation. Specify which account to fetch from.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
maxResults: z
|
maxResults: z
|
||||||
.number()
|
.number()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(200)
|
||||||
.describe("Maximum number of unread emails to fetch (1-100)"),
|
.describe("Maximum number of unread emails to fetch (1-200)"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
async ({ account, maxResults }) => {
|
||||||
async ({ account, maxResults }) => {
|
try {
|
||||||
try {
|
const gmail = getGmailClient(account);
|
||||||
const gmail = getGmailClient(account);
|
|
||||||
|
|
||||||
const listResponse = await gmail.users.messages.list({
|
const listResponse = await gmail.users.messages.list({
|
||||||
userId: "me",
|
userId: "me",
|
||||||
q: "is:unread",
|
q: "is:unread",
|
||||||
maxResults,
|
maxResults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageIds = listResponse.data.messages ?? [];
|
const messageIds = listResponse.data.messages ?? [];
|
||||||
|
|
||||||
if (messageIds.length === 0) {
|
if (messageIds.length === 0) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: "No unread emails found." }],
|
content: [{ type: "text" as const, text: "No unread emails found." }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const emails: string[] = [];
|
const emails: string[] = [];
|
||||||
|
|
||||||
for (const msg of messageIds) {
|
for (const msg of messageIds) {
|
||||||
const detail = await gmail.users.messages.get({
|
const detail = await gmail.users.messages.get({
|
||||||
userId: "me",
|
userId: "me",
|
||||||
id: msg.id!,
|
id: msg.id!,
|
||||||
format: "full",
|
format: "full",
|
||||||
});
|
});
|
||||||
|
|
||||||
const headers = detail.data.payload?.headers;
|
const headers = detail.data.payload?.headers;
|
||||||
const from = getHeader(headers, "From");
|
const from = getHeader(headers, "From");
|
||||||
const subject = getHeader(headers, "Subject");
|
const subject = getHeader(headers, "Subject");
|
||||||
const date = getHeader(headers, "Date");
|
const date = getHeader(headers, "Date");
|
||||||
const body = decodeBody(detail.data);
|
const body = decodeBody(detail.data);
|
||||||
|
|
||||||
const truncatedBody =
|
const truncatedBody =
|
||||||
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
|
body.length > 2000 ? body.substring(0, 2000) + "\n[...truncated]" : body;
|
||||||
|
|
||||||
emails.push(
|
emails.push(
|
||||||
[
|
[
|
||||||
`MESSAGE_ID: ${msg.id}`,
|
`MESSAGE_ID: ${msg.id}`,
|
||||||
`FROM: ${from}`,
|
`FROM: ${from}`,
|
||||||
`DATE: ${date}`,
|
`DATE: ${date}`,
|
||||||
`SUBJECT: ${subject}`,
|
`SUBJECT: ${subject}`,
|
||||||
`BODY:\n${truncatedBody}`,
|
`BODY:\n${truncatedBody}`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classificationInstructions = loadClassificationPrompt();
|
const classificationInstructions = loadClassificationPrompt();
|
||||||
const actionInstructions = loadActionPrompt();
|
const actionInstructions = loadActionPrompt();
|
||||||
const instructionsBlock =
|
const instructionsBlock =
|
||||||
(classificationInstructions
|
(classificationInstructions
|
||||||
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
|
? `\n\n${"=".repeat(60)}\nCLASSIFICATION INSTRUCTIONS (PHASE 1):\n${"=".repeat(60)}\n${classificationInstructions}`
|
||||||
: "") +
|
: "") +
|
||||||
(actionInstructions
|
(actionInstructions
|
||||||
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
|
? `\n\n${"=".repeat(60)}\nACTION INSTRUCTIONS (PHASE 2):\n${"=".repeat(60)}\n${actionInstructions}`
|
||||||
: "");
|
: "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text:
|
text:
|
||||||
`Found ${emails.length} unread email(s):\n\n` +
|
`Found ${emails.length} unread email(s):\n\n` +
|
||||||
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
|
`${"=".repeat(60)}\n${emails.join(`\n${"=".repeat(60)}\n`)}` +
|
||||||
instructionsBlock,
|
instructionsBlock,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: `Error fetching emails: ${errMsg}`,
|
text: `Error fetching emails: ${errMsg}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tool: delete_emails
|
// Tool: delete_emails
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"delete_emails",
|
"delete_emails",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Move emails to trash by their Gmail message IDs. Use this for " +
|
"Move emails to trash by their Gmail message IDs. Use this for " +
|
||||||
"category A (acknowledgements) and category C (rejections) emails. " +
|
"category A (acknowledgements) and category C (rejections) emails. " +
|
||||||
"Specify which account the emails belong to.",
|
"Specify which account the emails belong to.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
messageIds: z
|
messageIds: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.describe("Array of Gmail message IDs to move to trash"),
|
.describe("Array of Gmail message IDs to move to trash"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
async ({ account, messageIds }) => {
|
||||||
async ({ account, messageIds }) => {
|
|
||||||
try {
|
|
||||||
const gmail = getGmailClient(account);
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
for (const id of messageIds) {
|
|
||||||
try {
|
try {
|
||||||
await gmail.users.messages.trash({
|
const gmail = getGmailClient(account);
|
||||||
userId: "me",
|
const results: string[] = [];
|
||||||
id,
|
|
||||||
});
|
|
||||||
results.push(`Trashed: ${id}`);
|
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
results.push(`Failed to trash ${id}: ${errMsg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
for (const id of messageIds) {
|
||||||
content: [
|
try {
|
||||||
{
|
await gmail.users.messages.trash({
|
||||||
type: "text" as const,
|
userId: "me",
|
||||||
text: `Delete results:\n${results.join("\n")}`,
|
id,
|
||||||
},
|
});
|
||||||
],
|
results.push(`Trashed: ${id}`);
|
||||||
};
|
} catch (err) {
|
||||||
} catch (error) {
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
results.push(`Failed to trash ${id}: ${errMsg}`);
|
||||||
return {
|
}
|
||||||
content: [
|
}
|
||||||
{
|
|
||||||
type: "text" as const,
|
return {
|
||||||
text: `Error deleting emails: ${errMsg}`,
|
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
|
// Tool: append_to_summary
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
interface SummaryEntry {
|
interface SummaryEntry {
|
||||||
senderName: string;
|
senderName: string;
|
||||||
senderEmail: string;
|
senderEmail: string;
|
||||||
dateReceived: string;
|
dateReceived: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
category: "B" | "D";
|
category: "B" | "D";
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"append_to_summary",
|
"append_to_summary",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Append classified email entries to the local summary file. Use this " +
|
"Append classified email entries to the local summary file. Use this " +
|
||||||
"for category B (advancement to next step) and category D (other) emails. " +
|
"for category B (advancement to next step) and category D (other) emails. " +
|
||||||
"Each entry records the sender, date, subject, and category. " +
|
"Each entry records the sender, date, subject, and category. " +
|
||||||
"Specify which account the emails belong to.",
|
"Specify which account the emails belong to.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
account: accountSchema,
|
account: accountSchema,
|
||||||
entries: z
|
entries: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
senderName: z.string().describe("Name of the sender"),
|
senderName: z.string().describe("Name of the sender"),
|
||||||
senderEmail: z.string().describe("Email address of the sender"),
|
senderEmail: z.string().describe("Email address of the sender"),
|
||||||
dateReceived: z
|
dateReceived: z
|
||||||
.string()
|
.string()
|
||||||
.describe("Date and time the email was received"),
|
.describe("Date and time the email was received"),
|
||||||
subject: z.string().describe("Email subject line"),
|
subject: z.string().describe("Email subject line"),
|
||||||
category: z
|
category: z
|
||||||
.enum(["B", "D"])
|
.enum(["B", "D"])
|
||||||
.describe(
|
.describe(
|
||||||
"Category: B = advancement to next step, D = other/uncategorized"
|
"Category: B = advancement to next step, D = other/uncategorized"
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.describe("Array of email summary entries to append"),
|
.describe("Array of email summary entries to append"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
async ({ account, entries }) => {
|
||||||
async ({ account, entries }) => {
|
try {
|
||||||
try {
|
const summaryPath = getSummaryPath(account);
|
||||||
const summaryPath = getSummaryPath(account);
|
|
||||||
|
|
||||||
let summary: SummaryEntry[] = [];
|
let summary: SummaryEntry[] = [];
|
||||||
if (fs.existsSync(summaryPath)) {
|
if (fs.existsSync(summaryPath)) {
|
||||||
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nowIso = now.toISOString();
|
const nowIso = now.toISOString();
|
||||||
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
const newEntries: SummaryEntry[] = entries.map((e) => ({
|
||||||
...e,
|
...e,
|
||||||
addedAt: nowIso,
|
addedAt: nowIso,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
summary.push(...newEntries);
|
summary.push(...newEntries);
|
||||||
|
|
||||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const cutoff = now.getTime() - RETENTION_MS;
|
const cutoff = now.getTime() - RETENTION_MS;
|
||||||
const beforePurge = summary.length;
|
const beforePurge = summary.length;
|
||||||
summary = summary.filter(
|
summary = summary.filter(
|
||||||
(entry) => new Date(entry.addedAt).getTime() >= cutoff
|
(entry) => new Date(entry.addedAt).getTime() >= cutoff
|
||||||
);
|
);
|
||||||
const purged = beforePurge - summary.length;
|
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
|
const purgeNote = purged > 0
|
||||||
? `\nPurged ${purged} entry/entries older than 30 days.`
|
? `\nPurged ${purged} entry/entries older than 30 days.`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text:
|
text:
|
||||||
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
||||||
`Total entries in summary: ${summary.length}\n` +
|
`Total entries in summary: ${summary.length}\n` +
|
||||||
`Summary file: ${summaryPath}` +
|
`Summary file: ${summaryPath}` +
|
||||||
purgeNote,
|
purgeNote,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: `Error appending to summary: ${errMsg}`,
|
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}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -177,3 +177,5 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
2
test/fixtures/mock-emails.ts
vendored
2
test/fixtures/mock-emails.ts
vendored
@@ -165,3 +165,5 @@ export const RAW_BODIES = {
|
|||||||
"msg-cat-c-001": catCBody,
|
"msg-cat-c-001": catCBody,
|
||||||
"msg-cat-d-001": catDBody,
|
"msg-cat-d-001": catDBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -285,3 +285,5 @@ describe("Phase 1: Email Review Workflow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -84,3 +84,5 @@ describe("decodeBody", () => {
|
|||||||
expect(decodeBody(msg)).toBe(htmlContent);
|
expect(decodeBody(msg)).toBe(htmlContent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "Node16",
|
"module": "Node16",
|
||||||
"moduleResolution": "Node16",
|
"moduleResolution": "Node16",
|
||||||
"outDir": "./build",
|
"outDir": "./build",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": [
|
||||||
"exclude": ["node_modules", "test", "vitest.config.ts"]
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"test",
|
||||||
|
"vitest.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -6,3 +6,5 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// revised Aug 31
|
||||||
|
|||||||
Reference in New Issue
Block a user