Skip to content

Commit 6348dbc

Browse files
KrisBraunclaude
andcommitted
Add Gmail support and user instructions to message-tasks twist
Add Gmail as a messaging provider alongside Slack, with provider-aware source references and author info in AI analysis. Also add user instruction system for customizing task creation rules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06eb551 commit 6348dbc

2 files changed

Lines changed: 268 additions & 13 deletions

File tree

twists/message-tasks/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"dependencies": {
1717
"@plotday/twister": "workspace:^",
1818
"@plotday/tool-slack": "workspace:^",
19+
"@plotday/tool-gmail": "workspace:^",
1920
"typebox": "^1.0.35"
2021
},
2122
"devDependencies": {

twists/message-tasks/src/index.ts

Lines changed: 267 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { Type } from "typebox";
22

3+
import { Gmail } from "@plotday/tool-gmail";
34
import { Slack } from "@plotday/tool-slack";
45
import {
56
type ActivityFilter,
67
ActivityType,
78
type NewActivityWithNotes,
9+
type NewContact,
10+
type Note,
811
type Priority,
912
type ToolBuilder,
1013
Twist,
@@ -13,7 +16,15 @@ import { AI, type AIMessage } from "@plotday/twister/tools/ai";
1316
import { ActivityAccess, Plot } from "@plotday/twister/tools/plot";
1417
import { Uuid } from "@plotday/twister/utils/uuid";
1518

16-
type MessageProvider = "slack";
19+
type MessageProvider = "slack" | "gmail";
20+
21+
type Instruction = {
22+
id: string;
23+
text: string;
24+
summary: string;
25+
authorId: string;
26+
created: string;
27+
};
1728

1829
type ThreadTask = {
1930
threadId: string;
@@ -29,11 +40,49 @@ export default class MessageTasksTwist extends Twist<MessageTasksTwist> {
2940
onItem: this.onSlackThread,
3041
onSyncableDisabled: this.onSyncableDisabled,
3142
}),
43+
gmail: build(Gmail, {
44+
onItem: this.onGmailThread,
45+
onSyncableDisabled: this.onSyncableDisabled,
46+
}),
3247
ai: build(AI),
3348
plot: build(Plot, {
3449
activity: {
3550
access: ActivityAccess.Create,
3651
},
52+
note: {
53+
intents: [
54+
{
55+
description:
56+
"Give the twist an instruction that changes how it creates tasks from messages",
57+
examples: [
58+
"Ignore threads from #random",
59+
"Always create tasks for messages from my manager",
60+
"Never create tasks for bot messages",
61+
"Only create tasks when I'm directly mentioned",
62+
],
63+
handler: this.onInstruct,
64+
},
65+
{
66+
description: "List all saved instructions",
67+
examples: [
68+
"What are my instructions?",
69+
"Show my rules",
70+
"List instructions",
71+
],
72+
handler: this.onListInstructions,
73+
},
74+
{
75+
description:
76+
"Forget or remove a specific saved instruction",
77+
examples: [
78+
"Forget instruction about #random",
79+
"Remove rule 3",
80+
"Delete the instruction about bot messages",
81+
],
82+
handler: this.onForgetInstruction,
83+
},
84+
],
85+
},
3786
}),
3887
};
3988
}
@@ -47,6 +96,11 @@ export default class MessageTasksTwist extends Twist<MessageTasksTwist> {
4796
return this.onMessageThread(thread, "slack", channelId);
4897
}
4998

99+
async onGmailThread(thread: NewActivityWithNotes): Promise<void> {
100+
const channelId = thread.meta?.syncableId as string;
101+
return this.onMessageThread(thread, "gmail", channelId);
102+
}
103+
50104
async onSyncableDisabled(filter: ActivityFilter): Promise<void> {
51105
await this.tools.plot.updateActivity({ match: filter, archived: true });
52106
}
@@ -80,6 +134,172 @@ export default class MessageTasksTwist extends Twist<MessageTasksTwist> {
80134
}
81135
}
82136

137+
// ============================================================================
138+
// Instruction Storage
139+
// ============================================================================
140+
141+
private async getInstructions(): Promise<Instruction[]> {
142+
return (await this.get<Instruction[]>("instructions")) ?? [];
143+
}
144+
145+
private async setInstructions(instructions: Instruction[]): Promise<void> {
146+
await this.set("instructions", instructions);
147+
}
148+
149+
// ============================================================================
150+
// Intent Handlers
151+
// ============================================================================
152+
153+
async onInstruct(note: Note): Promise<void> {
154+
const content = note.content?.trim();
155+
if (!content) return;
156+
157+
const instructions = await this.getInstructions();
158+
if (instructions.length >= 20) {
159+
await this.tools.plot.createNote({
160+
activity: { id: note.activity.id },
161+
content:
162+
"You've reached the limit of 20 instructions. Remove one first with \"forget instruction\" before adding more.",
163+
});
164+
return;
165+
}
166+
167+
const response = await this.tools.ai.prompt({
168+
model: { speed: "fast", cost: "low" },
169+
system: `Summarize the user's instruction as a concise directive starting with a verb (e.g. "Ignore", "Always", "Never", "Only"). Keep it to one short sentence. If the input is unclear or not an instruction, respond with exactly "UNCLEAR".`,
170+
prompt: content,
171+
});
172+
173+
const summary = response.text.trim();
174+
175+
if (summary === "UNCLEAR") {
176+
await this.tools.plot.createNote({
177+
activity: { id: note.activity.id },
178+
content: `I didn't understand that as an instruction. Try something like:\n- "Ignore threads from #random"\n- "Always create tasks for messages from Sarah"\n- "Never create tasks for bot messages"`,
179+
});
180+
return;
181+
}
182+
183+
const instruction: Instruction = {
184+
id: crypto.randomUUID().slice(0, 8),
185+
text: content,
186+
summary,
187+
authorId: note.author?.id as string,
188+
created: new Date().toISOString(),
189+
};
190+
191+
instructions.push(instruction);
192+
await this.setInstructions(instructions);
193+
194+
await this.tools.plot.createNote({
195+
activity: { id: note.activity.id },
196+
content: `Saved: "${summary}"`,
197+
});
198+
}
199+
200+
async onListInstructions(note: Note): Promise<void> {
201+
const instructions = await this.getInstructions();
202+
203+
if (instructions.length === 0) {
204+
await this.tools.plot.createNote({
205+
activity: { id: note.activity.id },
206+
content: `No instructions yet. Mention me with an instruction like "Ignore threads from #random" to add one.`,
207+
});
208+
return;
209+
}
210+
211+
const list = instructions
212+
.map((inst, i) => `${i + 1}. ${inst.summary} \`${inst.id}\``)
213+
.join("\n");
214+
215+
await this.tools.plot.createNote({
216+
activity: { id: note.activity.id },
217+
content: `**Instructions:**\n${list}`,
218+
});
219+
}
220+
221+
async onForgetInstruction(note: Note): Promise<void> {
222+
const content = note.content?.trim();
223+
if (!content) return;
224+
225+
const instructions = await this.getInstructions();
226+
227+
if (instructions.length === 0) {
228+
await this.tools.plot.createNote({
229+
activity: { id: note.activity.id },
230+
content: "No instructions to remove.",
231+
});
232+
return;
233+
}
234+
235+
let target: Instruction | undefined;
236+
237+
// Strategy 1: Match a number (e.g. "forget instruction 3")
238+
const numMatch = content.match(/\d+/);
239+
if (numMatch) {
240+
const idx = parseInt(numMatch[0], 10) - 1;
241+
if (idx >= 0 && idx < instructions.length) {
242+
target = instructions[idx];
243+
}
244+
}
245+
246+
// Strategy 2: Match a short ID substring
247+
if (!target) {
248+
target = instructions.find((inst) =>
249+
content.toLowerCase().includes(inst.id.toLowerCase())
250+
);
251+
}
252+
253+
// Strategy 3: AI fuzzy match
254+
if (!target) {
255+
const summaries = instructions
256+
.map((inst, i) => `${i + 1}. ${inst.summary}`)
257+
.join("\n");
258+
259+
const schema = Type.Object({
260+
matchIndex: Type.Number({
261+
description:
262+
"1-based index of the best matching instruction, or 0 if none match",
263+
}),
264+
});
265+
266+
try {
267+
const response = await this.tools.ai.prompt({
268+
model: { speed: "fast", cost: "low" },
269+
system: `The user wants to remove one of these instructions:\n${summaries}\n\nReturn the 1-based index of the instruction that best matches the user's request. Return 0 if none match.`,
270+
prompt: content,
271+
outputSchema: schema,
272+
});
273+
274+
const idx = (response.output?.matchIndex ?? 0) - 1;
275+
if (idx >= 0 && idx < instructions.length) {
276+
target = instructions[idx];
277+
}
278+
} catch {
279+
// Fall through to "no match" handling
280+
}
281+
}
282+
283+
if (!target) {
284+
const list = instructions
285+
.map((inst, i) => `${i + 1}. ${inst.summary} \`${inst.id}\``)
286+
.join("\n");
287+
288+
await this.tools.plot.createNote({
289+
activity: { id: note.activity.id },
290+
content: `Couldn't find a matching instruction. Here are the current ones:\n${list}`,
291+
});
292+
return;
293+
}
294+
295+
await this.setInstructions(instructions.filter((i) => i.id !== target.id));
296+
297+
await this.tools.plot.createNote({
298+
activity: { id: note.activity.id },
299+
content: `Removed: "${target.summary}"`,
300+
});
301+
}
302+
83303
// ============================================================================
84304
// Message Thread Processing
85305
// ============================================================================
@@ -125,6 +345,13 @@ export default class MessageTasksTwist extends Twist<MessageTasksTwist> {
125345
confidence: number;
126346
isCompleted: boolean;
127347
}> {
348+
// Load user instructions
349+
const instructions = await this.getInstructions();
350+
const instructionBlock =
351+
instructions.length > 0
352+
? `\n\nUser instructions (follow these as rules):\n${instructions.map((i) => `- ${i.summary}`).join("\n")}`
353+
: "";
354+
128355
// Build conversation for AI
129356
const messages: AIMessage[] = [
130357
{
@@ -147,14 +374,18 @@ DO NOT create tasks for:
147374
- Already completed or resolved discussions
148375
- Automatic notifications or bot messages
149376
150-
If a task is needed, create a clear, actionable title that describes what the user needs to do.`,
377+
If a task is needed, create a clear, actionable title that describes what the user needs to do.${instructionBlock}`,
151378
},
152-
...thread.notes.map((note, idx) => ({
153-
role: "user" as const,
154-
content: `[Message ${idx + 1}] User: ${
155-
note.content || "(empty message)"
156-
}`,
157-
})),
379+
...thread.notes.map((note, idx) => {
380+
const author: NewContact | null =
381+
note.author && "email" in note.author ? note.author : null;
382+
return {
383+
role: "user" as const,
384+
content: `[Message ${idx + 1}] From ${
385+
author?.name || author?.email || "someone"
386+
}: ${note.content || "(empty message)"}`,
387+
};
388+
}),
158389
];
159390

160391
const schema = Type.Object({
@@ -217,6 +448,27 @@ If a task is needed, create a clear, actionable title that describes what the us
217448
}
218449
}
219450

451+
private formatSourceReference(
452+
thread: NewActivityWithNotes,
453+
provider: MessageProvider,
454+
channelId: string
455+
): string {
456+
if (provider === "gmail") {
457+
const firstNote = thread.notes?.[0];
458+
const author: NewContact | null =
459+
firstNote?.author && "email" in firstNote.author
460+
? firstNote.author
461+
: null;
462+
const senderName = author?.name || author?.email;
463+
const subject = thread.title;
464+
if (senderName && subject) return `From ${senderName}: ${subject}`;
465+
if (senderName) return `From ${senderName}`;
466+
if (subject) return `Re: ${subject}`;
467+
return `From Gmail`;
468+
}
469+
return `From #${channelId}`;
470+
}
471+
220472
private async createTaskFromThread(
221473
thread: NewActivityWithNotes,
222474
analysis: {
@@ -225,7 +477,7 @@ If a task is needed, create a clear, actionable title that describes what the us
225477
taskNote: string | null;
226478
confidence: number;
227479
},
228-
_provider: MessageProvider,
480+
provider: MessageProvider,
229481
channelId: string
230482
): Promise<void> {
231483
const threadId = "source" in thread ? thread.source : undefined;
@@ -234,6 +486,8 @@ If a task is needed, create a clear, actionable title that describes what the us
234486
return;
235487
}
236488

489+
const sourceRef = this.formatSourceReference(thread, provider, channelId);
490+
237491
// Create task activity - database handles upsert automatically
238492
const taskId = await this.tools.plot.createActivity({
239493
source: `message-tasks:${threadId}`,
@@ -243,17 +497,17 @@ If a task is needed, create a clear, actionable title that describes what the us
243497
notes: analysis.taskNote
244498
? [
245499
{
246-
content: `${analysis.taskNote}\n\n---\nFrom #${channelId}`,
500+
content: `${analysis.taskNote}\n\n---\n${sourceRef}`,
247501
},
248502
]
249503
: [
250504
{
251-
content: `From #${channelId}`,
505+
content: sourceRef,
252506
},
253507
],
254508
preview: analysis.taskNote
255-
? `${analysis.taskNote}\n\n---\nFrom #${channelId}`
256-
: `From #${channelId}`,
509+
? `${analysis.taskNote}\n\n---\n${sourceRef}`
510+
: sourceRef,
257511
meta: {
258512
originalThreadId: threadId,
259513
channelId,

0 commit comments

Comments
 (0)