diff --git a/.changeset/cyan-snakes-follow.md b/.changeset/cyan-snakes-follow.md new file mode 100644 index 0000000..67048db --- /dev/null +++ b/.changeset/cyan-snakes-follow.md @@ -0,0 +1,9 @@ +--- +"@plotday/tool-outlook-calendar": minor +"@plotday/tool-google-calendar": minor +"@plotday/tool-gmail": minor +"@plotday/tool-slack": minor +"@plotday/twister": minor +--- + +Changed: BREAKING: Refactored Activity and Note types for clarity and type safety. diff --git a/tools/gmail/src/gmail-api.ts b/tools/gmail/src/gmail-api.ts index 7d3cf04..15c2425 100644 --- a/tools/gmail/src/gmail-api.ts +++ b/tools/gmail/src/gmail-api.ts @@ -1,5 +1,11 @@ -import type { NewActivity } from "@plotday/twister"; -import { ActivityLinkType, ActivityType } from "@plotday/twister"; +import { ActivityType } from "@plotday/twister"; +import type { + ActivityWithNotes, + Actor, + ActorId, + ActorType, + Note, +} from "@plotday/twister"; export type GmailLabel = { id: string; @@ -234,6 +240,47 @@ export function parseEmailAddress(headerValue: string): EmailAddress { }; } +/** + * Converts an EmailAddress to an Actor. + */ +function emailAddressToActor(emailAddress: EmailAddress): Actor { + return { + id: `contact:${emailAddress.email}` as ActorId, + type: 2 as ActorType, // ActorType.Contact + email: emailAddress.email, + name: emailAddress.name, + }; +} + +/** + * Parses multiple email addresses from a header value (comma-separated). + */ +function parseEmailAddresses(headerValue: string | null): Actor[] { + if (!headerValue) return []; + + return headerValue + .split(",") + .map((addr) => addr.trim()) + .filter((addr) => addr.length > 0) + .map((addr) => emailAddressToActor(parseEmailAddress(addr))); +} + +/** + * Parses email addresses and returns just the ActorIds for mentions. + */ +function parseEmailAddressIds(headerValue: string | null): ActorId[] { + if (!headerValue) return []; + + return headerValue + .split(",") + .map((addr) => addr.trim()) + .filter((addr) => addr.length > 0) + .map((addr) => { + const parsed = parseEmailAddress(addr); + return `contact:${parsed.email}` as ActorId; + }); +} + /** * Gets a specific header value from a message */ @@ -320,114 +367,104 @@ function extractAttachments( } /** - * Transforms a Gmail thread into an array of Activities - * The first message is the parent, subsequent messages are replies + * Transforms a Gmail thread into an ActivityWithNotes structure. + * The subject becomes the Activity title, and each email becomes a Note. */ -export function transformGmailThread(thread: GmailThread): NewActivity[] { - if (!thread.messages || thread.messages.length === 0) return []; +export function transformGmailThread(thread: GmailThread): ActivityWithNotes { + if (!thread.messages || thread.messages.length === 0) { + // Return empty structure for invalid threads + return { + id: `gmail:${thread.id}` as any, + type: ActivityType.Note, + author: { id: "system" as ActorId, type: 1 as ActorType, name: null }, + title: null, + assignee: null, + doneAt: null, + start: null, + end: null, + recurrenceUntil: null, + recurrenceCount: null, + priority: null as any, + recurrenceRule: null, + recurrenceExdates: null, + recurrenceDates: null, + recurrence: null, + occurrence: null, + meta: null, + mentions: null, + tags: null, + draft: false, + private: false, + notes: [], + }; + } - const activities: NewActivity[] = []; const parentMessage = thread.messages[0]; - - // Extract key headers - const from = getHeader(parentMessage, "From"); const subject = getHeader(parentMessage, "Subject"); - const to = getHeader(parentMessage, "To"); - const cc = getHeader(parentMessage, "Cc"); - // Parse sender - const sender = from ? parseEmailAddress(from) : null; - - // Extract body - const body = extractBody(parentMessage.payload); - - // Create parent activity - const parentActivity: NewActivity = { - type: ActivityType.Action, - title: subject || parentMessage.snippet || "Email", - note: body || parentMessage.snippet, - noteType: "text", + // Create Activity + const activity: ActivityWithNotes = { + id: `gmail:${thread.id}` as any, + type: ActivityType.Note, + author: { id: "system" as ActorId, type: 1 as ActorType, name: null }, + title: subject || "Email", + assignee: null, + doneAt: null, start: new Date(parseInt(parentMessage.internalDate)), + end: null, + recurrenceUntil: null, + recurrenceCount: null, + priority: null as any, + recurrenceRule: null, + recurrenceExdates: null, + recurrenceDates: null, + recurrence: null, + occurrence: null, meta: { - source: `gmail:${thread.id}:${parentMessage.id}`, + source: `gmail:${thread.id}`, threadId: thread.id, - messageId: parentMessage.id, - from: sender, - to, - cc, - labels: parentMessage.labelIds, + historyId: thread.historyId, }, + mentions: null, + tags: null, + draft: false, + private: false, + notes: [], }; - // Initialize links array - parentActivity.links = []; - - // Add Gmail URL as action link - parentActivity.links.push({ - type: ActivityLinkType.external, - title: "Open in Gmail", - url: `https://mail.google.com/mail/u/0/#inbox/${thread.id}`, - }); - - // Add attachments as links - const attachments = extractAttachments(parentMessage); - attachments.forEach((att) => { - parentActivity.links!.push({ - type: ActivityLinkType.external, - title: `Attachment: ${att.filename}`, - url: att.url, - }); - }); - - activities.push(parentActivity); - - // Create activities for replies (messages after the first) - for (let i = 1; i < thread.messages.length; i++) { - const message = thread.messages[i]; - const replyFrom = getHeader(message, "From"); - const replySender = replyFrom ? parseEmailAddress(replyFrom) : null; - const replyBody = extractBody(message.payload); - - const replyActivity: NewActivity = { - type: ActivityType.Action, - title: `Re: ${subject || "Email"}`, - note: replyBody || message.snippet, - noteType: "text", - start: new Date(parseInt(message.internalDate)), - parent: { id: `gmail:${thread.id}:${parentMessage.id}` }, - meta: { - source: `gmail:${thread.id}:${message.id}`, - threadId: thread.id, - messageId: message.id, - from: replySender, - labels: message.labelIds, - }, + // Create Notes for all messages (including first) + for (const message of thread.messages) { + const from = getHeader(message, "From"); + const to = getHeader(message, "To"); + const cc = getHeader(message, "Cc"); + + const sender = from ? parseEmailAddress(from) : null; + if (!sender) continue; // Skip messages without sender + + const body = extractBody(message.payload); + + // Combine to and cc for mentions + const mentions: ActorId[] = [ + ...parseEmailAddressIds(to), + ...parseEmailAddressIds(cc), + ]; + + const note: Note = { + id: `gmail:${thread.id}:${message.id}` as any, + activity: activity, + author: emailAddressToActor(sender), + note: body || message.snippet, + links: null, + mentions: mentions.length > 0 ? mentions : null, + tags: null, + draft: false, + private: false, }; - // Initialize links array - replyActivity.links = []; - - // Add Gmail URL as action link - replyActivity.links.push({ - type: ActivityLinkType.external, - title: "Open in Gmail", - url: `https://mail.google.com/mail/u/0/#inbox/${thread.id}`, - }); - - // Add attachments as links - const replyAttachments = extractAttachments(message); - replyAttachments.forEach((att) => { - replyActivity.links!.push({ - type: ActivityLinkType.external, - title: `Attachment: ${att.filename}`, - url: att.url, - }); - }); - - activities.push(replyActivity); + activity.notes.push(note); } - return activities; + return activity; } /** diff --git a/tools/gmail/src/gmail.ts b/tools/gmail/src/gmail.ts index e1caccf..c01af63 100644 --- a/tools/gmail/src/gmail.ts +++ b/tools/gmail/src/gmail.ts @@ -1,6 +1,6 @@ import { - type Activity, type ActivityLink, + type ActivityWithNotes, Tool, type ToolBuilder, } from "@plotday/twister"; @@ -18,7 +18,11 @@ import { Integrations, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ActivityAccess, ContactAccess, Plot } from "@plotday/twister/tools/plot"; +import { + ActivityAccess, + ContactAccess, + Plot, +} from "@plotday/twister/tools/plot"; import { GmailApi, @@ -74,10 +78,15 @@ import { * } * } * - * async onGmailThread(thread: Activity[]) { - * // Process Gmail email threads - * for (const message of thread) { - * await this.plot.createActivity(message); + * async onGmailThread(thread: ActivityWithNotes) { + * // Process Gmail email thread + * // Each thread is an Activity with Notes for each email + * console.log(`Email thread: ${thread.title}`); + * console.log(`${thread.notes.length} messages`); + * + * // Access individual messages as Notes + * for (const note of thread.notes) { + * console.log(`From: ${note.author.email}, To: ${note.mentions?.join(", ")}`); * } * } * } @@ -190,7 +199,7 @@ export class Gmail extends Tool implements MessagingTool { } async startSync< - TCallback extends (thread: Activity[], ...args: any[]) => any + TCallback extends (thread: ActivityWithNotes, ...args: any[]) => any >( authToken: string, channelId: string, @@ -371,51 +380,59 @@ export class Gmail extends Tool implements MessagingTool { private async processEmailThreads( threads: GmailThread[], channelId: string, - authToken: string + _authToken: string ): Promise { + const callbackToken = await this.get( + `thread_callback_token_${channelId}` + ); + + if (!callbackToken) { + console.error("No callback token found for channel", channelId); + return; + } + for (const thread of threads) { try { - // Transform Gmail thread to Activity array - const activities = transformGmailThread(thread); + // Transform Gmail thread to ActivityWithNotes + const activityThread = transformGmailThread(thread); - if (activities.length === 0) continue; + if (activityThread.notes.length === 0) continue; - // Extract email addresses from all messages and create contacts - const emailAddresses = new Set(); + // Extract unique actors from notes for contact creation + const actorMap = new Map(); - for (const activity of activities) { - const meta = activity.meta as any; - if (meta?.from?.email) { - emailAddresses.add(meta.from.email); + for (const note of activityThread.notes) { + // Add author + if (note.author.email) { + actorMap.set(note.author.email, { + email: note.author.email, + name: note.author.name || undefined, + }); } - } - // Create contacts for all unique email addresses - if (emailAddresses.size > 0) { - const contacts = Array.from(emailAddresses).map((email) => { - // Try to find the name from the activity meta - const activity = activities.find( - (act: any) => act.meta?.from?.email === email - ); - const name = (activity?.meta as any)?.from?.name || null; - - return { - email, - name: name || undefined, - }; - }); - - await this.tools.plot.addContacts(contacts); + // Add mentioned actors + if (note.mentions) { + for (const mentionId of note.mentions) { + // Extract email from ActorId (format: "contact:email@example.com") + const email = mentionId.replace("contact:", ""); + if (email !== mentionId) { + // Only add if it's actually a contact: ID + actorMap.set(email, { + email, + name: undefined, + }); + } + } + } } - // Call parent callback with the thread - const callbackToken = await this.get( - `thread_callback_token_${channelId}` - ); - if (callbackToken) { - // Pass activities as-is - the callback will handle conversion if needed - await this.run(callbackToken as any, activities); + // Create contacts for all unique actors + if (actorMap.size > 0) { + await this.tools.plot.addContacts(Array.from(actorMap.values())); } + + // Call parent callback with single thread + await this.run(callbackToken as any, activityThread); } catch (error) { console.error(`Failed to process Gmail thread ${thread.id}:`, error); // Continue processing other threads diff --git a/tools/google-calendar/src/google-api.ts b/tools/google-calendar/src/google-api.ts index b5c6bd4..0d85790 100644 --- a/tools/google-calendar/src/google-api.ts +++ b/tools/google-calendar/src/google-api.ts @@ -312,19 +312,28 @@ export function transformGoogleEvent( ? new Date(event.end?.dateTime) : null; // Timed events use Date objects + // Handle cancelled events differently + const isCancelled = event.status === "cancelled"; + const activity: NewActivity = { - type: isAllDay ? ActivityType.Note : ActivityType.Event, - title: event.summary || null, - note: event.description || null, - noteType: "html", - start, - end, + type: isCancelled ? ActivityType.Note : (isAllDay ? ActivityType.Note : ActivityType.Event), + title: isCancelled + ? `Cancelled: ${event.summary || "Event"}` + : event.summary || null, + start: isCancelled ? null : start, + end: isCancelled ? null : end, meta: { source: `google-calendar:${event.id}`, id: event.id, calendarId: calendarId, htmlLink: event.htmlLink, hangoutLink: event.hangoutLink, + status: event.status, + originalStart: isCancelled ? start : undefined, + originalEnd: isCancelled ? end : undefined, + description: isCancelled + ? `This event was cancelled.\n\n${event.description || ""}` + : event.description || null, }, }; diff --git a/tools/google-calendar/src/google-calendar.ts b/tools/google-calendar/src/google-calendar.ts index a84f9af..10e1dba 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/tools/google-calendar/src/google-calendar.ts @@ -1,11 +1,13 @@ import { type Activity, + type ActivityCommon, type ActivityLink, ActivityLinkType, type ActorId, ConferencingProvider, - type NewActivity, + type NewActivityWithNotes, type NewContact, + type NewNote, Tag, Tool, type ToolBuilder, @@ -30,10 +32,10 @@ import { } from "@plotday/twister/tools/plot"; import { - extractConferencingLinks, GoogleApi, type GoogleEvent, type SyncState, + extractConferencingLinks, syncGoogleCalendar, transformGoogleEvent, } from "./google-api"; @@ -98,7 +100,7 @@ import { * } * } * - * async onCalendarEvent(activity: Activity, context: any) { + * async onCalendarEvent(activity: NewActivityWithNotes, context: any) { * // Process Google Calendar events * await this.plot.createActivity(activity); * } @@ -227,7 +229,7 @@ export class GoogleCalendar } async startSync< - TCallback extends (activity: Activity, ...args: any[]) => any + TCallback extends (activity: NewActivityWithNotes, ...args: any[]) => any >( authToken: string, calendarId: string, @@ -450,7 +452,7 @@ export class GoogleCalendar if (userAttendee) { // Find the user's ActorId from the contacts we just created - const userActorId = actorIds.find((actorId, index) => { + const userActorId = actorIds.find((_actorId, index) => { const attendee = event.attendees![index]; return ( attendee.self === true || @@ -513,17 +515,29 @@ export class GoogleCalendar }); } - const activity: NewActivity = { + // Create note with description and/or links + const notes: NewNote[] = []; + const description = activityData.meta?.description || event.description; + const hasDescription = description && description.trim().length > 0; + const hasLinks = links.length > 0; + + if (hasDescription || hasLinks) { + notes.push({ + activity: { id: "" }, // Will be filled in by the API + note: hasDescription ? description : null, + links: hasLinks ? links : null, + noteType: "text", + }); + } + + const activity: NewActivityWithNotes = { type: activityData.type, start: activityData.start || null, end: activityData.end || null, recurrenceUntil: activityData.recurrenceUntil || null, recurrenceCount: activityData.recurrenceCount || null, doneAt: null, - note: activityData.note || null, title: activityData.title || null, - parent: null, - links: links.length > 0 ? links : null, recurrenceRule: activityData.recurrenceRule || null, recurrenceExdates: activityData.recurrenceExdates || null, recurrenceDates: activityData.recurrenceDates || null, @@ -531,7 +545,7 @@ export class GoogleCalendar occurrence: null, meta: activityData.meta ?? null, tags, - mentions: actorIds.length > 0 ? actorIds : null, + notes, }; await this.run(callbackToken as any, activity); @@ -561,23 +575,72 @@ export class GoogleCalendar const callbackToken = await this.get("event_callback_token"); if (callbackToken && activityData.type) { - const activity: NewActivity = { + // Build links array for videoconferencing and calendar links + const links: ActivityLink[] = []; + const seenUrls = new Set(); + + // Extract all conferencing links (Zoom, Teams, Webex, etc.) + const conferencingLinks = extractConferencingLinks(event); + for (const link of conferencingLinks) { + if (!seenUrls.has(link.url)) { + seenUrls.add(link.url); + links.push({ + type: ActivityLinkType.conferencing, + url: link.url, + provider: link.provider, + }); + } + } + + // Add Google Meet link from hangoutLink if not already added + if (event.hangoutLink && !seenUrls.has(event.hangoutLink)) { + seenUrls.add(event.hangoutLink); + links.push({ + type: ActivityLinkType.conferencing, + url: event.hangoutLink, + provider: ConferencingProvider.googleMeet, + }); + } + + // Add calendar link + if (event.htmlLink) { + links.push({ + type: ActivityLinkType.external, + title: "View in Calendar", + url: event.htmlLink, + }); + } + + // Create note with description and/or links + const notes: NewNote[] = []; + const description = activityData.meta?.description || event.description; + const hasDescription = description && description.trim().length > 0; + const hasLinks = links.length > 0; + + if (hasDescription || hasLinks) { + notes.push({ + activity: { id: "" }, // Will be filled in by the API + note: hasDescription ? description : null, + links: hasLinks ? links : null, + noteType: "text", + }); + } + + const activity: NewActivityWithNotes = { type: activityData.type, start: activityData.start || null, end: activityData.end || null, recurrenceUntil: activityData.recurrenceUntil || null, recurrenceCount: activityData.recurrenceCount || null, doneAt: null, - note: activityData.note || null, title: activityData.title || null, - parent: null, - links: null, recurrenceRule: null, recurrenceExdates: null, recurrenceDates: null, recurrence: null, // Would need to find master activity occurrence: new Date(originalStartTime), meta: activityData.meta ?? null, + notes, }; await this.run(callbackToken as any, activity); @@ -668,18 +731,20 @@ export class GoogleCalendar } async onActivityUpdated( - activity: Activity, + activity: ActivityCommon, changes?: { - previous: Activity; + previous: ActivityCommon; tagsAdded: Record; tagsRemoved: Record; } ): Promise { if (!changes) return; + // Cast to Activity to access Activity-specific fields + const activityFull = activity as Activity; // Only process calendar events if ( - !activity.meta?.source || - !activity.meta.source.startsWith("google-calendar:") + !activityFull.meta?.source || + !activityFull.meta.source.startsWith("google-calendar:") ) { return; } @@ -690,7 +755,8 @@ export class GoogleCalendar const skipChanged = Tag.Skip in changes.tagsAdded || Tag.Skip in changes.tagsRemoved; const undecidedChanged = - Tag.Undecided in changes.tagsAdded || Tag.Undecided in changes.tagsRemoved; + Tag.Undecided in changes.tagsAdded || + Tag.Undecided in changes.tagsRemoved; if (!attendChanged && !skipChanged && !undecidedChanged) { return; // No RSVP-related tag changes @@ -699,7 +765,8 @@ export class GoogleCalendar // Determine new RSVP status based on current tags const hasAttend = activity.tags?.[Tag.Attend] && activity.tags[Tag.Attend].length > 0; - const hasSkip = activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; + const hasSkip = + activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; const hasUndecided = activity.tags?.[Tag.Undecided] && activity.tags[Tag.Undecided].length > 0; @@ -739,8 +806,8 @@ export class GoogleCalendar } // Extract calendar info from metadata - const eventId = activity.meta.id; - const calendarId = activity.meta.calendarId; + const eventId = activityFull.meta.id; + const calendarId = activityFull.meta.calendarId; if (!eventId || !calendarId) { console.warn("Missing event or calendar ID in activity metadata"); diff --git a/tools/outlook-calendar/src/graph-api.ts b/tools/outlook-calendar/src/graph-api.ts index 940f7d5..1237d5b 100644 --- a/tools/outlook-calendar/src/graph-api.ts +++ b/tools/outlook-calendar/src/graph-api.ts @@ -24,6 +24,7 @@ export type OutlookEvent = { timeZone: string; }; isAllDay?: boolean; + isCancelled?: boolean; type?: "singleInstance" | "occurrence" | "exception" | "seriesMaster"; seriesMasterId?: string; recurrence?: { @@ -71,7 +72,13 @@ export type OutlookEvent = { attendees?: Array<{ type?: "required" | "optional" | "resource"; status?: { - response?: "none" | "organizer" | "tentativelyAccepted" | "accepted" | "declined" | "notResponded"; + response?: + | "none" + | "organizer" + | "tentativelyAccepted" + | "accepted" + | "declined" + | "notResponded"; time?: string; }; emailAddress?: { @@ -198,9 +205,10 @@ export class GraphApi { id: string; expirationDateTime: string; }> { - const resource = calendarId === "primary" - ? "/me/events" - : `/me/calendars/${calendarId}/events`; + const resource = + calendarId === "primary" + ? "/me/events" + : `/me/calendars/${calendarId}/events`; const body = { changeType: "created,updated,deleted", @@ -270,7 +278,9 @@ export function fromMsDate(dateValue?: { if (dateValue.timeZone && dateValue.timeZone !== "UTC") { // For simplicity, we're assuming UTC in the API call (via Prefer header) // If timezone handling is needed, implement proper conversion here - console.warn(`Non-UTC timezone ${dateValue.timeZone} may need special handling`); + console.warn( + `Non-UTC timezone ${dateValue.timeZone} may need special handling` + ); } // Ensure the date string ends with Z for UTC @@ -297,7 +307,9 @@ export function toDateString(dateValue?: { /** * Parse RRULE from Microsoft Graph recurrence pattern */ -export function parseOutlookRRule(recurrence: OutlookEvent["recurrence"]): string | undefined { +export function parseOutlookRRule( + recurrence: OutlookEvent["recurrence"] +): string | undefined { if (!recurrence) return undefined; const pattern = recurrence.pattern; @@ -359,14 +371,20 @@ export function parseOutlookRRule(recurrence: OutlookEvent["recurrence"]): strin } // Add BYMONTH for yearly recurrence - if ((pattern?.type === "absoluteYearly" || pattern?.type === "relativeYearly") && pattern.month) { + if ( + (pattern?.type === "absoluteYearly" || + pattern?.type === "relativeYearly") && + pattern.month + ) { rrule += `;BYMONTH=${pattern.month}`; } // Add UNTIL or COUNT from range if (range?.type === "endDate" && range.endDate) { // Convert date to RRULE format (YYYYMMDD or YYYYMMDDTHHmmssZ) - const endDate = range.endDate.replace(/[-:]/g, "").replace(/\.\d{3}Z?$/, "Z"); + const endDate = range.endDate + .replace(/[-:]/g, "") + .replace(/\.\d{3}Z?$/, "Z"); rrule += `;UNTIL=${endDate}`; } else if (range?.type === "numbered" && range.numberOfOccurrences) { rrule += `;COUNT=${range.numberOfOccurrences}`; @@ -381,7 +399,9 @@ export function parseOutlookRRule(recurrence: OutlookEvent["recurrence"]): strin * Exception dates are represented as separate exception events with type="exception". * This function exists for API compatibility but returns empty array. */ -export function parseOutlookExDates(_recurrence?: OutlookEvent["recurrence"]): Date[] { +export function parseOutlookExDates( + _recurrence?: OutlookEvent["recurrence"] +): Date[] { // Microsoft Graph represents exceptions as separate events, not as EXDATE // Exception events have type="exception" and seriesMasterId pointing to the master event return []; @@ -392,7 +412,9 @@ export function parseOutlookExDates(_recurrence?: OutlookEvent["recurrence"]): D * Note: Microsoft Graph doesn't support RDATE in the recurrence pattern. * This function exists for API compatibility but returns empty array. */ -export function parseOutlookRDates(_recurrence?: OutlookEvent["recurrence"]): Date[] { +export function parseOutlookRDates( + _recurrence?: OutlookEvent["recurrence"] +): Date[] { // Microsoft Graph doesn't support RDATE in recurrence patterns return []; } @@ -400,7 +422,9 @@ export function parseOutlookRDates(_recurrence?: OutlookEvent["recurrence"]): Da /** * Parse recurrence end date/time from Microsoft Graph recurrence */ -export function parseOutlookRecurrenceEnd(recurrence?: OutlookEvent["recurrence"]): Date | string | null { +export function parseOutlookRecurrenceEnd( + recurrence?: OutlookEvent["recurrence"] +): Date | string | null { if (!recurrence?.range) return null; const range = recurrence.range; @@ -422,7 +446,9 @@ export function parseOutlookRecurrenceEnd(recurrence?: OutlookEvent["recurrence" /** * Parse recurrence count from Microsoft Graph recurrence */ -export function parseOutlookRecurrenceCount(recurrence?: OutlookEvent["recurrence"]): number | null { +export function parseOutlookRecurrenceCount( + recurrence?: OutlookEvent["recurrence"] +): number | null { if (!recurrence?.range) return null; const range = recurrence.range; @@ -459,14 +485,23 @@ export function transformOutlookEvent( ? toDateString(event.end) || null : fromMsDate(event.end) || null; + // Handle cancelled events differently + const isCancelled = event.isCancelled === true; + // Create base activity const activity: NewActivity = { - type: isAllDay ? ActivityType.Note : ActivityType.Event, - title: event.subject || null, - note: event.body?.content || null, - noteType: event.body?.contentType === "html" ? "html" : "text", - start, - end, + type: isCancelled + ? ActivityType.Note + : isAllDay + ? ActivityType.Note + : ActivityType.Event, + title: isCancelled + ? event.subject + ? `Cancelled: ${event.subject}` + : "Cancelled Event" + : event.subject || null, + start: isCancelled ? null : start, + end: isCancelled ? null : end, meta: { source: `outlook-calendar:${event.id}`, id: event.id, @@ -474,6 +509,9 @@ export function transformOutlookEvent( webLink: event.webLink, onlineMeetingUrl: event.onlineMeeting?.joinUrl, iCalUId: event.iCalUId, + isCancelled: event.isCancelled, + originalStart: start, + originalEnd: end, }, }; @@ -507,7 +545,11 @@ export function transformOutlookEvent( } // Handle exception events (modifications to recurring event instances) - if (event.type === "exception" && event.seriesMasterId && event.originalStart) { + if ( + event.type === "exception" && + event.seriesMasterId && + event.originalStart + ) { // This is a modified instance of a recurring event const originalStartDate = new Date(event.originalStart); activity.occurrence = originalStartDate; @@ -539,15 +581,17 @@ export async function syncOutlookCalendar( url = state.state; } else if (state.state) { // We have a delta token, append it to the URL - const resource = calendarId === "primary" - ? "/me/events" - : `/me/calendars/${calendarId}/events`; + const resource = + calendarId === "primary" + ? "/me/events" + : `/me/calendars/${calendarId}/events`; url = `https://graph.microsoft.com/v1.0${resource}/delta?$deltatoken=${state.state}`; } else { // Initial sync - use delta query without token - const resource = calendarId === "primary" - ? "/me/events" - : `/me/calendars/${calendarId}/events`; + const resource = + calendarId === "primary" + ? "/me/events" + : `/me/calendars/${calendarId}/events`; const params: string[] = []; diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/tools/outlook-calendar/src/outlook-calendar.ts index edefc3b..654d9c2 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/tools/outlook-calendar/src/outlook-calendar.ts @@ -1,11 +1,13 @@ import { type Activity, + type ActivityCommon, type ActivityLink, ActivityLinkType, type ActorId, ConferencingProvider, - type NewActivity, + type NewActivityWithNotes, type NewContact, + type NewNote, Tag, Tool, type ToolBuilder, @@ -28,11 +30,12 @@ import { ContactAccess, Plot, } from "@plotday/twister/tools/plot"; + import { GraphApi, + type SyncState, syncOutlookCalendar, transformOutlookEvent, - type SyncState, } from "./graph-api"; /** @@ -229,7 +232,7 @@ export class OutlookCalendar } async startSync< - TCallback extends (activity: Activity, ...args: any[]) => any + TCallback extends (activity: NewActivityWithNotes, ...args: any[]) => any >( authToken: string, calendarId: string, @@ -380,8 +383,7 @@ export class OutlookCalendar if (outlookEvent.attendees && outlookEvent.attendees.length > 0) { const contacts: NewContact[] = outlookEvent.attendees .filter( - (att) => - att.emailAddress?.address && att.type !== "resource" + (att) => att.emailAddress?.address && att.type !== "resource" ) .map((att) => ({ email: att.emailAddress!.address!, @@ -440,30 +442,21 @@ export class OutlookCalendar } } - // Add tags to the activity - if (tags && Object.keys(tags).length > 0) { - activity.tags = tags; - } - - // Add mentions to the activity (all invitees) - if (actorIds.length > 0) { - activity.mentions = actorIds; - } - // Build links array for videoconferencing and calendar links const links: ActivityLink[] = []; + // Add conferencing link if available if (outlookEvent.onlineMeeting?.joinUrl) { - const provider = detectConferencingProvider( - outlookEvent.onlineMeeting.joinUrl - ); links.push({ type: ActivityLinkType.conferencing, url: outlookEvent.onlineMeeting.joinUrl, - provider, + provider: detectConferencingProvider( + outlookEvent.onlineMeeting.joinUrl + ), }); } + // Add calendar link if (outlookEvent.webLink) { links.push({ type: ActivityLinkType.external, @@ -472,14 +465,36 @@ export class OutlookCalendar }); } - if (links.length > 0) { - activity.links = links; + // Create note with description and/or links + const notes: NewNote[] = []; + const hasDescription = + outlookEvent.body?.content && + outlookEvent.body.content.trim().length > 0; + const hasLinks = links.length > 0; + + if (hasDescription || hasLinks) { + notes.push({ + activity: { id: "" }, // Will be filled in by the API + note: hasDescription ? outlookEvent.body!.content! : null, + links: hasLinks ? links : null, + noteType: + outlookEvent.body?.contentType === "html" ? "html" : "text", + }); } + // Build NewActivityWithNotes from the transformed activity + const activityWithNotes: NewActivityWithNotes = { + ...activity, + tags: tags && Object.keys(tags).length > 0 ? tags : activity.tags, + notes, + }; + // Call the event callback - const callbackToken = await this.get("event_callback_token"); + const callbackToken = await this.get( + "event_callback_token" + ); if (callbackToken) { - await this.run(callbackToken as any, activity); + await this.run(callbackToken as any, activityWithNotes); } } catch (error) { console.error(`Error processing event ${outlookEvent.id}:`, error); @@ -583,18 +598,20 @@ export class OutlookCalendar } async onActivityUpdated( - activity: Activity, + activity: ActivityCommon, changes?: { - previous: Activity; + previous: ActivityCommon; tagsAdded: Record; tagsRemoved: Record; } ): Promise { if (!changes) return; + // Cast to Activity to access Activity-specific fields + const activityFull = activity as Activity; // Only process calendar events if ( - !activity.meta?.source || - !activity.meta.source.startsWith("outlook-calendar:") + !activityFull.meta?.source || + !activityFull.meta.source.startsWith("outlook-calendar:") ) { return; } @@ -605,7 +622,8 @@ export class OutlookCalendar const skipChanged = Tag.Skip in changes.tagsAdded || Tag.Skip in changes.tagsRemoved; const undecidedChanged = - Tag.Undecided in changes.tagsAdded || Tag.Undecided in changes.tagsRemoved; + Tag.Undecided in changes.tagsAdded || + Tag.Undecided in changes.tagsRemoved; if (!attendChanged && !skipChanged && !undecidedChanged) { return; // No RSVP-related tag changes @@ -614,7 +632,8 @@ export class OutlookCalendar // Determine new RSVP status based on current tags const hasAttend = activity.tags?.[Tag.Attend] && activity.tags[Tag.Attend].length > 0; - const hasSkip = activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; + const hasSkip = + activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; const hasUndecided = activity.tags?.[Tag.Undecided] && activity.tags[Tag.Undecided].length > 0; @@ -654,8 +673,8 @@ export class OutlookCalendar } // Extract calendar info from metadata - const eventId = activity.meta.id; - const calendarId = activity.meta.calendarId; + const eventId = activityFull.meta.id; + const calendarId = activityFull.meta.calendarId; if (!eventId || !calendarId) { console.warn("Missing event or calendar ID in activity metadata"); @@ -712,8 +731,7 @@ export class OutlookCalendar const attendees = event.attendees || []; const userAttendee = attendees.find( (att: any) => - att.emailAddress?.address?.toLowerCase() === - userEmail.toLowerCase() + att.emailAddress?.address?.toLowerCase() === userEmail.toLowerCase() ); if (userAttendee && userAttendee.status?.response === status) { diff --git a/tools/slack/src/slack-api.ts b/tools/slack/src/slack-api.ts index eafbc15..3584687 100644 --- a/tools/slack/src/slack-api.ts +++ b/tools/slack/src/slack-api.ts @@ -1,5 +1,11 @@ -import type { NewActivity, ActorId } from "@plotday/twister"; import { ActivityType } from "@plotday/twister"; +import type { + ActivityWithNotes, + Actor, + ActorId, + ActorType, + Note, +} from "@plotday/twister"; export type SlackChannel = { id: string; @@ -80,7 +86,9 @@ export class SlackApi { }); if (!response.ok) { - throw new Error(`Slack API error: ${response.status} ${response.statusText}`); + throw new Error( + `Slack API error: ${response.status} ${response.statusText}` + ); } const data = await response.json(); @@ -184,97 +192,145 @@ function parseUserMentions(text: string): string[] { return mentions; } +/** + * Parses user mentions and returns ActorIds for the mentions field. + */ +function parseUserMentionIds(text: string): ActorId[] { + const userIds = parseUserMentions(text); + return userIds.map((userId) => `slack:${userId}` as ActorId); +} + +/** + * Converts a Slack user ID to an Actor. + */ +function slackUserToActor(userId: string): Actor { + return { + id: `slack:${userId}` as ActorId, + type: 2 as ActorType, // ActorType.Contact + name: null, + }; +} + /** * Converts Slack markdown to plain text for better readability */ function formatSlackText(text: string): string { - return text - // Convert user mentions - .replace(/<@([A-Z0-9]+)>/g, "@$1") - // Convert channel mentions - .replace(/<#([A-Z0-9]+)\|([^>]+)>/g, "#$2") - // Convert links - .replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "$2 ($1)") - .replace(/<(https?:\/\/[^>]+)>/g, "$1") - // Convert bold - .replace(/\*([^*]+)\*/g, "**$1**") - // Convert italic - .replace(/_([^_]+)_/g, "*$1*") - // Convert strikethrough - .replace(/~([^~]+)~/g, "~~$1~~") - // Convert code - .replace(/`([^`]+)`/g, "`$1`"); + return ( + text + // Convert user mentions + .replace(/<@([A-Z0-9]+)>/g, "@$1") + // Convert channel mentions + .replace(/<#([A-Z0-9]+)\|([^>]+)>/g, "#$2") + // Convert links + .replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "$2 ($1)") + .replace(/<(https?:\/\/[^>]+)>/g, "$1") + // Convert bold + .replace(/\*([^*]+)\*/g, "**$1**") + // Convert italic + .replace(/_([^_]+)_/g, "*$1*") + // Convert strikethrough + .replace(/~([^~]+)~/g, "~~$1~~") + // Convert code + .replace(/`([^`]+)`/g, "`$1`") + ); } /** - * Transforms a Slack message thread into an array of Activities - * The first message is the parent, subsequent messages are replies + * Transforms a Slack message thread into an ActivityWithNotes structure. + * The first message snippet becomes the Activity title, and each message becomes a Note. */ export function transformSlackThread( messages: SlackMessage[], channelId: string -): NewActivity[] { - if (messages.length === 0) return []; - - const activities: NewActivity[] = []; +): ActivityWithNotes { const parentMessage = messages[0]; - const threadTs = parentMessage.thread_ts || parentMessage.ts; - // Create parent activity - const parentActivity: NewActivity = { - type: ActivityType.Action, - title: formatSlackText(parentMessage.text).substring(0, 100) || "Slack message", - note: formatSlackText(parentMessage.text), - noteType: "markdown", + if (!parentMessage) { + // Return empty structure for invalid threads + return { + id: `slack:${channelId}:empty` as any, + type: ActivityType.Note, + author: { id: "system" as ActorId, type: 1 as ActorType, name: null }, + title: "Empty thread", + assignee: null, + doneAt: null, + start: null, + end: null, + recurrenceUntil: null, + recurrenceCount: null, + priority: null as any, + recurrenceRule: null, + recurrenceExdates: null, + recurrenceDates: null, + recurrence: null, + occurrence: null, + meta: null, + mentions: null, + tags: null, + draft: false, + private: false, + notes: [], + }; + } + + const threadTs = parentMessage.thread_ts || parentMessage.ts; + const firstText = formatSlackText(parentMessage.text); + const title = firstText.substring(0, 50) || "Slack message"; + + // Create Activity + const activity: ActivityWithNotes = { + id: `slack:${channelId}:${threadTs}` as any, + type: ActivityType.Note, + author: { id: "system" as ActorId, type: 1 as ActorType, name: null }, + title, + assignee: null, + doneAt: null, start: new Date(parseFloat(parentMessage.ts) * 1000), + end: null, + recurrenceUntil: null, + recurrenceCount: null, + priority: null as any, + recurrenceRule: null, + recurrenceExdates: null, + recurrenceDates: null, + recurrence: null, + occurrence: null, meta: { - source: `slack:${channelId}:${parentMessage.ts}`, - channelId, - messageTs: parentMessage.ts, - threadTs, - userId: parentMessage.user || parentMessage.bot_id, - reactions: parentMessage.reactions, + source: `slack:${channelId}:${threadTs}`, + channelId: channelId, + threadTs: threadTs, }, + mentions: null, + tags: null, + draft: false, + private: false, + notes: [], }; - // Add user mentions - const mentions = parseUserMentions(parentMessage.text); - if (mentions.length > 0) { - parentActivity.mentions = mentions as ActorId[]; - } - - activities.push(parentActivity); - - // Create activities for replies - for (let i = 1; i < messages.length; i++) { - const reply = messages[i]; - const replyActivity: NewActivity = { - type: ActivityType.Action, - title: formatSlackText(reply.text).substring(0, 100) || "Reply", - note: formatSlackText(reply.text), - noteType: "markdown", - start: new Date(parseFloat(reply.ts) * 1000), - parent: { id: `slack:${channelId}:${parentMessage.ts}` }, // Link to parent - meta: { - source: `slack:${channelId}:${reply.ts}`, - channelId, - messageTs: reply.ts, - threadTs, - userId: reply.user || reply.bot_id, - reactions: reply.reactions, - }, + // Create Notes for all messages (including first) + for (const message of messages) { + const userId = message.user || message.bot_id; + if (!userId) continue; // Skip messages without user + + const text = formatSlackText(message.text); + const mentions = parseUserMentionIds(message.text); + + const note: Note = { + id: `slack:${channelId}:${message.ts}` as any, + activity: activity, + author: slackUserToActor(userId), + note: text, + links: null, + mentions: mentions.length > 0 ? mentions : null, + tags: null, + draft: false, + private: false, }; - // Add user mentions for reply - const replyMentions = parseUserMentions(reply.text); - if (replyMentions.length > 0) { - replyActivity.mentions = replyMentions as ActorId[]; - } - - activities.push(replyActivity); + activity.notes.push(note); } - return activities; + return activity; } /** @@ -322,7 +378,11 @@ export async function syncSlackChannel( for (const [threadTs, messagesInThread] of threadMap.entries()) { const parentMessage = messagesInThread.find((m) => m.ts === threadTs); - if (parentMessage && parentMessage.reply_count && parentMessage.reply_count > 0) { + if ( + parentMessage && + parentMessage.reply_count && + parentMessage.reply_count > 0 + ) { // Fetch all replies for this thread const replies = await api.getThreadReplies(state.channelId, threadTs); threads.push([parentMessage, ...replies]); diff --git a/tools/slack/src/slack.ts b/tools/slack/src/slack.ts index 1ff24c0..0a0602e 100644 --- a/tools/slack/src/slack.ts +++ b/tools/slack/src/slack.ts @@ -1,14 +1,13 @@ import { - type Activity, type ActivityLink, - type NewActivity, + type ActivityWithNotes, Tool, type ToolBuilder, } from "@plotday/twister"; import { type MessageChannel, - type MessagingAuth, type MessageSyncOptions, + type MessagingAuth, type MessagingTool, } from "@plotday/twister/common/messaging"; import { type Callback } from "@plotday/twister/tools/callbacks"; @@ -98,11 +97,11 @@ import { * } * } * - * async onSlackThread(thread: Activity[]) { - * // Process Slack message threads - * for (const message of thread) { - * await this.plot.createActivity(message); - * } + * async onSlackThread(thread: ActivityWithNotes) { + * // Process Slack message thread + * // thread contains the Activity with thread.notes containing each message + * console.log(`Thread: ${thread.title}`); + * console.log(`${thread.notes.length} messages`); * } * } * ``` @@ -132,15 +131,15 @@ export class Slack extends Tool implements MessagingTool { // Bot scopes for workspace-level "Add to Slack" installation // These are the scopes the bot token will have const slackScopes = [ - "channels:history", // Read messages in public channels - "channels:read", // View basic channel info - "groups:history", // Read messages in private channels (if bot is added) - "groups:read", // View basic private channel info - "users:read", // View users in workspace - "users:read.email", // View user email addresses - "chat:write", // Send messages as the bot - "im:history", // Read direct messages with the bot - "mpim:history", // Read group direct messages + "channels:history", // Read messages in public channels + "channels:read", // View basic channel info + "groups:history", // Read messages in private channels (if bot is added) + "groups:read", // View basic private channel info + "users:read", // View users in workspace + "users:read.email", // View user email addresses + "chat:write", // Send messages as the bot + "im:history", // Read direct messages with the bot + "mpim:history", // Read group direct messages ]; // Generate opaque token for authorization @@ -188,7 +187,9 @@ export class Slack extends Tool implements MessagingTool { console.log("Got Slack channels", channels); return channels - .filter((channel: SlackChannel) => channel.is_member && !channel.is_archived) + .filter( + (channel: SlackChannel) => channel.is_member && !channel.is_archived + ) .map((channel: SlackChannel) => ({ id: channel.id, name: channel.name, @@ -198,7 +199,7 @@ export class Slack extends Tool implements MessagingTool { } async startSync< - TCallback extends (thread: Activity[], ...args: any[]) => any + TCallback extends (thread: ActivityWithNotes, ...args: any[]) => any >( authToken: string, channelId: string, @@ -314,11 +315,7 @@ export class Slack extends Tool implements MessagingTool { const result = await syncSlackChannel(api, state); if (result.threads.length > 0) { - await this.processMessageThreads( - result.threads, - channelId, - authToken - ); + await this.processMessageThreads(result.threads, channelId, authToken); console.log( `Synced ${result.threads.length} threads in batch ${batchNumber} for channel ${channelId}` ); @@ -359,24 +356,45 @@ export class Slack extends Tool implements MessagingTool { authToken: string ): Promise { const api = await this.getApi(authToken); + const callbackToken = await this.get( + `thread_callback_token_${channelId}` + ); + + if (!callbackToken) { + console.error("No callback token found for channel", channelId); + return; + } for (const thread of threads) { try { - // Transform Slack thread to Activity array - const activities = transformSlackThread(thread, channelId); + // Transform Slack thread to ActivityWithNotes + const activityThread = transformSlackThread(thread, channelId); - if (activities.length === 0) continue; + if (activityThread.notes.length === 0) continue; - // Create contacts for all mentioned users - const allMentions = activities.flatMap((act) => act.mentions || []); - const uniqueUserIds = [ - ...new Set( - allMentions.map((mention: any) => mention.id as string) - ), - ]; + // Extract unique Slack user IDs from notes + const userIdSet = new Set(); + + for (const note of activityThread.notes) { + // Add author if it's a Slack user + if (note.author.id.startsWith("slack:")) { + const userId = note.author.id.replace("slack:", ""); + userIdSet.add(userId); + } + + // Add mentioned users + if (note.mentions) { + for (const mentionId of note.mentions) { + if (mentionId.startsWith("slack:")) { + const userId = mentionId.replace("slack:", ""); + userIdSet.add(userId); + } + } + } + } // Fetch user info and create contacts - for (const userId of uniqueUserIds) { + for (const userId of userIdSet) { const user = await api.getUser(userId); if (user && user.profile?.email) { await this.tools.plot.addContacts([ @@ -391,14 +409,8 @@ export class Slack extends Tool implements MessagingTool { } } - // Call parent callback with the thread - const callbackToken = await this.get( - `thread_callback_token_${channelId}` - ); - if (callbackToken) { - // Pass activities as-is - the callback will handle conversion if needed - await this.run(callbackToken as any, activities); - } + // Call parent callback with single thread + await this.run(callbackToken as any, activityThread); } catch (error) { console.error(`Failed to process thread:`, error); // Continue processing other threads diff --git a/twister/docs/ADVANCED.md b/twister/docs/ADVANCED.md deleted file mode 100644 index afb3108..0000000 --- a/twister/docs/ADVANCED.md +++ /dev/null @@ -1,589 +0,0 @@ ---- -title: Advanced -group: Guides ---- - -# Advanced Topics - -Advanced patterns and techniques for building sophisticated Plot twists. - -## Table of Contents - -- [Complex twist Architectures](#complex-twist-architectures) -- [Error Handling](#error-handling) -- [Debugging and Logging](#debugging-and-logging) -- [Security Best Practices](#security-best-practices) -- [Migration and Versioning](#migration-and-versioning) -- [Performance Patterns](#performance-patterns) - ---- - -## Complex twist Architectures - -### Multi-Service Integration - -Coordinate multiple external services: - -```typescript -import { GitHubTool } from "@mycompany/plot-github-tool"; -import { JiraTool } from "@mycompany/plot-jira-tool"; -import { SlackTool } from "@mycompany/plot-slack-tool"; - -import { twist, type Priority, type ToolBuilder } from "@plotday/twister"; -import { Plot } from "@plotday/twister/tools/plot"; - -export default class DevOpsTwist extends Twist { - build(build: ToolBuilder) { - return { - plot: build(Plot), - github: build(GitHubTool, { - owner: "mycompany", - repo: "myapp", - token: process.env.GITHUB_TOKEN!, - }), - slack: build(SlackTool, { - webhookUrl: process.env.SLACK_WEBHOOK_URL!, - }), - jira: build(JiraTool, { - domain: "mycompany.atlassian.net", - apiToken: process.env.JIRA_TOKEN!, - }), - }; - } - - async activate(priority: Pick) { - // Set up cross-service workflow - await this.setupIssueSync(); - } - - async setupIssueSync() { - // When GitHub issue is created, create Jira ticket and post to Slack - // When Jira ticket is updated, update GitHub issue - // When PR is merged, update both and notify Slack - } -} -``` - -### State Machine Pattern - -Implement complex workflows with state machines: - -```typescript -type WorkflowState = "pending" | "in_progress" | "review" | "complete"; - -interface WorkflowData { - state: WorkflowState; - activityId: string; - metadata: Record; -} - -class WorkflowTwist extends Twist { - async transitionTo(workflowId: string, newState: WorkflowState) { - const workflow = await this.get(`workflow:${workflowId}`); - if (!workflow) throw new Error("Workflow not found"); - - const oldState = workflow.state; - - // Validate transition - if (!this.isValidTransition(oldState, newState)) { - throw new Error(`Invalid transition: ${oldState} -> ${newState}`); - } - - // Execute transition logic - await this.onExit(workflowId, oldState); - - workflow.state = newState; - await this.set(`workflow:${workflowId}`, workflow); - - await this.onEnter(workflowId, newState); - } - - private isValidTransition(from: WorkflowState, to: WorkflowState): boolean { - const transitions: Record = { - pending: ["in_progress"], - in_progress: ["review", "pending"], - review: ["complete", "in_progress"], - complete: [], - }; - - return transitions[from]?.includes(to) ?? false; - } - - private async onEnter(workflowId: string, state: WorkflowState) { - switch (state) { - case "in_progress": - await this.notifyAssigned(workflowId); - break; - case "review": - await this.requestReview(workflowId); - break; - case "complete": - await this.markComplete(workflowId); - break; - } - } - - private async onExit(workflowId: string, state: WorkflowState) { - // Cleanup for previous state - } -} -``` - ---- - -## Error Handling - -### Graceful Degradation - -Handle errors without breaking the twist: - -```typescript -async activate(priority: Pick) { - try { - await this.setupWebhooks(); - } catch (error) { - console.error("Failed to setup webhooks:", error); - // twist still activates, just without webhooks - // Consider creating an activity to notify the user - await this.tools.plot.createActivity({ - type: ActivityType.Note, - title: "⚠️ Webhook setup failed", - note: `Could not set up automatic syncing. Error: ${error.message}` - }); - } - - // Continue with other initialization - await this.initialSync(); -} -``` - -### Retry Logic - -Implement exponential backoff for transient failures: - -```typescript -async fetchWithRetry( - url: string, - maxRetries: number = 3 -): Promise { - let lastError: Error; - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - lastError = error as Error; - console.error(`Attempt ${attempt + 1} failed:`, error); - - if (attempt < maxRetries - 1) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, attempt) * 1000; - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - } - - throw new Error(`Failed after ${maxRetries} attempts: ${lastError!.message}`); -} -``` - -### Error Recovery - -Save state before risky operations: - -```typescript -async processLargeDataset(items: Item[]) { - for (let i = 0; i < items.length; i++) { - try { - await this.processItem(items[i]); - - // Save progress - await this.set("last_processed_index", i); - } catch (error) { - console.error(`Error processing item ${i}:`, error); - - // Create activity for manual review - await this.tools.plot.createActivity({ - type: ActivityType.Note, - title: `Processing error at item ${i}`, - note: error.message - }); - - // Continue with next item - continue; - } - } -} - -// Resume from last checkpoint -async resumeProcessing() { - const lastIndex = await this.get("last_processed_index") || 0; - const items = await this.get("items_to_process"); - - if (items) { - await this.processLargeDataset(items.slice(lastIndex + 1)); - } -} -``` - ---- - -## Debugging and Logging - -### Structured Logging - -Use consistent log formats: - -```typescript -interface LogContext { - twistId: string; - priorityId?: string; - operation: string; - [key: string]: any; -} - -class MyTwist extends Twist { - private log( - level: "info" | "warn" | "error", - message: string, - context?: Partial - ) { - const logEntry = { - timestamp: new Date().toISOString(), - level, - message, - twist: this.id, - ...context, - }; - - console.log(JSON.stringify(logEntry)); - } - - async activate(priority: Pick) { - this.log("info", "twist activating", { - priorityId: priority.id, - operation: "activate", - }); - - try { - await this.setupWebhooks(); - this.log("info", "Webhooks configured successfully"); - } catch (error) { - this.log("error", "Failed to setup webhooks", { - error: error.message, - stack: error.stack, - }); - } - } -} -``` - -### Debug Mode - -Add debug flag for verbose logging: - -```typescript -class MyTwist extends Twist { - private get debugMode(): Promise { - return this.get("debug_mode").then((v) => v ?? false); - } - - private async debug(message: string, data?: any) { - if (await this.debugMode) { - console.log(`[DEBUG] ${message}`, data || ""); - } - } - - async processData(data: any) { - await this.debug("Processing data", { itemCount: data.length }); - - for (const item of data) { - await this.debug("Processing item", item); - await this.processItem(item); - } - } -} -``` - -### Performance Monitoring - -Track operation durations: - -```typescript -async withTiming( - operation: string, - fn: () => Promise -): Promise { - const start = Date.now(); - - try { - const result = await fn(); - const duration = Date.now() - start; - - console.log(`[PERF] ${operation}: ${duration}ms`); - - return result; - } catch (error) { - const duration = Date.now() - start; - console.log(`[PERF] ${operation}: ${duration}ms (failed)`); - throw error; - } -} - -// Usage -await this.withTiming("sync-calendar", async () => { - await this.syncCalendar(); -}); -``` - ---- - -## Security Best Practices - -### Secrets Management - -Never hardcode secrets: - -```typescript -// ❌ WRONG -const apiKey = "sk-1234567890abcdef"; - -// ✅ CORRECT - Use environment variables -const apiKey = process.env.API_KEY; -if (!apiKey) { - throw new Error("API_KEY environment variable is required"); -} -``` - -### Input Validation - -Validate all external input: - -```typescript -async onWebhook(request: WebhookRequest) { - // Validate signature - if (!this.validateSignature(request)) { - console.error("Invalid webhook signature"); - return; - } - - // Validate schema - if (!this.isValidPayload(request.body)) { - console.error("Invalid webhook payload"); - return; - } - - // Process safely - await this.processWebhook(request.body); -} - -private validateSignature(request: WebhookRequest): boolean { - const signature = request.headers["x-webhook-signature"]; - const expectedSignature = this.computeSignature(request.body); - return signature === expectedSignature; -} -``` - -### Rate Limiting - -Protect external APIs: - -```typescript -class RateLimiter { - private lastRequest: number = 0; - private minInterval: number = 1000; // 1 request per second - - async throttle(fn: () => Promise): Promise { - const now = Date.now(); - const timeSinceLastRequest = now - this.lastRequest; - - if (timeSinceLastRequest < this.minInterval) { - const delay = this.minInterval - timeSinceLastRequest; - await new Promise(resolve => setTimeout(resolve, delay)); - } - - this.lastRequest = Date.now(); - return await fn(); - } -} - -// Usage -private rateLimiter = new RateLimiter(); - -async fetchData() { - return await this.rateLimiter.throttle(async () => { - return await fetch("https://api.example.com/data"); - }); -} -``` - ---- - -## Migration and Versioning - -### Version Tracking - -Track twist version for migrations: - -```typescript -async activate(priority: Pick) { - await this.set("twist_version", "1.0.0"); -} - -async upgrade() { - const currentVersion = await this.get("twist_version") || "0.0.0"; - - if (this.compareVersions(currentVersion, "2.0.0") < 0) { - await this.migrateToV2(); - } - - if (this.compareVersions(currentVersion, "2.1.0") < 0) { - await this.migrateToV21(); - } - - await this.set("twist_version", "2.1.0"); -} -``` - -### Data Migration - -Migrate stored data structures: - -```typescript -async migrateToV2() { - // V1 stored user data as separate fields - const userId = await this.get("user_id"); - const userName = await this.get("user_name"); - const userEmail = await this.get("user_email"); - - if (userId && userName && userEmail) { - // V2 uses a single user object - await this.set("user", { - id: userId, - name: userName, - email: userEmail - }); - - // Clean up old fields - await this.clear("user_id"); - await this.clear("user_name"); - await this.clear("user_email"); - } -} -``` - -### Breaking Changes - -Handle breaking changes gracefully: - -```typescript -async upgrade() { - const version = await this.get("version") || "1.0.0"; - - if (version < "2.0.0") { - // V2 completely changed how webhooks work - // Clean up old webhooks - const oldWebhooks = await this.get("webhooks"); - if (oldWebhooks) { - for (const webhook of oldWebhooks) { - await this.deleteOldWebhook(webhook); - } - await this.clear("webhooks"); - } - - // Set up new webhook system - await this.setupNewWebhooks(); - } - - await this.set("version", "2.0.0"); -} -``` - ---- - -## Performance Patterns - -### Lazy Loading - -Load data only when needed: - -```typescript -class MyTwist extends Twist { - private _config: Config | null = null; - - private async getConfig(): Promise { - if (!this._config) { - this._config = await this.get("config"); - } - return this._config!; - } - - async someMethod() { - const config = await this.getConfig(); // Loaded once - // Use config... - } -} -``` - -### Request Coalescing - -Combine multiple similar requests: - -```typescript -private pendingUserFetches = new Map>(); - -async getUser(userId: string): Promise { - // If already fetching, return existing promise - if (this.pendingUserFetches.has(userId)) { - return this.pendingUserFetches.get(userId)!; - } - - // Start new fetch - const promise = this.fetchUser(userId); - this.pendingUserFetches.set(userId, promise); - - try { - const user = await promise; - return user; - } finally { - this.pendingUserFetches.delete(userId); - } -} -``` - -### Bulk Operations - -Batch database operations: - -```typescript -async syncAllItems(items: Item[]) { - // ❌ SLOW - One at a time - // for (const item of items) { - // await this.tools.plot.createActivity({...}); - // } - - // ✅ FAST - Bulk create - await this.tools.plot.createActivities( - items.map(item => ({ - type: ActivityType.Action, - title: item.title, - note: item.description - })) - ); -} -``` - ---- - -## Next Steps - -- **[Runtime Environment](RUNTIME.md)** - Understanding execution constraints -- **[Building Tools](BUILDING_TOOLS.md)** - Creating reusable tools -- **[Built-in Tools](TOOLS_GUIDE.md)** - Comprehensive tool documentation -- **API Reference** - Explore detailed API documentation diff --git a/twister/docs/BUILDING_TOOLS.md b/twister/docs/BUILDING_TOOLS.md index 089f1b6..f7cb5d9 100644 --- a/twister/docs/BUILDING_TOOLS.md +++ b/twister/docs/BUILDING_TOOLS.md @@ -61,7 +61,7 @@ export class HelloTool extends Tool { ### Using Your Tool ```typescript -import { twist, type ToolBuilder } from "@plotday/twister"; +import { type ToolBuilder, twist } from "@plotday/twister"; import { HelloTool } from "./tools/hello"; @@ -417,16 +417,20 @@ export class GitHubTool extends Tool { await this.tools.plot.createActivity({ type: ActivityType.Action, title: issue.title, - note: issue.body, meta: { github_issue_id: issue.id.toString(), github_number: issue.number.toString(), }, - links: [ + notes: [ { - type: ActivityLinkType.external, - title: "View on GitHub", - url: issue.html_url, + note: issue.body, + links: [ + { + type: ActivityLinkType.external, + title: "View on GitHub", + url: issue.html_url, + }, + ], }, ], }); @@ -437,20 +441,45 @@ export class GitHubTool extends Tool { request: WebhookRequest, context: { priorityId: string } ): Promise { - const { action, issue } = request.body; + const { action, issue, comment } = request.body; if (action === "opened") { - // Create new activity for new issue + // Create new activity for new issue with initial Note await this.tools.plot.createActivity({ type: ActivityType.Action, title: issue.title, meta: { github_issue_id: issue.id.toString(), }, + notes: [ + { + note: issue.body || "No description provided", + links: [ + { + type: ActivityLinkType.external, + title: "View on GitHub", + url: issue.html_url, + }, + ], + }, + ], + }); + } else if (action === "created" && comment) { + // Add comment as Note to existing Activity + const activity = await this.tools.plot.getActivityBySource({ + github_issue_id: issue.id.toString(), }); + + if (activity) { + await this.tools.plot.createNote({ + activity: { id: activity.id }, + note: comment.body, + // author could be set if you have user mapping + }); + } } else if (action === "closed") { // Mark activity as done - const activity = await this.tools.plot.getActivityByMeta({ + const activity = await this.tools.plot.getActivityBySource({ github_issue_id: issue.id.toString(), }); @@ -583,7 +612,7 @@ describe("GitHubTool", () => { Test your tool with a real twist: ```typescript -import { twist, type ToolBuilder } from "@plotday/twister"; +import { type ToolBuilder, twist } from "@plotday/twister"; import { Plot } from "@plotday/twister/tools/plot"; import { GitHubTool } from "./github-tool"; @@ -823,5 +852,4 @@ async getIssues(): Promise { ## Next Steps - **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Learn from built-in tool patterns -- **[Advanced Topics](ADVANCED.md)** - Complex tool patterns - **API Reference** - Explore the Tool class API diff --git a/twister/docs/CORE_CONCEPTS.md b/twister/docs/CORE_CONCEPTS.md index 9408ab6..ca1424a 100644 --- a/twister/docs/CORE_CONCEPTS.md +++ b/twister/docs/CORE_CONCEPTS.md @@ -34,7 +34,7 @@ A twist is a class that: ### Twist Anatomy ```typescript -import { Twist, type Priority, type ToolBuilder } from "@plotday/twister"; +import { type Priority, type ToolBuilder, Twist } from "@plotday/twister"; import { Plot } from "@plotday/twister/tools/plot"; export default class MyTwist extends Twist { @@ -240,36 +240,39 @@ type Activity = { id: string; // Unique identifier type: ActivityType; // Note, Task, or Event title: string | null; // Display title - note: string | null; // Additional details + preview: string | null; // Brief preview text start: Date | null; // Event start time end: Date | null; // Event end time doneAt: Date | null; // Task completion time - links: ActivityLink[]; // Action links tags: Record; // Tag assignments // ... and more }; ``` -### Activity Links +### Activity Notes -Links enable user interaction with activities: +Activities can have multiple Notes attached to them. Notes contain detailed content and links: ```typescript -import { ActivityLinkType } from "@plotday/twister"; - await this.tools.plot.createActivity({ type: ActivityType.Action, title: "Fix bug #123", - links: [ - { - type: ActivityLinkType.external, - title: "View Issue", - url: "https://github.com/org/repo/issues/123", - }, + preview: "Critical bug affecting login flow", + notes: [ { - type: ActivityLinkType.callback, - title: "Mark as Fixed", - callback: await this.callback("markAsFixed", "123"), + note: "Users are unable to log in with SSO. Error occurs in auth middleware.", + links: [ + { + type: ActivityLinkType.external, + title: "View Issue", + url: "https://github.com/org/repo/issues/123", + }, + { + type: ActivityLinkType.callback, + title: "Mark as Fixed", + callback: await this.callback("markAsFixed", "123"), + }, + ], }, ], }); @@ -280,6 +283,101 @@ await this.tools.plot.createActivity({ - **external** - Opens URL in browser - **auth** - Initiates OAuth flow - **callback** - Triggers twist method when clicked +- **conferencing** - Video conferencing links (Zoom, Meet, Teams, etc.) + +### Best Practices for Activities and Notes + +#### Always Create Activities with an Initial Note + +**In most cases, an Activity should be created with at least one initial Note.** The Activity's `title` and `preview` are just short summaries that may be truncated in the UI. Detailed information, context, and links should always go in Notes. + +```typescript +// ✅ GOOD - Activity with detailed Note +await this.tools.plot.createActivity({ + type: ActivityType.Action, + title: "Review PR #456", + preview: "New authentication feature", + notes: [ + { + note: "Please review the OAuth 2.0 implementation. Key changes include:\n- Token refresh logic\n- Session management\n- Error handling for expired tokens", + links: [ + { + type: ActivityLinkType.external, + title: "View PR", + url: "https://github.com/org/repo/pull/456", + }, + ], + }, + ], +}); + +// ❌ BAD - Relying only on title and preview +await this.tools.plot.createActivity({ + type: ActivityType.Action, + title: + "Review PR #456 - OAuth implementation with token refresh and session management", + preview: "New authentication feature with detailed changes", + // Missing Notes with full context +}); +``` + +**Why?** The title may be truncated when viewing Activity Notes, and detailed information is essential for understanding the full context. + +#### Add Notes to Existing Activities for Related Content + +**Wherever possible, related messages should be added to an existing Activity rather than creating a new Activity.** This keeps conversations, workflows, and related information together. + +**Use this pattern for:** + +- **Email threads** - All messages in a thread as Notes on one Activity +- **Chat conversations** - All messages in a channel or thread as Notes +- **Workflows** - All steps in an end-to-end process as Notes +- **Document collaboration** - All comments and updates as Notes +- **Issue tracking** - All comments and status updates as Notes + +```typescript +// ✅ GOOD - Add to existing Activity +async onNewMessage(message: Message, threadId: string) { + // Find existing activity for this thread + const activity = await this.tools.plot.getActivityBySource({ + thread_id: threadId, + }); + + if (activity) { + // Add new message as a Note + await this.tools.plot.createNote({ + activity: { id: activity.id }, + note: message.text, + author: message.author, + }); + } else { + // Create new Activity with initial Note + await this.tools.plot.createActivity({ + type: ActivityType.Note, + title: message.subject || "New conversation", + preview: message.text.substring(0, 100), + meta: { thread_id: threadId }, + notes: [ + { + note: message.text, + }, + ], + }); + } +} + +// ❌ BAD - Creating separate Activity for each message +async onNewMessage(message: Message, threadId: string) { + // This creates clutter - each message becomes its own Activity + await this.tools.plot.createActivity({ + type: ActivityType.Note, + title: `Message from ${message.author}`, + preview: message.text, + }); +} +``` + +**Why?** Grouping related content keeps the user's workspace organized and provides better context. A chat conversation with 20 messages should be one Activity with 20 Notes, not 20 separate Activities. --- @@ -490,4 +588,3 @@ await this.tools.plot.createActivity({ - **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Learn about Plot, Store, AI, and more - **[Building Custom Tools](BUILDING_TOOLS.md)** - Create reusable tools - **[Runtime Environment](RUNTIME.md)** - Understand execution constraints -- **[Advanced Topics](ADVANCED.md)** - Complex patterns and techniques diff --git a/twister/docs/GETTING_STARTED.md b/twister/docs/GETTING_STARTED.md index bd278fd..5bb8887 100644 --- a/twister/docs/GETTING_STARTED.md +++ b/twister/docs/GETTING_STARTED.md @@ -110,9 +110,9 @@ Edit `src/index.ts` to add your twist logic: import { type Activity, ActivityType, - Twist, type Priority, type ToolBuilder, + Twist, } from "@plotday/twister"; import { Plot } from "@plotday/twister/tools/plot"; @@ -219,12 +219,16 @@ Now that you have a basic twist running, explore: await this.tools.plot.createActivity({ type: ActivityType.Action, title: "Review pull request", - note: "Check the new authentication flow", - links: [ + notes: [ { - type: ActivityLinkType.external, - title: "View PR", - url: "https://github.com/org/repo/pull/123", + note: "Please review the authentication changes and ensure they follow security best practices", + links: [ + { + type: ActivityLinkType.external, + title: "View PR", + url: "https://github.com/org/repo/pull/123", + }, + ], }, ], }); @@ -253,6 +257,68 @@ await this.runTask(callback, { }); ``` +### Best Practices + +#### Always Include Notes with Activities + +**Important:** Always create Activities with at least one initial Note. The `title` and `preview` are brief summaries that may be truncated in the UI. Detailed information should go in Notes. + +```typescript +// ✅ Good - Activity with detailed Note +await this.tools.plot.createActivity({ + type: ActivityType.Action, + title: "Deploy v2.0", + notes: [ + { + note: "Deployment checklist:\n- Run database migrations\n- Update environment variables\n- Deploy backend services\n- Deploy frontend\n- Run smoke tests", + links: [ + { + type: ActivityLinkType.external, + title: "Deployment Guide", + url: "https://docs.example.com/deploy", + }, + ], + }, + ], +}); + +// ❌ Bad - No detailed information +await this.tools.plot.createActivity({ + type: ActivityType.Action, + title: "Deploy v2.0", + // Missing Notes with context and steps +}); +``` + +#### Add Notes to Existing Activities for Related Content + +For conversations, email threads, or workflows, add Notes to the existing Activity instead of creating new Activities: + +```typescript +// Check if Activity exists +const existing = await this.tools.plot.getActivityBySource({ + conversation_id: conversationId, +}); + +if (existing) { + // Add to existing Activity + await this.tools.plot.createNote({ + activity: { id: existing.id }, + note: newMessage.text, + }); +} else { + // Create new Activity with initial Note + await this.tools.plot.createActivity({ + type: ActivityType.Note, + title: "New conversation", + meta: { conversation_id: conversationId }, + notes: [{ note: newMessage.text }], + }); +} +``` + +See [Core Concepts - Best Practices](CORE_CONCEPTS.md#best-practices-for-activities-and-notes) for more details. + ## Need Help? - **Documentation**: Continue reading the guides diff --git a/twister/docs/RUNTIME.md b/twister/docs/RUNTIME.md index 639bb6f..70e0c1c 100644 --- a/twister/docs/RUNTIME.md +++ b/twister/docs/RUNTIME.md @@ -549,5 +549,4 @@ async longOperation() { ## Next Steps - **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Learn about Store and Tasks tools -- **[Advanced Topics](ADVANCED.md)** - Complex patterns and techniques - **[Core Concepts](CORE_CONCEPTS.md)** - Understanding the twist architecture diff --git a/twister/docs/TOOLS_GUIDE.md b/twister/docs/TOOLS_GUIDE.md index dae57cb..8a934b1 100644 --- a/twister/docs/TOOLS_GUIDE.md +++ b/twister/docs/TOOLS_GUIDE.md @@ -43,29 +43,43 @@ import { ActivityLinkType, ActivityType } from "@plotday/twister"; // Create a note await this.tools.plot.createActivity({ type: ActivityType.Note, - title: "Meeting notes", - note: "Discussed Q1 planning", + title: "Q1 Planning Meeting Notes", + notes: [ + { + note: "Discussed goals for Q1 and assigned action items.", + }, + ], }); -// Create a task +// Create a task with a note containing links await this.tools.plot.createActivity({ type: ActivityType.Action, title: "Review pull request #123", - links: [ + notes: [ { - type: ActivityLinkType.external, - title: "View PR", - url: "https://github.com/org/repo/pull/123", + note: "Please review the changes and provide feedback", + links: [ + { + type: ActivityLinkType.external, + title: "View PR", + url: "https://github.com/org/repo/pull/123", + }, + ], }, ], }); -// Create an event +// Create an event with description in a note await this.tools.plot.createActivity({ type: ActivityType.Event, title: "Team standup", start: new Date("2025-02-01T10:00:00Z"), end: new Date("2025-02-01T10:30:00Z"), + notes: [ + { + note: "Daily standup meeting to sync on progress", + }, + ], }); ``` @@ -77,10 +91,10 @@ await this.tools.plot.updateActivity(activity.id, { doneAt: new Date(), }); -// Update title and note +// Update title and preview await this.tools.plot.updateActivity(activity.id, { title: "Updated title", - note: "Additional information", + preview: "Additional information", }); // Reschedule event @@ -127,11 +141,97 @@ await this.tools.plot.createActivity({ }); // Later, find by meta -const activity = await this.tools.plot.getActivityByMeta({ +const activity = await this.tools.plot.getActivityBySource({ github_pr_id: "123", }); ``` +### Creating and Managing Notes + +#### Creating Notes on New Activities + +**Best Practice:** Always create Activities with at least one initial Note containing detailed information. The `title` and `preview` are short summaries that may be truncated—detailed content should go in Notes. + +```typescript +// ✅ Recommended - Activity with initial Note +await this.tools.plot.createActivity({ + type: ActivityType.Action, + title: "Customer feedback: Login issues", + preview: "User reports problems with SSO authentication", + meta: { source: "support-ticket:12345" }, + notes: [ + { + note: "Customer reported:\n\n\"I'm unable to log in using Google SSO. The page redirects but then shows an error 'Invalid state parameter'.\"\n\nPriority: High\nAffected users: ~15 reports", + links: [ + { + type: ActivityLinkType.external, + title: "View Support Ticket", + url: "https://support.example.com/tickets/12345", + }, + ], + }, + ], +}); +``` + +#### Adding Notes to Existing Activities + +**Best Practice:** For related content (email threads, chat conversations, workflows), add Notes to the existing Activity rather than creating new Activities. + +```typescript +// Add a new Note to an existing Activity +await this.tools.plot.createNote({ + activity: { id: activity.id }, + note: "Update: Engineering team has identified the root cause. Fix will be deployed in the next release.", + links: [ + { + type: ActivityLinkType.external, + title: "View PR Fix", + url: "https://github.com/org/repo/pull/789", + }, + ], +}); +``` + +#### Pattern: Email Threads and Conversations + +Keep all messages in a thread or conversation within a single Activity: + +```typescript +async handleEmailThread(thread: EmailThread) { + // Check if Activity exists for this thread + const existing = await this.tools.plot.getActivityBySource({ + email_thread_id: thread.id, + }); + + if (existing) { + // Add new messages as Notes + for (const message of thread.newMessages) { + await this.tools.plot.createNote({ + activity: { id: existing.id }, + note: message.body, + author: message.sender, + }); + } + } else { + // Create new Activity with initial Note + const firstMessage = thread.messages[0]; + await this.tools.plot.createActivity({ + type: ActivityType.Note, + title: thread.subject, + preview: firstMessage.body.substring(0, 100), + meta: { email_thread_id: thread.id }, + notes: thread.messages.map((msg) => ({ + note: msg.body, + author: msg.sender, + })), + }); + } +} +``` + +**Why this matters:** A conversation with 20 messages should be one Activity with 20 Notes, not 20 separate Activities. This keeps the workspace organized and provides better context. + --- ## Store @@ -267,15 +367,22 @@ async activate(priority: Pick) { authCallback ); - // Create activity with auth link + // Create activity with auth link in a note await this.tools.plot.createActivity({ type: ActivityType.Note, title: "Connect your Google Calendar", - links: [{ - type: ActivityLinkType.auth, - title: "Connect Google", - url: authLink - }] + notes: [ + { + note: "Click below to connect your Google account", + links: [ + { + type: ActivityLinkType.auth, + title: "Connect Google", + url: authLink, + }, + ], + }, + ], }); } @@ -780,7 +887,11 @@ async triageEmail(emailContent: string) { await this.tools.plot.createActivity({ type: ActivityType.Action, title: `URGENT: ${response.output.summary}`, - note: `Actions:\n${response.output.suggestedActions.join("\n")}` + notes: [ + { + note: `Actions:\n${response.output.suggestedActions.join("\n")}`, + }, + ], }); } } @@ -792,5 +903,4 @@ async triageEmail(emailContent: string) { - **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own reusable tools - **[Runtime Environment](RUNTIME.md)** - Understanding execution constraints -- **[Advanced Topics](ADVANCED.md)** - Complex patterns and techniques - **API Reference** - Explore detailed API docs in the sidebar diff --git a/twister/docs/index.md b/twister/docs/index.md index c036e10..6fbb4b6 100644 --- a/twister/docs/index.md +++ b/twister/docs/index.md @@ -55,12 +55,6 @@ Plot Twists are smart automations that connect, organize, and prioritize your wo - Memory and state management - Performance optimization -- **[Advanced Topics](ADVANCED.md)** - Complex patterns and techniques - - Multi-twist coordination - - Error handling - - Debugging and logging - - Security best practices - ## API Reference Explore the complete API documentation using the navigation on the left: @@ -86,9 +80,9 @@ Check out these examples to get started: ```typescript import { ActivityType, - Twist, type Priority, type ToolBuilder, + Twist, } from "@plotday/twister"; import { Plot } from "@plotday/twister/tools/plot"; @@ -111,7 +105,7 @@ export default class WelcomeTwist extends Twist { ### Calendar Sync Twist ```typescript -import { type Activity, Twist, type ToolBuilder } from "@plotday/twister"; +import { type Activity, type ToolBuilder, Twist } from "@plotday/twister"; import { Network } from "@plotday/twister/tools/network"; import { Plot } from "@plotday/twister/tools/plot"; diff --git a/twister/package.json b/twister/package.json index 1e54e3a..2af625f 100644 --- a/twister/package.json +++ b/twister/package.json @@ -66,6 +66,10 @@ "types": "./dist/utils/types.d.ts", "default": "./dist/utils/types.js" }, + "./utils/hash": { + "types": "./dist/utils/hash.d.ts", + "default": "./dist/utils/hash.js" + }, "./common/calendar": { "types": "./dist/common/calendar.d.ts", "default": "./dist/common/calendar.js" diff --git a/twister/src/common/calendar.ts b/twister/src/common/calendar.ts index a13a066..1dc819e 100644 --- a/twister/src/common/calendar.ts +++ b/twister/src/common/calendar.ts @@ -1,4 +1,4 @@ -import type { Activity, ActivityLink } from "../index"; +import type { ActivityLink, NewActivityWithNotes } from "../index"; /** * Represents successful calendar authorization. @@ -89,7 +89,7 @@ export interface SyncOptions { * } * } * - * async onCalendarEvent(activity: Activity) { + * async onCalendarEvent(activity: NewActivityWithNotes) { * // Step 4: Process synced events * await this.plot.createActivity(activity); * } @@ -138,7 +138,9 @@ export interface CalendarTool { * @returns Promise that resolves when sync setup is complete * @throws When auth token is invalid or calendar doesn't exist */ - startSync any>( + startSync< + TCallback extends (activity: NewActivityWithNotes, ...args: any[]) => any + >( authToken: string, calendarId: string, callback: TCallback, diff --git a/twister/src/common/messaging.ts b/twister/src/common/messaging.ts index 7e68b3e..a560450 100644 --- a/twister/src/common/messaging.ts +++ b/twister/src/common/messaging.ts @@ -1,4 +1,4 @@ -import type { Activity, ActivityLink } from "../index"; +import type { ActivityLink, NewActivityWithNotes } from "../index"; /** * Represents a successful messaging service authorization. @@ -43,7 +43,8 @@ export interface MessageSyncOptions { /** * Base interface for email and chat integration tools. * - * All synced messages/emails are converted to Activity. + * All synced messages/emails are converted to ActivityWithNotes objects. + * Each email thread or chat conversation becomes an Activity with Notes for each message. */ export interface MessagingTool { /** @@ -71,18 +72,20 @@ export interface MessagingTool { /** * Begins synchronizing messages from a specific channel. * - * Messages are converted to an array of Activity represent the email/chat thread. - * The meta.source is unique and stable per message. The meta.source of the first - * Activity in the thread can be used as a thread identifier. + * Email threads and chat conversations are converted to ActivityWithNotes objects. + * Each object contains an Activity (with subject/title) and Notes array (one per message). + * The Activity.id can be used as a stable conversation identifier. * * @param authToken - Authorization token for access * @param channelId - ID of the channel (e.g., channel, inbox) to sync - * @param callback - Function receiving (activity, ...extraArgs) for each synced message/thread + * @param callback - Function receiving (thread, ...extraArgs) for each synced conversation * @param options - Optional configuration for limiting the sync scope (e.g., time range) * @param extraArgs - Additional arguments to pass to the callback (type-checked) * @returns Promise that resolves when sync setup is complete */ - startSync any>( + startSync< + TCallback extends (thread: NewActivityWithNotes, ...args: any[]) => any + >( authToken: string, channelId: string, callback: TCallback, diff --git a/twister/src/plot.ts b/twister/src/plot.ts index dea9519..2728c7a 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -214,7 +214,6 @@ export type ActivityMeta = { * type: ActivityType.Action, * title: "Review budget proposal", * author: { id: "user-1", name: "John Doe", type: ActorType.User }, - * end: null, * priority: { id: "work", title: "Work" }, * // ... other fields * }; @@ -229,17 +228,30 @@ export type ActivityMeta = { * }; * ``` */ -export type Activity = { +export type ActivityCommon = { /** Unique identifier for the activity */ id: string; - /** The type of activity (Note, Task, or Event) */ - type: ActivityType; /** Information about who created the activity */ author: Actor; + /** Whether this activity is in draft state (not shown in do now view) */ + draft: boolean; + /** Whether this activity is private (only visible to author) */ + private: boolean; + /** Tags attached to this activity. Maps tag ID to array of actor IDs who added that tag. */ + tags: Partial> | null; + /** Array of actor IDs (users, contacts, or twists) mentioned in this activity via @-mentions */ + mentions: ActorId[] | null; +}; + +export type Activity = ActivityCommon & { /** The display title/summary of the activity */ title: string | null; - /** Primary content for the activity */ - note: string | null; + /** The type of activity (Note, Task, or Event) */ + type: ActivityType; + /** Who this activity note is assigned to */ + assignee: Actor | null; + /** Timestamp when the activity was marked as complete. Null if not completed. */ + doneAt: Date | null; /** * Start time of a scheduled activity. Notes are not typically scheduled unless they're about specific times. * For recurring events, this represents the start of the first occurrence. @@ -266,14 +278,6 @@ export type Activity = { * Null for non-recurring activities or indefinite recurrence. */ recurrenceCount: number | null; - /** Timestamp when the activity was marked as complete. Null if not completed. */ - doneAt: Date | null; - /** Reference to a parent activity for creating hierarchical relationships */ - parent: Activity | null; - /** For nested activities in a thread, references the top-level activity of that thread */ - threadRoot?: Activity; - /** Array of interactive links attached to the activity */ - links: Array | null; /** The priority context this activity belongs to */ priority: Priority; /** Recurrence rule in RFC 5545 RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") */ @@ -294,10 +298,14 @@ export type Activity = { occurrence: Date | null; /** Metadata about the activity, typically from an external system that created it */ meta: ActivityMeta | null; - /** Tags attached to this activity. Maps tag ID to array of actor IDs who added that tag. */ - tags: Partial> | null; - /** Array of actor IDs (users, contacts, or twists) mentioned in this activity via @-mentions */ - mentions: ActorId[] | null; +}; + +export type ActivityWithNotes = Activity & { + notes: Note[]; +}; + +export type NewActivityWithNotes = NewActivity & { + notes: Omit[]; }; /** @@ -373,22 +381,7 @@ export type PickPriorityConfig = { * ``` */ export type NewActivity = Pick & - Partial< - Omit< - Activity, - "id" | "author" | "type" | "parent" | "priority" | "threadRoot" - > & { - parent?: Pick | null; - - /** - * Format of the note content. Determines how the note is processed: - * - 'text': Plain text that will be converted to markdown (auto-links URLs, preserves line breaks) - * - 'markdown': Already in markdown format (default, no conversion) - * - 'html': HTML content that will be converted to markdown - */ - noteType?: NoteType; - } - > & + Partial> & ( | { /** Explicit priority (required when specified) - disables automatic priority matching */ @@ -409,21 +402,71 @@ export type ActivityUpdate = Pick & | "start" | "end" | "doneAt" - | "note" | "title" + | "draft" + | "private" | "meta" - | "links" | "recurrenceRule" | "recurrenceDates" | "recurrenceExdates" | "recurrenceUntil" | "recurrenceCount" | "occurrence" - | "mentions" > > & { - parent?: Pick | null; + /** + * Full tags object from Activity. Maps tag ID to array of actor IDs who added that tag. + * Only allowed for activities created by the twist. + * Use twistTags instead for adding/removing the twist's tags on other activities. + */ + tags?: Partial>; + /** + * Add or remove the twist's tags. + * Maps tag ID to boolean: true = add tag, false = remove tag. + * This is allowed on all activities the twist has access to. + */ + twistTags?: Partial>; + }; + +/** + * Represents a note within an activity. + * + * Notes contain the detailed content (note text, links) associated with an activity. + * They are always ordered by creation time within their parent activity. + */ +export type Note = Omit & { + /** The parent activity this note belongs to */ + activity: Activity; + /** Primary content for the note (markdown) */ + note: string | null; + /** Array of interactive links attached to the note */ + links: Array | null; +}; + +/** + * Type for creating new notes. + * + * Requires the activity reference, with all other fields optional. + */ +export type NewNote = Partial> & { + /** Reference to the parent activity (required) */ + activity: Pick; + + /** + * Format of the note content. Determines how the note is processed: + * - 'text': Plain text that will be converted to markdown (auto-links URLs, preserves line breaks) + * - 'markdown': Already in markdown format (default, no conversion) + * - 'html': HTML content that will be converted to markdown + */ + noteType?: NoteType; +}; + +/** + * Type for updating existing notes. + */ +export type NoteUpdate = Pick & + Partial> & { /** * Format of the note content. Determines how the note is processed: * - 'text': Plain text that will be converted to markdown (auto-links URLs, preserves line breaks) @@ -433,16 +476,16 @@ export type ActivityUpdate = Pick & noteType?: NoteType; /** - * Full tags object from Activity. Maps tag ID to array of actor IDs who added that tag. - * Only allowed for activities created by the twist. - * Use twistTags instead for adding/removing the twist's tags on other activities. + * Full tags object from Note. Maps tag ID to array of actor IDs who added that tag. + * Only allowed for notes created by the twist. + * Use twistTags instead for adding/removing the twist's tags on other notes. */ tags?: Partial>; /** * Add or remove the twist's tags. * Maps tag ID to boolean: true = add tag, false = remove tag. - * This is allowed on all activities the twist has access to. + * This is allowed on all notes the twist has access to. */ twistTags?: Partial>; }; diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index e211b67..4533d56 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -1,26 +1,29 @@ import { type Activity, - type ActivityMeta, type ActivityUpdate, type Actor, type ActorId, ITool, type NewActivity, + type NewActivityWithNotes, type NewContact, + type NewNote, type NewPriority, + type Note, + type NoteUpdate, type Priority, type Tag, } from ".."; export enum ActivityAccess { /** - * Create new Activity on a thread where the twist was mentioned. - * Add/remove tags on Activity where the twist was mentioned. + * Create new Note on an Activity where the twist was mentioned. + * Add/remove tags on Activity or Note where the twist was mentioned. */ Respond, /** - * Create new, top-level Activity. - * Create new Activity in a thread the twist created. + * Create new Activity. + * Create new Note in an Activity the twist created. * All Respond permissions. */ Create, @@ -51,13 +54,13 @@ export enum ContactAccess { * Intent handler for activity mentions. * Defines how the twist should respond when mentioned in an activity. */ -export type ActivityIntentHandler = { +export type NoteIntentHandler = { /** Human-readable description of what this intent handles */ description: string; /** Example phrases or activity content that would match this intent */ examples: string[]; /** The function to call when this intent is matched */ - handler: (activity: Activity) => Promise; + handler: (note: Note) => Promise; }; /** @@ -94,31 +97,33 @@ export type ActivityIntentHandler = { */ export abstract class Plot extends ITool { static readonly Options: { - /** - * Activity event callbacks. - */ activity?: { + /** + * Capability to create Notes and modify tags. + */ access?: ActivityAccess; - /** - * Called when an activity is updated. + * Called when an activity created by this twist is updated. + * This is often used to implement two-way sync with an external system. * * @param activity - The updated activity * @param changes - Optional changes object containing the previous version and tag modifications */ updated?: ( activity: Activity, - changes?: { + changes: { previous: Activity; tagsAdded: Record; tagsRemoved: Record; } ) => Promise; - + }; + note?: { /** - * Intent handlers for activity mentions. - * When an activity mentions this twist, the system will match the activity - * content against these intent descriptions and call the matching handler. + * Respond to mentions in notes. + * + * When a note mentions this twist, the system will match the note + * content against these intents and call the matching handler. * * @example * ```typescript @@ -133,7 +138,7 @@ export abstract class Plot extends ITool { * }] * ``` */ - intents?: ActivityIntentHandler[]; + intents?: NoteIntentHandler[]; }; priority?: { access?: PriorityAccess; @@ -154,7 +159,9 @@ export abstract class Plot extends ITool { * @returns Promise resolving to the complete created activity */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract createActivity(activity: NewActivity): Promise; + abstract createActivity( + activity: NewActivity | NewActivityWithNotes + ): Promise; /** * Creates multiple activities in a single batch operation. @@ -167,7 +174,9 @@ export abstract class Plot extends ITool { * @returns Promise resolving to array of created activities */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract createActivities(activities: NewActivity[]): Promise; + abstract createActivities( + activities: (NewActivity | NewActivityWithNotes)[] + ): Promise; /** * Updates an existing activity in the Plot system. @@ -224,30 +233,121 @@ export abstract class Plot extends ITool { abstract updateActivity(activity: ActivityUpdate): Promise; /** - * Retrieves all activities in the same thread as the specified activity. + * Retrieves all notes within an activity. + * + * Notes are detailed entries within an activity, ordered by creation time. + * Each note can contain markdown content, links, and other detailed information + * related to the parent activity. + * + * @param activity - The activity whose notes to retrieve + * @returns Promise resolving to array of notes in the activity + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract getNotes(activity: Activity): Promise; + + /** + * Creates a new note in an activity. * - * A thread consists of related activities linked through parent-child - * relationships or other associative connections. This is useful for - * finding conversation histories or related task sequences. + * Notes provide detailed content within an activity, supporting markdown, + * links, and other rich content. The note will be automatically assigned + * an ID and author information based on the current execution context. + * + * @param note - The note data to create + * @returns Promise resolving to the complete created note + * + * @example + * ```typescript + * // Create a note with content + * await this.plot.createNote({ + * activity: { id: "activity-123" }, + * note: "Discussion notes from the meeting...", + * noteType: "markdown" + * }); + * + * // Create a note with links + * await this.plot.createNote({ + * activity: { id: "activity-456" }, + * note: "Meeting recording available", + * links: [{ + * type: ActivityLinkType.external, + * title: "View Recording", + * url: "https://example.com/recording" + * }] + * }); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract createNote(note: NewNote): Promise; + + /** + * Creates multiple notes in a single batch operation. * - * @param activity - The activity whose thread to retrieve - * @returns Promise resolving to array of activities in the thread + * This method efficiently creates multiple notes at once, which is + * more performant than calling createNote() multiple times individually. + * All notes are created with the same author and access control rules. + * + * @param notes - Array of note data to create + * @returns Promise resolving to array of created notes + * + * @example + * ```typescript + * // Create multiple notes in one batch + * await this.plot.createNotes([ + * { + * activity: { id: "activity-123" }, + * note: "First message in thread" + * }, + * { + * activity: { id: "activity-123" }, + * note: "Second message in thread" + * } + * ]); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract createNotes(notes: NewNote[]): Promise; + + /** + * Updates an existing note in the Plot system. + * + * Only the fields provided in the update object will be modified - all other fields + * remain unchanged. This enables partial updates without needing to fetch and resend + * the entire note object. + * + * @param note - The note update containing the ID and fields to change + * @returns Promise that resolves when the update is complete + * + * @example + * ```typescript + * // Update note content + * await this.plot.updateNote({ + * id: "note-123", + * note: "Updated content with more details" + * }); + * + * // Add tags to a note + * await this.plot.updateNote({ + * id: "note-456", + * twistTags: { + * [Tag.Important]: true + * } + * }); + * ``` */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract getThread(activity: Activity): Promise; + abstract updateNote(note: NoteUpdate): Promise; /** - * Finds an activity by its metadata. + * Finds an activity by its meta.source. * * This method enables lookup of activities that were created from external * systems, using the metadata to locate the corresponding Plot activity. - * Useful for preventing duplicate imports and maintaining sync state. * - * @param meta - The activity metadata to search for + * @param source - The meta.source value to search for * @returns Promise resolving to the matching activity or null if not found */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract getActivityByMeta(meta: ActivityMeta): Promise; + abstract getActivityBySource(source: string): Promise; /** * Creates a new priority in the Plot system. diff --git a/twister/src/utils/hash.ts b/twister/src/utils/hash.ts new file mode 100644 index 0000000..2bb56bf --- /dev/null +++ b/twister/src/utils/hash.ts @@ -0,0 +1,8 @@ +export function quickHash(str: string) { + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; ++i) { + hash ^= str.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} diff --git a/twister/typedoc.json b/twister/typedoc.json index a2e09b2..f93ac1e 100644 --- a/twister/typedoc.json +++ b/twister/typedoc.json @@ -15,7 +15,8 @@ "src/tools/store.ts", "src/tools/tasks.ts", "src/common/calendar.ts", - "src/utils/types.ts" + "src/utils/types.ts", + "src/utils/hash.ts" ], "out": "dist/docs", "tsconfig": "./tsconfig.json", @@ -39,8 +40,7 @@ "docs/TOOLS_GUIDE.md", "docs/BUILDING_TOOLS.md", "docs/CLI_REFERENCE.md", - "docs/RUNTIME.md", - "docs/ADVANCED.md" + "docs/RUNTIME.md" ], "name": "Creating Plot Twists", "includeVersion": false, diff --git a/twists/calendar-sync/src/index.ts b/twists/calendar-sync/src/index.ts index a62abd6..f54337b 100644 --- a/twists/calendar-sync/src/index.ts +++ b/twists/calendar-sync/src/index.ts @@ -5,7 +5,11 @@ import { type ActivityLink, ActivityLinkType, ActivityType, + type ActivityUpdate, + type ActorId, + type NewActivityWithNotes, type Priority, + type Tag, type ToolBuilder, Twist, } from "@plotday/twister"; @@ -16,6 +20,7 @@ import type { SyncOptions, } from "@plotday/twister/common/calendar"; import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { quickHash } from "@plotday/twister/utils/hash"; type CalendarProvider = "google" | "outlook"; @@ -93,13 +98,18 @@ export default class CalendarSyncTwist extends Twist { "outlook" ); - // Create activity with both auth links + // Create onboarding activity const connectActivity = await this.tools.plot.createActivity({ type: ActivityType.Action, title: "Connect your calendar", start: new Date(), end: null, - links: [googleAuthLink, outlookAuthLink], + notes: [ + { + note: "Connect a calendar account to get started. You can connect as many as you like.", + links: [googleAuthLink, outlookAuthLink], + }, + ], }); // Store the original activity ID for use as parent @@ -170,13 +180,182 @@ export default class CalendarSyncTwist extends Twist { } async handleEvent( - activity: Activity, + activity: NewActivityWithNotes, _provider: CalendarProvider, _calendarId: string ): Promise { + // Check if activity already exists based on meta.source + if (activity.meta?.source) { + const existing = await this.tools.plot.getActivityBySource( + activity.meta.source + ); + if (existing) { + // Activity already exists - update it if needed + await this.updateExistingEvent(existing, activity); + return; + } + activity.meta = { + ...activity.meta, + // Add a hash so we can add a new note if it changes + descriptionHash: quickHash(activity.notes[0]?.note ?? ""), + }; + } + await this.tools.plot.createActivity(activity); } + private async updateExistingEvent( + existing: Activity, + incoming: NewActivityWithNotes + ): Promise { + const updates: ActivityUpdate = { id: existing.id }; + let updatedDescription: string | undefined; + let hasChanges = false; + + // Check for type changes (e.g., event was cancelled and became a Note) + if (incoming.type !== undefined && incoming.type !== existing.type) { + updates.type = incoming.type; + hasChanges = true; + } + + // Check for title changes + if (incoming.title !== undefined && incoming.title !== existing.title) { + updates.title = incoming.title; + hasChanges = true; + } + + // Check for time changes (rescheduling or cancellation setting times to null) + if (incoming.start !== undefined) { + const incomingStart = + incoming.start === null + ? null + : typeof incoming.start === "string" + ? incoming.start + : incoming.start?.toISOString(); + const existingStart = + existing.start === null + ? null + : typeof existing.start === "string" + ? existing.start + : existing.start?.toISOString(); + + if (incomingStart !== existingStart) { + updates.start = incoming.start; + hasChanges = true; + } + } + + if (incoming.end !== undefined) { + const incomingEnd = + incoming.end === null + ? null + : typeof incoming.end === "string" + ? incoming.end + : incoming.end?.toISOString(); + const existingEnd = + existing.end === null + ? null + : typeof existing.end === "string" + ? existing.end + : existing.end?.toISOString(); + + if (incomingEnd !== existingEnd) { + updates.end = incoming.end; + hasChanges = true; + } + } + + // Check for recurrence rule changes + if ( + incoming.recurrenceRule !== undefined && + incoming.recurrenceRule !== existing.recurrenceRule + ) { + updates.recurrenceRule = incoming.recurrenceRule; + hasChanges = true; + } + + // Check for recurrence until changes + if ( + incoming.recurrenceUntil !== undefined && + incoming.recurrenceUntil !== existing.recurrenceUntil + ) { + updates.recurrenceUntil = incoming.recurrenceUntil; + hasChanges = true; + } + + // Check for recurrence count changes + if ( + incoming.recurrenceCount !== undefined && + incoming.recurrenceCount !== existing.recurrenceCount + ) { + updates.recurrenceCount = incoming.recurrenceCount; + hasChanges = true; + } + + // Check for tag changes (RSVP status) + if (incoming.tags) { + const tagsChanged = this.haveTagsChanged(existing.tags, incoming.tags); + if (tagsChanged) { + updates.tags = incoming.tags; + hasChanges = true; + } + } + + // Check for metadata changes + if (incoming.meta) { + const metaChanged = + JSON.stringify(existing.meta) !== JSON.stringify(incoming.meta); + if (metaChanged) { + updates.meta = incoming.meta; + hasChanges = true; + } + } + + // Check for description changes + if ( + existing.meta && + existing.meta.descriptionHash !== quickHash(incoming.notes[0]?.note ?? "") + ) { + updatedDescription = incoming.notes[0]?.note ?? undefined; + updates.meta = { + ...(incoming.meta ?? existing.meta), + descriptionHash: quickHash(incoming.notes[0]?.note ?? ""), + }; + hasChanges = true; + } + + // Apply updates if there are any changes + if (hasChanges) { + console.log( + `Updating activity ${existing.id} with changes:`, + Object.keys(updates).filter((k) => k !== "id") + ); + await this.tools.plot.updateActivity(updates); + } else { + console.log(`No changes detected for activity ${existing.id}`); + } + + if (updatedDescription) { + // Add a new note with the updated description + await this.tools.plot.createNote({ + activity: { id: existing.id }, + note: `*Calendar description updated*: ${updatedDescription}`, + }); + } + } + + private haveTagsChanged( + existingTags: Partial> | null, + incomingTags: Partial> + ): boolean { + // Convert both to JSON for simple comparison + // This works for most cases, though a more sophisticated comparison + // could check individual tag additions/removals + const existingJson = JSON.stringify(existingTags || {}); + const incomingJson = JSON.stringify(incomingTags); + return existingJson !== incomingJson; + } + async onAuthComplete( authResult: CalendarAuth, provider: CalendarProvider @@ -195,11 +374,15 @@ export default class CalendarSyncTwist extends Twist { const calendars = await tool.getCalendars(authResult.authToken); if (calendars.length === 0) { - await this.tools.plot.createActivity({ - type: ActivityType.Note, - note: `I couldn't find any calendars for that account.`, - parent: await this.getParentActivity(), - }); + const activity = await this.getParentActivity(); + if (activity) { + await this.tools.plot.createNote({ + activity, + note: `I couldn't find any calendars for that account.`, + }); + } else { + console.warn("No parent activity found for no calendars note"); + } return; } @@ -247,12 +430,17 @@ export default class CalendarSyncTwist extends Twist { } // Create the calendar selection activity + const providerName = provider === "google" ? "Google" : "Outlook"; await this.tools.plot.createActivity({ type: ActivityType.Action, title: `Which calendars would you like to connect?`, start: new Date(), - links, - parent: await this.getParentActivity(), + notes: [ + { + note: `Which ${providerName} calendars you'd like to sync?`, + links, + }, + ], }); } @@ -283,11 +471,14 @@ export default class CalendarSyncTwist extends Twist { ); console.log(`Started syncing ${provider} calendar: ${calendarName}`); - - await this.tools.plot.createActivity({ - type: ActivityType.Note, - note: `Reading your ${calendarName} calendar`, - parent: await this.getParentActivity(), + const activity = await this.getParentActivity(); + if (!activity) { + console.warn("No parent activity found for calendar sync note"); + return; + } + await this.tools.plot.createNote({ + activity, + note: `Reading your ${calendarName} calendar.`, }); } catch (error) { console.error( diff --git a/twists/chat/src/index.ts b/twists/chat/src/index.ts index a063ad1..2d8ec4f 100644 --- a/twists/chat/src/index.ts +++ b/twists/chat/src/index.ts @@ -7,6 +7,7 @@ import { ActorType, Tag, type ToolBuilder, + type Note, } from "@plotday/twister"; import { AI, type AIMessage } from "@plotday/twister/tools/ai"; import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; @@ -18,6 +19,8 @@ export default class ChatTwist extends Twist { plot: build(Plot, { activity: { access: ActivityAccess.Respond, + }, + note: { intents: [ { description: "Respond to general questions and requests", @@ -34,8 +37,11 @@ export default class ChatTwist extends Twist { }; } - async responsd(activity: Activity) { - const previousActivities = await this.tools.plot.getThread(activity); + async responsd(note: Note) { + const activity = note.activity; + + // Get all notes in this activity (conversation history) + const previousNotes = await this.tools.plot.getNotes(activity); // Add Thinking tag to indicate processing has started await this.tools.plot.updateActivity({ @@ -48,20 +54,30 @@ export default class ChatTwist extends Twist { const messages: AIMessage[] = [ { role: "system", - content: `You are an AI assistant inside of a productivity app. + content: `You are an AI assistant inside of a productivity app. You respond helpfully to user requests. You can also create tasks, but should only do so when the user explicitly asks you to.`, }, - ...previousActivities - .filter((a) => a.note ?? a.title) + // Include activity title as context + ...(activity.title + ? [ + { + role: "user" as const, + content: activity.title, + }, + ] + : []), + // Include all previous notes in the conversation + ...previousNotes + .filter((n: Note) => n.note) .map( - (prevActivity) => + (prevNote: Note) => ({ role: - prevActivity.author.type === ActorType.Twist + prevNote.author.type === ActorType.Twist ? "assistant" : "user", - content: (prevActivity.note ?? prevActivity.title)!, + content: prevNote.note!, } satisfies AIMessage) ), ]; @@ -70,7 +86,7 @@ You can also create tasks, but should only do so when the user explicitly asks y message: Type.Object({ note: Type.String({ description: "Response to the user's prompt" }), title: Type.String({ - description: "Short title for the response notee", + description: "Short title for the response note", }), }), action_items: Type.Optional( @@ -100,19 +116,34 @@ You can also create tasks, but should only do so when the user explicitly asks y outputSchema: schema, }); + type ActionItem = { + title: string; + note?: string; + }; + + // Note: For now, creating activities without parent relationship + // Once Note API is available, responses should become Notes await Promise.all([ this.tools.plot.createActivity({ title: response.output!.message.title, - note: response.output!.message.note, - parent: activity, + notes: [ + { + note: response.output!.message.note, + }, + ], priority: activity.priority, type: activity.type, }), - ...(response.output!.action_items?.map((item: any) => + ...(response.output!.action_items?.map((item: ActionItem) => this.tools.plot.createActivity({ title: item.title, - note: item.note, - parent: activity, + notes: item.note + ? [ + { + note: item.note, + }, + ] + : undefined, priority: activity.priority, type: ActivityType.Action, start: new Date(), diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index 6b6a65c..2591b12 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -6,6 +6,7 @@ import { type ActivityLink, ActivityLinkType, ActivityType, + type NewActivityWithNotes, type Priority, type ToolBuilder, Twist, @@ -167,9 +168,13 @@ export default class MessageTasksTwist extends Twist { const connectActivity = await this.tools.plot.createActivity({ type: ActivityType.Action, title: "Connect messaging to create tasks", - note: "I'll analyze your message threads and create tasks when action is needed.", start: new Date(), - links: [slackAuthLink], + notes: [ + { + note: "I'll analyze your message threads and create tasks when action is needed.", + links: [slackAuthLink], + }, + ], }); // Store for parent relationship @@ -198,11 +203,13 @@ export default class MessageTasksTwist extends Twist { const channels = await tool.getChannels(authResult.authToken); if (channels.length === 0) { - await this.tools.plot.createActivity({ - type: ActivityType.Note, - note: `No channels found for ${provider}.`, - parent: await this.getOnboardingActivity(), - }); + const activity = await this.getOnboardingActivity(); + if (activity) { + await this.tools.plot.createNote({ + activity, + note: `No channels found for ${provider}.`, + }); + } return; } @@ -214,11 +221,13 @@ export default class MessageTasksTwist extends Twist { ); } catch (error) { console.error(`Failed to fetch channels for ${provider}:`, error); - await this.tools.plot.createActivity({ - type: ActivityType.Note, - note: `Failed to connect to ${provider}. Please try again.`, - parent: await this.getOnboardingActivity(), - }); + const activity = await this.getOnboardingActivity(); + if (activity) { + await this.tools.plot.createNote({ + activity, + note: `Failed to connect to ${provider}. Please try again.`, + }); + } } } @@ -255,14 +264,14 @@ export default class MessageTasksTwist extends Twist { } // Create the channel selection activity - await this.tools.plot.createActivity({ - type: ActivityType.Action, - title: `Which ${provider} channels should I monitor?`, - note: "Select channels where you want tasks created from actionable messages.", - start: new Date(), - links, - parent: await this.getOnboardingActivity(), - }); + const activity = await this.getOnboardingActivity(); + if (activity) { + await this.tools.plot.createNote({ + activity, + note: `Which ${provider} channels should I monitor?`, + links, + }); + } } async onChannelSelected( @@ -300,21 +309,25 @@ export default class MessageTasksTwist extends Twist { console.log(`Started monitoring ${provider} channel: ${channelName}`); - await this.tools.plot.createActivity({ - type: ActivityType.Note, - note: `Now monitoring #${channelName} for actionable threads`, - parent: await this.getOnboardingActivity(), - }); + const activity = await this.getOnboardingActivity(); + if (activity) { + await this.tools.plot.createNote({ + activity, + note: `Now monitoring #${channelName} for actionable threads`, + }); + } } catch (error) { console.error( `Failed to start monitoring channel ${channelName}:`, error ); - await this.tools.plot.createActivity({ - type: ActivityType.Note, - note: `Failed to monitor #${channelName}. Please try again.`, - parent: await this.getOnboardingActivity(), - }); + const activity = await this.getOnboardingActivity(); + if (activity) { + await this.tools.plot.createNote({ + activity, + note: `Failed to monitor #${channelName}. Please try again.`, + }); + } } } @@ -323,20 +336,20 @@ export default class MessageTasksTwist extends Twist { // ============================================================================ async onMessageThread( - thread: Activity[], + thread: NewActivityWithNotes, provider: MessageProvider, channelId: string ): Promise { - if (thread.length === 0) return; + if (!thread.notes || thread.notes.length === 0) return; - const threadId = thread[0].meta?.source as string; + const threadId = thread.meta?.source as string; if (!threadId) { console.warn("Thread has no source meta, skipping"); return; } console.log( - `Processing thread: ${threadId} with ${thread.length} messages` + `Processing thread: ${threadId} with ${thread.notes.length} messages` ); // Check if we already have a task for this thread @@ -366,7 +379,7 @@ export default class MessageTasksTwist extends Twist { await this.createTaskFromThread(thread, analysis, provider, channelId); } - private async analyzeThread(thread: Activity[]): Promise<{ + private async analyzeThread(thread: NewActivityWithNotes): Promise<{ needsTask: boolean; taskTitle: string | null; taskNote: string | null; @@ -397,11 +410,9 @@ DO NOT create tasks for: If a task is needed, create a clear, actionable title that describes what the user needs to do.`, }, - ...thread.map((activity, idx) => ({ + ...thread.notes.map((note, idx) => ({ role: "user" as const, - content: `[Message ${idx + 1}] ${activity.author?.name || "User"}: ${ - activity.note || activity.title || "(empty message)" - }`, + content: `[Message ${idx + 1}] User: ${note.note || "(empty message)"}`, })), ]; @@ -466,7 +477,7 @@ If a task is needed, create a clear, actionable title that describes what the us } private async createTaskFromThread( - thread: Activity[], + thread: NewActivityWithNotes, analysis: { needsTask: boolean; taskTitle: string | null; @@ -476,8 +487,7 @@ If a task is needed, create a clear, actionable title that describes what the us provider: MessageProvider, channelId: string ): Promise { - const rootMessage = thread[0]; - const threadId = rootMessage.meta?.source as string; + const threadId = thread.meta?.source as string; // Get channel name for context const configs = await this.getChannelConfigs(); @@ -486,17 +496,37 @@ If a task is needed, create a clear, actionable title that describes what the us ); const channelName = channelConfig?.channelName || channelId; + // Check if task already exists for this thread + const taskSource = `message-tasks:${threadId}`; + const existingActivity = await this.tools.plot.getActivityBySource( + taskSource + ); + if (existingActivity) { + console.log(`Task with source ${taskSource} already exists`); + // Store the mapping and return + await this.storeThreadTask(threadId, existingActivity.id); + return; + } + // Create task activity const task = await this.tools.plot.createActivity({ type: ActivityType.Action, title: - analysis.taskTitle || rootMessage.title || "Action needed from message", - note: analysis.taskNote - ? `${analysis.taskNote}\n\n---\nFrom #${channelName}` - : `From #${channelName}`, + analysis.taskTitle || thread.title || "Action needed from message", start: new Date(), + notes: analysis.taskNote + ? [ + { + note: `${analysis.taskNote}\n\n---\nFrom #${channelName}`, + }, + ] + : [ + { + note: `From #${channelName}`, + }, + ], meta: { - source: `message-tasks:${threadId}`, + source: taskSource, originalThreadId: threadId, provider, channelId, @@ -513,11 +543,11 @@ If a task is needed, create a clear, actionable title that describes what the us } private async checkThreadForCompletion( - thread: Activity[], + thread: NewActivityWithNotes, taskInfo: ThreadTask ): Promise { // Only check the last few messages for completion signals - const recentMessages = thread.slice(-3); + const recentMessages = thread.notes.slice(-3); // Build a simple prompt to check for completion const messages: AIMessage[] = [ @@ -534,11 +564,9 @@ Look for signals like: Return true only if there's clear evidence the task is done.`, }, - ...recentMessages.map((activity) => ({ + ...recentMessages.map((note) => ({ role: "user" as const, - content: `${activity.author?.name || "User"}: ${ - activity.note || activity.title || "" - }`, + content: `User: ${note.note || ""}`, })), ];