11import { Type } from "typebox" ;
22
3+ import { Gmail } from "@plotday/tool-gmail" ;
34import { Slack } from "@plotday/tool-slack" ;
45import {
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";
1316import { ActivityAccess , Plot } from "@plotday/twister/tools/plot" ;
1417import { 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
1829type 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