@hmawla/co-assistant 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +28 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/heartbeats/email-reply-check.heartbeat.md +13 -14
- package/package.json +1 -1
- package/plugins/gmail/index.compiled.mjs +146 -10
- package/plugins/gmail/plugin.json +1 -1
- package/plugins/gmail/tools.ts +240 -11
|
@@ -2,30 +2,29 @@ You are checking my recent emails for any that require a reply from me.
|
|
|
2
2
|
|
|
3
3
|
## Instructions
|
|
4
4
|
|
|
5
|
-
1. Use
|
|
6
|
-
2.
|
|
7
|
-
3.
|
|
8
|
-
4.
|
|
5
|
+
1. Use `gmail__search_threads` to fetch my recent inbox threads (query: `in:inbox`, maxThreads: 18, **includeLatestBody: true**). This single call returns all threads with every message (including your sent replies) and the full body of the latest message. **Do NOT call any other Gmail tools** — this gives you everything you need.
|
|
6
|
+
2. Skip any thread whose latest message ID appears in the deduplication list below. Also skip newsletters, automated notifications, marketing, no-reply senders, and receipts.
|
|
7
|
+
3. For each remaining thread, check the `lastMessageIsSent` field. **If `lastMessageIsSent` is true, I already replied — SKIP this thread.**
|
|
8
|
+
4. Only for threads where `lastMessageIsSent` is false, determine whether it requires a reply from me. Consider:
|
|
9
9
|
- Direct questions asked to me
|
|
10
10
|
- Action items or requests directed at me
|
|
11
11
|
- Invitations or RSVPs awaiting my response
|
|
12
12
|
- Important threads where I'm expected to respond
|
|
13
|
-
|
|
14
|
-
5. For each email that needs a reply, suggest a concise, professional reply draft.
|
|
13
|
+
5. For each thread that needs a reply, suggest a concise, professional reply draft based on the latest incoming message body.
|
|
15
14
|
|
|
16
15
|
## Output Format
|
|
17
16
|
|
|
18
|
-
For each
|
|
17
|
+
For each thread that needs a reply, output exactly ONE entry (not one per message):
|
|
19
18
|
|
|
20
|
-
**📧 From:** [sender]
|
|
21
|
-
**Subject:** [subject]
|
|
22
|
-
**Why reply:** [brief reason]
|
|
19
|
+
**📧 From:** [sender of the latest message]
|
|
20
|
+
**Subject:** [thread subject]
|
|
21
|
+
**Why reply:** [brief reason based on the latest message in the thread]
|
|
23
22
|
**Suggested reply:**
|
|
24
23
|
> [your suggested reply text]
|
|
25
24
|
|
|
26
25
|
---
|
|
27
26
|
|
|
28
|
-
If no
|
|
27
|
+
If no threads require a reply, do not say anything unless invoked with /heartbeat
|
|
29
28
|
|
|
30
29
|
## Deduplication
|
|
31
30
|
|
|
@@ -33,7 +32,7 @@ If no emails require a reply, do not say anything unless invoked with /heartbeat
|
|
|
33
32
|
|
|
34
33
|
## IMPORTANT — Deduplication Marker
|
|
35
34
|
|
|
36
|
-
At the very end of your response, you MUST output exactly one line in this format with
|
|
35
|
+
At the very end of your response, you MUST output exactly one line in this format with the message ID of the **most recent message per thread** you checked (whether it needed a reply or not). Only one ID per thread. This prevents re-checking the same threads next time:
|
|
37
36
|
|
|
38
|
-
<!-- PROCESSED:
|
|
39
|
-
|
|
37
|
+
<!-- PROCESSED: latest_msg_id_thread1, latest_msg_id_thread2, latest_msg_id_thread3 -->
|
|
38
|
+
Do not output the same message ID multiple times.
|
package/package.json
CHANGED
|
@@ -13877,18 +13877,22 @@ function extractBody(payload) {
|
|
|
13877
13877
|
function createGmailTools(auth, logger) {
|
|
13878
13878
|
const searchEmails = {
|
|
13879
13879
|
name: "search_emails",
|
|
13880
|
-
description: "Search for emails in Gmail
|
|
13880
|
+
description: "Search for emails in Gmail. Returns metadata by default; set includeBody=true to also return full message bodies (avoids needing separate read_email calls).",
|
|
13881
13881
|
parameters: external_exports.object({
|
|
13882
13882
|
/** Gmail search query (e.g. "from:alice subject:meeting"). */
|
|
13883
13883
|
query: external_exports.string().describe("Gmail search query"),
|
|
13884
13884
|
/** Maximum number of results to return (default 10, max 50). */
|
|
13885
|
-
maxResults: external_exports.number().int().min(1).max(50).optional().default(10).describe("Maximum number of results to return")
|
|
13885
|
+
maxResults: external_exports.number().int().min(1).max(50).optional().default(10).describe("Maximum number of results to return"),
|
|
13886
|
+
/** When true, fetches full message bodies inline. Slower per message but
|
|
13887
|
+
* eliminates the need for separate read_email calls. */
|
|
13888
|
+
includeBody: external_exports.boolean().optional().default(false).describe("Include full message body in results")
|
|
13886
13889
|
}),
|
|
13887
13890
|
handler: async (args) => {
|
|
13888
13891
|
try {
|
|
13889
13892
|
const query = args.query;
|
|
13890
13893
|
const maxResults = args.maxResults ?? 10;
|
|
13891
|
-
|
|
13894
|
+
const includeBody = args.includeBody ?? false;
|
|
13895
|
+
logger.debug({ query, maxResults, includeBody }, "search_emails called");
|
|
13892
13896
|
const params = new URLSearchParams({
|
|
13893
13897
|
q: query,
|
|
13894
13898
|
maxResults: String(maxResults)
|
|
@@ -13905,24 +13909,28 @@ function createGmailTools(auth, logger) {
|
|
|
13905
13909
|
if (!listData.messages?.length) {
|
|
13906
13910
|
return "No emails found matching that query.";
|
|
13907
13911
|
}
|
|
13912
|
+
const format = includeBody ? "full" : "metadata";
|
|
13908
13913
|
const headers = await authHeaders(auth);
|
|
13909
13914
|
const results = await Promise.all(
|
|
13910
13915
|
listData.messages.map(async (msg) => {
|
|
13911
|
-
const
|
|
13912
|
-
|
|
13913
|
-
{ headers }
|
|
13914
|
-
);
|
|
13916
|
+
const url2 = includeBody ? `${GMAIL_API}/messages/${msg.id}?format=${format}` : `${GMAIL_API}/messages/${msg.id}?format=${format}&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`;
|
|
13917
|
+
const msgRes = await fetch(url2, { headers });
|
|
13915
13918
|
if (!msgRes.ok) {
|
|
13916
|
-
return { id: msg.id, error: `Failed to fetch (${msgRes.status})` };
|
|
13919
|
+
return { id: msg.id, threadId: msg.threadId, error: `Failed to fetch (${msgRes.status})` };
|
|
13917
13920
|
}
|
|
13918
13921
|
const msgData = await msgRes.json();
|
|
13919
|
-
|
|
13922
|
+
const result = {
|
|
13920
13923
|
id: msgData.id,
|
|
13924
|
+
threadId: msgData.threadId,
|
|
13921
13925
|
subject: getHeader(msgData.payload.headers, "Subject"),
|
|
13922
13926
|
from: getHeader(msgData.payload.headers, "From"),
|
|
13923
13927
|
date: getHeader(msgData.payload.headers, "Date"),
|
|
13924
13928
|
snippet: msgData.snippet
|
|
13925
13929
|
};
|
|
13930
|
+
if (includeBody) {
|
|
13931
|
+
result.body = extractBody(msgData.payload);
|
|
13932
|
+
}
|
|
13933
|
+
return result;
|
|
13926
13934
|
})
|
|
13927
13935
|
);
|
|
13928
13936
|
return {
|
|
@@ -14030,7 +14038,135 @@ function createGmailTools(auth, logger) {
|
|
|
14030
14038
|
}
|
|
14031
14039
|
}
|
|
14032
14040
|
};
|
|
14033
|
-
|
|
14041
|
+
const getThread = {
|
|
14042
|
+
name: "get_thread",
|
|
14043
|
+
description: "Get all messages in a Gmail thread (including your sent replies). Use this to check if you already replied to a conversation before suggesting a new reply.",
|
|
14044
|
+
parameters: external_exports.object({
|
|
14045
|
+
/** The Gmail thread ID (returned by search_emails or read_email). */
|
|
14046
|
+
threadId: external_exports.string().describe("Gmail thread ID")
|
|
14047
|
+
}),
|
|
14048
|
+
handler: async (args) => {
|
|
14049
|
+
try {
|
|
14050
|
+
const threadId = args.threadId;
|
|
14051
|
+
logger.debug({ threadId }, "get_thread called");
|
|
14052
|
+
const res = await fetch(
|
|
14053
|
+
`${GMAIL_API}/threads/${threadId}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date`,
|
|
14054
|
+
{ headers: await authHeaders(auth) }
|
|
14055
|
+
);
|
|
14056
|
+
if (!res.ok) {
|
|
14057
|
+
const errText = await res.text();
|
|
14058
|
+
logger.error({ status: res.status, errText }, "Gmail get_thread failed");
|
|
14059
|
+
return `Error getting thread (${res.status}): ${errText}`;
|
|
14060
|
+
}
|
|
14061
|
+
const data = await res.json();
|
|
14062
|
+
const messages = data.messages.map((msg) => ({
|
|
14063
|
+
id: msg.id,
|
|
14064
|
+
from: getHeader(msg.payload.headers, "From"),
|
|
14065
|
+
to: getHeader(msg.payload.headers, "To"),
|
|
14066
|
+
date: getHeader(msg.payload.headers, "Date"),
|
|
14067
|
+
snippet: msg.snippet,
|
|
14068
|
+
isSent: msg.labelIds?.includes("SENT") ?? false
|
|
14069
|
+
}));
|
|
14070
|
+
return {
|
|
14071
|
+
threadId: data.id,
|
|
14072
|
+
messageCount: messages.length,
|
|
14073
|
+
messages
|
|
14074
|
+
};
|
|
14075
|
+
} catch (error48) {
|
|
14076
|
+
const message = error48 instanceof Error ? error48.message : String(error48);
|
|
14077
|
+
logger.error({ error: message }, "get_thread error");
|
|
14078
|
+
return `Error getting thread: ${message}`;
|
|
14079
|
+
}
|
|
14080
|
+
}
|
|
14081
|
+
};
|
|
14082
|
+
const searchThreads = {
|
|
14083
|
+
name: "search_threads",
|
|
14084
|
+
description: "Search Gmail and return results grouped by thread. Each thread includes ALL messages (including your sent replies) with isSent flags, so you can tell at a glance whether you already replied. Ideal for inbox review \u2014 returns everything in a single call.",
|
|
14085
|
+
parameters: external_exports.object({
|
|
14086
|
+
/** Gmail search query (e.g. "in:inbox", "from:alice"). */
|
|
14087
|
+
query: external_exports.string().describe("Gmail search query"),
|
|
14088
|
+
/** Maximum threads to return (default 5, max 20). */
|
|
14089
|
+
maxThreads: external_exports.number().int().min(1).max(20).optional().default(5).describe("Maximum number of threads to return"),
|
|
14090
|
+
/** When true, includes the full decoded body of the latest message in
|
|
14091
|
+
* each thread. When false, only snippets are returned. */
|
|
14092
|
+
includeLatestBody: external_exports.boolean().optional().default(false).describe("Include full body of the latest message per thread")
|
|
14093
|
+
}),
|
|
14094
|
+
handler: async (args) => {
|
|
14095
|
+
try {
|
|
14096
|
+
const query = args.query;
|
|
14097
|
+
const maxThreads = args.maxThreads ?? 5;
|
|
14098
|
+
const includeLatestBody = args.includeLatestBody ?? false;
|
|
14099
|
+
logger.debug({ query, maxThreads, includeLatestBody }, "search_threads called");
|
|
14100
|
+
const params = new URLSearchParams({
|
|
14101
|
+
q: query,
|
|
14102
|
+
maxResults: String(maxThreads)
|
|
14103
|
+
});
|
|
14104
|
+
const listRes = await fetch(`${GMAIL_API}/threads?${params}`, {
|
|
14105
|
+
headers: await authHeaders(auth)
|
|
14106
|
+
});
|
|
14107
|
+
if (!listRes.ok) {
|
|
14108
|
+
const errText = await listRes.text();
|
|
14109
|
+
logger.error({ status: listRes.status, errText }, "Gmail threads search failed");
|
|
14110
|
+
return `Error searching threads (${listRes.status}): ${errText}`;
|
|
14111
|
+
}
|
|
14112
|
+
const listData = await listRes.json();
|
|
14113
|
+
if (!listData.threads?.length) {
|
|
14114
|
+
return { threadCount: 0, threads: [] };
|
|
14115
|
+
}
|
|
14116
|
+
const format = includeLatestBody ? "full" : "metadata";
|
|
14117
|
+
const headers = await authHeaders(auth);
|
|
14118
|
+
const threads = await Promise.all(
|
|
14119
|
+
listData.threads.map(async (t) => {
|
|
14120
|
+
const url2 = `${GMAIL_API}/threads/${t.id}?format=${format}&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date`;
|
|
14121
|
+
const res = await fetch(url2, { headers });
|
|
14122
|
+
if (!res.ok) {
|
|
14123
|
+
return { threadId: t.id, error: `Failed to fetch (${res.status})` };
|
|
14124
|
+
}
|
|
14125
|
+
const data = await res.json();
|
|
14126
|
+
const messages = data.messages.map((msg) => {
|
|
14127
|
+
const result = {
|
|
14128
|
+
id: msg.id,
|
|
14129
|
+
from: getHeader(msg.payload.headers, "From"),
|
|
14130
|
+
to: getHeader(msg.payload.headers, "To"),
|
|
14131
|
+
date: getHeader(msg.payload.headers, "Date"),
|
|
14132
|
+
snippet: msg.snippet,
|
|
14133
|
+
isSent: msg.labelIds?.includes("SENT") ?? false
|
|
14134
|
+
};
|
|
14135
|
+
return result;
|
|
14136
|
+
});
|
|
14137
|
+
const latest = data.messages[data.messages.length - 1];
|
|
14138
|
+
const lastIsSent = latest?.labelIds?.includes("SENT") ?? false;
|
|
14139
|
+
const subject = getHeader(
|
|
14140
|
+
data.messages[0].payload.headers,
|
|
14141
|
+
"Subject"
|
|
14142
|
+
);
|
|
14143
|
+
const thread = {
|
|
14144
|
+
threadId: data.id,
|
|
14145
|
+
subject,
|
|
14146
|
+
messageCount: messages.length,
|
|
14147
|
+
lastMessageIsSent: lastIsSent,
|
|
14148
|
+
messages
|
|
14149
|
+
};
|
|
14150
|
+
if (includeLatestBody && latest) {
|
|
14151
|
+
thread.latestBody = extractBody(
|
|
14152
|
+
latest.payload
|
|
14153
|
+
);
|
|
14154
|
+
}
|
|
14155
|
+
return thread;
|
|
14156
|
+
})
|
|
14157
|
+
);
|
|
14158
|
+
return {
|
|
14159
|
+
threadCount: threads.length,
|
|
14160
|
+
threads
|
|
14161
|
+
};
|
|
14162
|
+
} catch (error48) {
|
|
14163
|
+
const message = error48 instanceof Error ? error48.message : String(error48);
|
|
14164
|
+
logger.error({ error: message }, "search_threads error");
|
|
14165
|
+
return `Error searching threads: ${message}`;
|
|
14166
|
+
}
|
|
14167
|
+
}
|
|
14168
|
+
};
|
|
14169
|
+
return [searchEmails, readEmail, sendEmail, getThread, searchThreads];
|
|
14034
14170
|
}
|
|
14035
14171
|
|
|
14036
14172
|
// plugins/gmail/index.ts
|
package/plugins/gmail/tools.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function createGmailTools(auth: GmailAuth, logger: Logger): ToolDefinitio
|
|
|
117
117
|
const searchEmails: ToolDefinition = {
|
|
118
118
|
name: "search_emails",
|
|
119
119
|
description:
|
|
120
|
-
"Search for emails in Gmail
|
|
120
|
+
"Search for emails in Gmail. Returns metadata by default; set includeBody=true to also return full message bodies (avoids needing separate read_email calls).",
|
|
121
121
|
parameters: z.object({
|
|
122
122
|
/** Gmail search query (e.g. "from:alice subject:meeting"). */
|
|
123
123
|
query: z.string().describe("Gmail search query"),
|
|
@@ -130,13 +130,21 @@ export function createGmailTools(auth: GmailAuth, logger: Logger): ToolDefinitio
|
|
|
130
130
|
.optional()
|
|
131
131
|
.default(10)
|
|
132
132
|
.describe("Maximum number of results to return"),
|
|
133
|
+
/** When true, fetches full message bodies inline. Slower per message but
|
|
134
|
+
* eliminates the need for separate read_email calls. */
|
|
135
|
+
includeBody: z
|
|
136
|
+
.boolean()
|
|
137
|
+
.optional()
|
|
138
|
+
.default(false)
|
|
139
|
+
.describe("Include full message body in results"),
|
|
133
140
|
}),
|
|
134
141
|
|
|
135
142
|
handler: async (args) => {
|
|
136
143
|
try {
|
|
137
144
|
const query = args.query as string;
|
|
138
145
|
const maxResults = (args.maxResults as number | undefined) ?? 10;
|
|
139
|
-
|
|
146
|
+
const includeBody = (args.includeBody as boolean | undefined) ?? false;
|
|
147
|
+
logger.debug({ query, maxResults, includeBody }, "search_emails called");
|
|
140
148
|
|
|
141
149
|
// Step 1 — List message IDs matching the query.
|
|
142
150
|
const params = new URLSearchParams({
|
|
@@ -162,32 +170,47 @@ export function createGmailTools(auth: GmailAuth, logger: Logger): ToolDefinitio
|
|
|
162
170
|
return "No emails found matching that query.";
|
|
163
171
|
}
|
|
164
172
|
|
|
165
|
-
// Step 2 — Fetch each message
|
|
173
|
+
// Step 2 — Fetch each message. Use "full" format when body is
|
|
174
|
+
// requested, otherwise "metadata" for a lighter response.
|
|
175
|
+
const format = includeBody ? "full" : "metadata";
|
|
166
176
|
const headers = await authHeaders(auth);
|
|
167
177
|
const results = await Promise.all(
|
|
168
178
|
listData.messages.map(async (msg) => {
|
|
169
|
-
const
|
|
170
|
-
`${GMAIL_API}/messages/${msg.id}?format
|
|
171
|
-
{
|
|
172
|
-
);
|
|
179
|
+
const url = includeBody
|
|
180
|
+
? `${GMAIL_API}/messages/${msg.id}?format=${format}`
|
|
181
|
+
: `${GMAIL_API}/messages/${msg.id}?format=${format}&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`;
|
|
182
|
+
const msgRes = await fetch(url, { headers });
|
|
173
183
|
|
|
174
184
|
if (!msgRes.ok) {
|
|
175
|
-
return { id: msg.id, error: `Failed to fetch (${msgRes.status})` };
|
|
185
|
+
return { id: msg.id, threadId: msg.threadId, error: `Failed to fetch (${msgRes.status})` };
|
|
176
186
|
}
|
|
177
187
|
|
|
178
188
|
const msgData = (await msgRes.json()) as {
|
|
179
189
|
id: string;
|
|
190
|
+
threadId: string;
|
|
180
191
|
snippet: string;
|
|
181
|
-
payload: {
|
|
192
|
+
payload: {
|
|
193
|
+
headers: Array<{ name: string; value: string }>;
|
|
194
|
+
body?: { data?: string };
|
|
195
|
+
parts?: Array<Record<string, unknown>>;
|
|
196
|
+
mimeType?: string;
|
|
197
|
+
};
|
|
182
198
|
};
|
|
183
199
|
|
|
184
|
-
|
|
200
|
+
const result: Record<string, unknown> = {
|
|
185
201
|
id: msgData.id,
|
|
202
|
+
threadId: msgData.threadId,
|
|
186
203
|
subject: getHeader(msgData.payload.headers, "Subject"),
|
|
187
204
|
from: getHeader(msgData.payload.headers, "From"),
|
|
188
205
|
date: getHeader(msgData.payload.headers, "Date"),
|
|
189
206
|
snippet: msgData.snippet,
|
|
190
207
|
};
|
|
208
|
+
|
|
209
|
+
if (includeBody) {
|
|
210
|
+
result.body = extractBody(msgData.payload as Record<string, unknown>);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return result;
|
|
191
214
|
}),
|
|
192
215
|
);
|
|
193
216
|
|
|
@@ -332,5 +355,211 @@ export function createGmailTools(auth: GmailAuth, logger: Logger): ToolDefinitio
|
|
|
332
355
|
},
|
|
333
356
|
};
|
|
334
357
|
|
|
335
|
-
|
|
358
|
+
// -----------------------------------------------------------------------
|
|
359
|
+
// get_thread
|
|
360
|
+
// -----------------------------------------------------------------------
|
|
361
|
+
const getThread: ToolDefinition = {
|
|
362
|
+
name: "get_thread",
|
|
363
|
+
description:
|
|
364
|
+
"Get all messages in a Gmail thread (including your sent replies). " +
|
|
365
|
+
"Use this to check if you already replied to a conversation before suggesting a new reply.",
|
|
366
|
+
parameters: z.object({
|
|
367
|
+
/** The Gmail thread ID (returned by search_emails or read_email). */
|
|
368
|
+
threadId: z.string().describe("Gmail thread ID"),
|
|
369
|
+
}),
|
|
370
|
+
|
|
371
|
+
handler: async (args) => {
|
|
372
|
+
try {
|
|
373
|
+
const threadId = args.threadId as string;
|
|
374
|
+
logger.debug({ threadId }, "get_thread called");
|
|
375
|
+
|
|
376
|
+
const res = await fetch(
|
|
377
|
+
`${GMAIL_API}/threads/${threadId}?format=metadata` +
|
|
378
|
+
"&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date",
|
|
379
|
+
{ headers: await authHeaders(auth) },
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (!res.ok) {
|
|
383
|
+
const errText = await res.text();
|
|
384
|
+
logger.error({ status: res.status, errText }, "Gmail get_thread failed");
|
|
385
|
+
return `Error getting thread (${res.status}): ${errText}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const data = (await res.json()) as {
|
|
389
|
+
id: string;
|
|
390
|
+
messages: Array<{
|
|
391
|
+
id: string;
|
|
392
|
+
labelIds: string[];
|
|
393
|
+
snippet: string;
|
|
394
|
+
payload: { headers: Array<{ name: string; value: string }> };
|
|
395
|
+
}>;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const messages = data.messages.map((msg) => ({
|
|
399
|
+
id: msg.id,
|
|
400
|
+
from: getHeader(msg.payload.headers, "From"),
|
|
401
|
+
to: getHeader(msg.payload.headers, "To"),
|
|
402
|
+
date: getHeader(msg.payload.headers, "Date"),
|
|
403
|
+
snippet: msg.snippet,
|
|
404
|
+
isSent: msg.labelIds?.includes("SENT") ?? false,
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
threadId: data.id,
|
|
409
|
+
messageCount: messages.length,
|
|
410
|
+
messages,
|
|
411
|
+
};
|
|
412
|
+
} catch (error) {
|
|
413
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
414
|
+
logger.error({ error: message }, "get_thread error");
|
|
415
|
+
return `Error getting thread: ${message}`;
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// -----------------------------------------------------------------------
|
|
421
|
+
// search_threads (single-call inbox analysis)
|
|
422
|
+
// -----------------------------------------------------------------------
|
|
423
|
+
const searchThreads: ToolDefinition = {
|
|
424
|
+
name: "search_threads",
|
|
425
|
+
description:
|
|
426
|
+
"Search Gmail and return results grouped by thread. Each thread includes " +
|
|
427
|
+
"ALL messages (including your sent replies) with isSent flags, so you can " +
|
|
428
|
+
"tell at a glance whether you already replied. Ideal for inbox review — " +
|
|
429
|
+
"returns everything in a single call.",
|
|
430
|
+
parameters: z.object({
|
|
431
|
+
/** Gmail search query (e.g. "in:inbox", "from:alice"). */
|
|
432
|
+
query: z.string().describe("Gmail search query"),
|
|
433
|
+
/** Maximum threads to return (default 5, max 20). */
|
|
434
|
+
maxThreads: z
|
|
435
|
+
.number()
|
|
436
|
+
.int()
|
|
437
|
+
.min(1)
|
|
438
|
+
.max(20)
|
|
439
|
+
.optional()
|
|
440
|
+
.default(5)
|
|
441
|
+
.describe("Maximum number of threads to return"),
|
|
442
|
+
/** When true, includes the full decoded body of the latest message in
|
|
443
|
+
* each thread. When false, only snippets are returned. */
|
|
444
|
+
includeLatestBody: z
|
|
445
|
+
.boolean()
|
|
446
|
+
.optional()
|
|
447
|
+
.default(false)
|
|
448
|
+
.describe("Include full body of the latest message per thread"),
|
|
449
|
+
}),
|
|
450
|
+
|
|
451
|
+
handler: async (args) => {
|
|
452
|
+
try {
|
|
453
|
+
const query = args.query as string;
|
|
454
|
+
const maxThreads = (args.maxThreads as number | undefined) ?? 5;
|
|
455
|
+
const includeLatestBody = (args.includeLatestBody as boolean | undefined) ?? false;
|
|
456
|
+
logger.debug({ query, maxThreads, includeLatestBody }, "search_threads called");
|
|
457
|
+
|
|
458
|
+
// Step 1 — List thread IDs matching the query.
|
|
459
|
+
const params = new URLSearchParams({
|
|
460
|
+
q: query,
|
|
461
|
+
maxResults: String(maxThreads),
|
|
462
|
+
});
|
|
463
|
+
const listRes = await fetch(`${GMAIL_API}/threads?${params}`, {
|
|
464
|
+
headers: await authHeaders(auth),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
if (!listRes.ok) {
|
|
468
|
+
const errText = await listRes.text();
|
|
469
|
+
logger.error({ status: listRes.status, errText }, "Gmail threads search failed");
|
|
470
|
+
return `Error searching threads (${listRes.status}): ${errText}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const listData = (await listRes.json()) as {
|
|
474
|
+
threads?: Array<{ id: string; snippet: string }>;
|
|
475
|
+
resultSizeEstimate?: number;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (!listData.threads?.length) {
|
|
479
|
+
return { threadCount: 0, threads: [] };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Step 2 — Fetch each thread. Use "full" format for the latest
|
|
483
|
+
// message body when requested, "metadata" otherwise.
|
|
484
|
+
const format = includeLatestBody ? "full" : "metadata";
|
|
485
|
+
const headers = await authHeaders(auth);
|
|
486
|
+
const threads = await Promise.all(
|
|
487
|
+
listData.threads.map(async (t) => {
|
|
488
|
+
const url =
|
|
489
|
+
`${GMAIL_API}/threads/${t.id}?format=${format}` +
|
|
490
|
+
"&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
|
|
491
|
+
const res = await fetch(url, { headers });
|
|
492
|
+
|
|
493
|
+
if (!res.ok) {
|
|
494
|
+
return { threadId: t.id, error: `Failed to fetch (${res.status})` };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const data = (await res.json()) as {
|
|
498
|
+
id: string;
|
|
499
|
+
messages: Array<{
|
|
500
|
+
id: string;
|
|
501
|
+
labelIds: string[];
|
|
502
|
+
snippet: string;
|
|
503
|
+
payload: {
|
|
504
|
+
headers: Array<{ name: string; value: string }>;
|
|
505
|
+
body?: { data?: string };
|
|
506
|
+
parts?: Array<Record<string, unknown>>;
|
|
507
|
+
mimeType?: string;
|
|
508
|
+
};
|
|
509
|
+
}>;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// Build a compact summary for each message in the thread.
|
|
513
|
+
const messages = data.messages.map((msg) => {
|
|
514
|
+
const result: Record<string, unknown> = {
|
|
515
|
+
id: msg.id,
|
|
516
|
+
from: getHeader(msg.payload.headers, "From"),
|
|
517
|
+
to: getHeader(msg.payload.headers, "To"),
|
|
518
|
+
date: getHeader(msg.payload.headers, "Date"),
|
|
519
|
+
snippet: msg.snippet,
|
|
520
|
+
isSent: msg.labelIds?.includes("SENT") ?? false,
|
|
521
|
+
};
|
|
522
|
+
return result;
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// The last message in the array is the most recent.
|
|
526
|
+
const latest = data.messages[data.messages.length - 1];
|
|
527
|
+
const lastIsSent = latest?.labelIds?.includes("SENT") ?? false;
|
|
528
|
+
const subject = getHeader(
|
|
529
|
+
data.messages[0].payload.headers,
|
|
530
|
+
"Subject",
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const thread: Record<string, unknown> = {
|
|
534
|
+
threadId: data.id,
|
|
535
|
+
subject,
|
|
536
|
+
messageCount: messages.length,
|
|
537
|
+
lastMessageIsSent: lastIsSent,
|
|
538
|
+
messages,
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Only decode the body for the latest message when requested.
|
|
542
|
+
if (includeLatestBody && latest) {
|
|
543
|
+
thread.latestBody = extractBody(
|
|
544
|
+
latest.payload as Record<string, unknown>,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return thread;
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
threadCount: threads.length,
|
|
554
|
+
threads,
|
|
555
|
+
};
|
|
556
|
+
} catch (error) {
|
|
557
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
558
|
+
logger.error({ error: message }, "search_threads error");
|
|
559
|
+
return `Error searching threads: ${message}`;
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
return [searchEmails, readEmail, sendEmail, getThread, searchThreads];
|
|
336
565
|
}
|