From 57f3742c7a931c824fb811673a1d95f47bd1ffbb Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 24 Feb 2026 15:00:06 -0500 Subject: [PATCH 01/25] Activity --> Thread, Link --> Action --- tools/asana/src/asana.ts | 108 ++-- tools/github-issues/src/github-issues.ts | 94 ++-- tools/github/src/github.ts | 82 +-- tools/gmail/src/gmail-api.ts | 24 +- tools/gmail/src/gmail.ts | 20 +- tools/google-calendar/src/google-api.ts | 28 +- tools/google-calendar/src/google-calendar.ts | 136 ++--- tools/google-drive/src/google-drive.ts | 58 +- tools/jira/src/jira.ts | 88 +-- tools/linear/src/linear.ts | 108 ++-- tools/outlook-calendar/src/graph-api.ts | 32 +- .../outlook-calendar/src/outlook-calendar.ts | 126 ++--- tools/slack/src/slack-api.ts | 24 +- tools/slack/src/slack.ts | 24 +- twister/src/common/calendar.ts | 20 +- twister/src/common/documents.ts | 38 +- twister/src/common/messaging.ts | 16 +- twister/src/common/projects.ts | 36 +- twister/src/common/source-control.ts | 38 +- twister/src/plot.ts | 499 ++++++++++-------- twister/src/tag.ts | 2 +- twister/src/tool.ts | 12 +- twister/src/tools/callbacks.ts | 2 +- twister/src/tools/plot.ts | 187 +++---- twister/src/tools/twists.ts | 2 +- twister/src/twist.ts | 22 +- twists/calendar-sync/src/index.ts | 18 +- twists/chat/src/index.ts | 44 +- twists/code-review/src/index.ts | 46 +- twists/document-actions/src/index.ts | 36 +- twists/message-tasks/src/index.ts | 54 +- twists/project-sync/src/index.ts | 58 +- 32 files changed, 1070 insertions(+), 1012 deletions(-) diff --git a/tools/asana/src/asana.ts b/tools/asana/src/asana.ts index 4843fae..623d940 100644 --- a/tools/asana/src/asana.ts +++ b/tools/asana/src/asana.ts @@ -1,14 +1,14 @@ import * as asana from "asana"; import { - type Activity, - type ActivityFilter, - type Link, - LinkType, - ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, + type Thread, + type ThreadFilter, + type Action, + ActionType, + ThreadMeta, + ThreadType, + type NewThread, + type NewThreadWithNotes, type NewNote, type Serializable, type SyncToolOptions, @@ -111,7 +111,7 @@ export class Asana extends Tool implements ProjectTool { // Create disable callback if parent provided onSyncableDisabled if (this.options.onSyncableDisabled) { - const filter: ActivityFilter = { + const filter: ThreadFilter = { meta: { syncProvider: "asana", syncableId: syncable.id }, }; const disableCallbackToken = await this.tools.callbacks.createFromParent( @@ -190,7 +190,7 @@ export class Asana extends Tool implements ProjectTool { */ async startSync< TArgs extends Serializable[], - TCallback extends (task: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (task: NewThreadWithNotes, ...args: TArgs) => any >( options: { projectId: string; @@ -342,18 +342,18 @@ export class Asana extends Tool implements ProjectTool { } } - const activityWithNotes = await this.convertTaskToActivity( + const threadWithNotes = await this.convertTaskToThread( task, projectId ); // Set unread based on sync type (false for initial sync to avoid notification overload) - activityWithNotes.unread = !state.initialSync; + threadWithNotes.unread = !state.initialSync; // Unarchive on initial sync only (preserve user's archive state on incremental syncs) if (state.initialSync) { - activityWithNotes.archived = false; + threadWithNotes.archived = false; } // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, activityWithNotes); + await this.tools.callbacks.run(callbackToken, threadWithNotes); } // Check if more pages by checking if we got a full batch @@ -381,12 +381,12 @@ export class Asana extends Tool implements ProjectTool { } /** - * Convert an Asana task to a Plot Activity + * Convert an Asana task to a Plot Thread */ - private async convertTaskToActivity( + private async convertTaskToThread( task: any, projectId: string - ): Promise { + ): Promise { const createdBy = task.created_by; const assignee = task.assignee; @@ -419,30 +419,30 @@ export class Asana extends Tool implements ProjectTool { } // Use stable identifier for source - const activitySource = `asana:task:${task.gid}`; + const threadSource = `asana:task:${task.gid}`; // Construct Asana task URL for link const taskUrl = `https://app.asana.com/0/${projectId}/${task.gid}`; - // Build activity-level links - const activityLinks: Link[] = []; - activityLinks.push({ - type: LinkType.external, + // Build thread-level actions + const threadActions: Action[] = []; + threadActions.push({ + type: ActionType.external, title: `Open in Asana`, url: taskUrl, }); - // Create initial note with description (links moved to activity level) + // Create initial note with description (actions moved to thread level) notes.push({ - activity: { source: activitySource }, + thread: { source: threadSource }, key: "description", content: description, created: task.created_at ? new Date(task.created_at) : undefined, }); return { - source: activitySource, - type: ActivityType.Action, + source: threadSource, + type: ThreadType.Action, title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, meta: { @@ -451,7 +451,7 @@ export class Asana extends Tool implements ProjectTool { syncProvider: "asana", syncableId: projectId, }, - links: activityLinks.length > 0 ? activityLinks : undefined, + actions: threadActions.length > 0 ? threadActions : undefined, author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned tasks done: @@ -466,34 +466,34 @@ export class Asana extends Tool implements ProjectTool { /** * Update task with new values * - * @param activity - The updated activity + * @param thread - The updated thread */ - async updateIssue(activity: Activity): Promise { + async updateIssue(thread: Thread): Promise { // Extract Asana task GID and project ID from meta - const taskGid = activity.meta?.taskGid as string | undefined; + const taskGid = thread.meta?.taskGid as string | undefined; if (!taskGid) { - throw new Error("Asana task GID not found in activity meta"); + throw new Error("Asana task GID not found in thread meta"); } - const projectId = activity.meta?.projectId as string | undefined; + const projectId = thread.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Asana project ID not found in activity meta"); + throw new Error("Asana project ID not found in thread meta"); } const client = await this.getClient(projectId); const updateFields: any = {}; // Handle title - if (activity.title !== null) { - updateFields.name = activity.title; + if (thread.title !== null) { + updateFields.name = thread.title; } // Handle assignee - updateFields.assignee = activity.assignee?.id || null; + updateFields.assignee = thread.assignee?.id || null; // Handle completion status based on done // Asana only has completed boolean (no In Progress state) updateFields.completed = - activity.type === ActivityType.Action && activity.done !== null; + thread.type === ThreadType.Action && thread.done !== null; // Apply updates if any fields changed if (Object.keys(updateFields).length > 0) { @@ -504,20 +504,20 @@ export class Asana extends Tool implements ProjectTool { /** * Add a comment (story) to an Asana task * - * @param meta - Activity metadata containing taskGid and projectId + * @param meta - Thread metadata containing taskGid and projectId * @param body - Comment text (markdown not directly supported, plain text) */ async addIssueComment( - meta: ActivityMeta, + meta: ThreadMeta, body: string ): Promise { const taskGid = meta.taskGid as string | undefined; if (!taskGid) { - throw new Error("Asana task GID not found in activity meta"); + throw new Error("Asana task GID not found in thread meta"); } const projectId = meta.projectId as string | undefined; if (!projectId) { - throw new Error("Asana project ID not found in activity meta"); + throw new Error("Asana project ID not found in thread meta"); } const client = await this.getClient(projectId); @@ -690,7 +690,7 @@ export class Asana extends Tool implements ProjectTool { } // Use stable identifier for source - const activitySource = `asana:task:${task.gid}`; + const threadSource = `asana:task:${task.gid}`; // Extract description let description: string | null = null; @@ -698,10 +698,10 @@ export class Asana extends Tool implements ProjectTool { description = task.notes; } - // Create partial activity update (no notes = doesn't touch existing notes) - const activity: NewActivity = { - source: activitySource, - type: ActivityType.Action, + // Create partial thread update (no notes = doesn't touch existing notes) + const thread: NewThread = { + source: threadSource, + type: ThreadType.Action, title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, meta: { @@ -719,7 +719,7 @@ export class Asana extends Tool implements ProjectTool { preview: description || null, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } catch (error) { console.warn("Failed to process Asana task webhook:", error); } @@ -739,7 +739,7 @@ export class Asana extends Tool implements ProjectTool { try { // Use stable identifier for source - const activitySource = `asana:task:${taskGid}`; + const threadSource = `asana:task:${taskGid}`; // Fetch stories (comments) for this task // We fetch all stories since Asana doesn't provide the specific story GID in the webhook @@ -774,14 +774,14 @@ export class Asana extends Tool implements ProjectTool { }; } - // Create activity update with single story note - const activity: NewActivityWithNotes = { - source: activitySource, - type: ActivityType.Action, // Required field (will match existing activity) + // Create thread update with single story note + const thread: NewThreadWithNotes = { + source: threadSource, + type: ThreadType.Action, // Required field (will match existing thread) notes: [ { key: `story-${latestStory.gid}`, - activity: { source: activitySource }, + thread: { source: threadSource }, content: latestStory.text || "", created: latestStory.created_at ? new Date(latestStory.created_at) @@ -797,7 +797,7 @@ export class Asana extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } catch (error) { console.warn("Failed to process Asana story webhook:", error); } diff --git a/tools/github-issues/src/github-issues.ts b/tools/github-issues/src/github-issues.ts index 4df4a36..7cb1a18 100644 --- a/tools/github-issues/src/github-issues.ts +++ b/tools/github-issues/src/github-issues.ts @@ -1,12 +1,12 @@ import { Octokit } from "@octokit/rest"; import { - type Link, - LinkType, - type ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, + type Action, + ActionType, + type ThreadMeta, + ThreadType, + type NewThread, + type NewThreadWithNotes, type NewNote, type Serializable, type SyncToolOptions, @@ -209,7 +209,7 @@ export class GitHubIssues extends Tool implements ProjectTool { async startSync< TArgs extends Serializable[], TCallback extends ( - issue: NewActivityWithNotes, + issue: NewThreadWithNotes, ...args: TArgs ) => any, >( @@ -359,7 +359,7 @@ export class GitHubIssues extends Tool implements ProjectTool { // Skip pull requests (GitHub returns PRs in issues endpoint) if (issue.pull_request) continue; - const activity = await this.convertIssueToActivity( + const thread = await this.convertIssueToThread( octokit, issue, repoId, @@ -367,13 +367,13 @@ export class GitHubIssues extends Tool implements ProjectTool { state.initialSync ); - if (activity) { - activity.meta = { - ...activity.meta, + if (thread) { + thread.meta = { + ...thread.meta, syncProvider: "github-issues", syncableId: repoId, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); processedInBatch++; } } @@ -419,15 +419,15 @@ export class GitHubIssues extends Tool implements ProjectTool { } /** - * Convert a GitHub issue to a NewActivityWithNotes + * Convert a GitHub issue to a NewThreadWithNotes */ - private async convertIssueToActivity( + private async convertIssueToThread( octokit: Octokit, issue: any, repoId: string, repoFullName: string, initialSync: boolean - ): Promise { + ): Promise { // Build author contact (GitHub users may not have email) let authorContact: NewContact | undefined; if (issue.user) { @@ -453,17 +453,17 @@ export class GitHubIssues extends Tool implements ProjectTool { const description = issue.body || ""; const hasDescription = description.trim().length > 0; - // Build activity-level links - const activityLinks: Link[] = []; + // Build thread-level actions + const threadActions: Action[] = []; if (issue.html_url) { - activityLinks.push({ - type: LinkType.external, + threadActions.push({ + type: ActionType.external, title: "Open in GitHub", url: issue.html_url, }); } - // Build notes array (inline notes don't require the `activity` field) + // Build notes array (inline notes don't require the `thread` field) const notes: any[] = []; notes.push({ @@ -518,9 +518,9 @@ export class GitHubIssues extends Tool implements ProjectTool { ); } - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: `github:issue:${repoId}:${issue.number}`, - type: ActivityType.Action, + type: ThreadType.Action, title: issue.title, created: issue.created_at, author: authorContact, @@ -533,37 +533,37 @@ export class GitHubIssues extends Tool implements ProjectTool { githubRepoFullName: repoFullName, projectId: repoId, }, - links: activityLinks.length > 0 ? activityLinks : undefined, + actions: threadActions.length > 0 ? threadActions : undefined, notes, preview: hasDescription ? description : null, ...(initialSync ? { unread: false } : {}), ...(initialSync ? { archived: false } : {}), }; - return activity; + return thread; } /** * Update issue with new values */ async updateIssue( - activity: import("@plotday/twister").Activity + thread: import("@plotday/twister").Thread ): Promise { - const issueNumber = activity.meta?.githubIssueNumber as number | undefined; + const issueNumber = thread.meta?.githubIssueNumber as number | undefined; if (!issueNumber) { - throw new Error("GitHub issue number not found in activity meta"); + throw new Error("GitHub issue number not found in thread meta"); } - const repoFullName = activity.meta?.githubRepoFullName as + const repoFullName = thread.meta?.githubRepoFullName as | string | undefined; if (!repoFullName) { - throw new Error("GitHub repo name not found in activity meta"); + throw new Error("GitHub repo name not found in thread meta"); } - const projectId = activity.meta?.projectId as string | undefined; + const projectId = thread.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in activity meta"); + throw new Error("Project ID not found in thread meta"); } const octokit = await this.getClient(projectId); @@ -575,15 +575,15 @@ export class GitHubIssues extends Tool implements ProjectTool { } = {}; // Handle open/close status - if (activity.type === ActivityType.Action && activity.done !== null) { + if (thread.type === ThreadType.Action && thread.done !== null) { updateFields.state = "closed"; } else { updateFields.state = "open"; } // Handle assignee - if (activity.assignee) { - const actors = await this.tools.plot.getActors([activity.assignee.id]); + if (thread.assignee) { + const actors = await this.tools.plot.getActors([thread.assignee.id]); const actor = actors[0]; if (actor?.name) { // GitHub assignees use login names @@ -607,22 +607,22 @@ export class GitHubIssues extends Tool implements ProjectTool { * Add a comment to a GitHub issue */ async addIssueComment( - meta: ActivityMeta, + meta: ThreadMeta, body: string ): Promise { const issueNumber = meta.githubIssueNumber as number | undefined; if (!issueNumber) { - throw new Error("GitHub issue number not found in activity meta"); + throw new Error("GitHub issue number not found in thread meta"); } const repoFullName = meta.githubRepoFullName as string | undefined; if (!repoFullName) { - throw new Error("GitHub repo name not found in activity meta"); + throw new Error("GitHub repo name not found in thread meta"); } const projectId = meta.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in activity meta"); + throw new Error("Project ID not found in thread meta"); } const octokit = await this.getClient(projectId); @@ -749,9 +749,9 @@ export class GitHubIssues extends Tool implements ProjectTool { }; } - const activity: NewActivity = { + const thread: NewThread = { source: `github:issue:${repoId}:${issue.number}`, - type: ActivityType.Action, + type: ThreadType.Action, title: issue.title, created: issue.created_at, author: authorContact, @@ -769,7 +769,7 @@ export class GitHubIssues extends Tool implements ProjectTool { preview: issue.body || null, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } /** @@ -801,18 +801,18 @@ export class GitHubIssues extends Tool implements ProjectTool { }; } - const activitySource = `github:issue:${repoId}:${issue.number}`; + const threadSource = `github:issue:${repoId}:${issue.number}`; const note: NewNote = { key: `comment-${comment.id}`, - activity: { source: activitySource }, + thread: { source: threadSource }, content: comment.body ?? null, created: comment.created_at, author: commentAuthor, }; - const activity: NewActivityWithNotes = { - source: activitySource, - type: ActivityType.Action, + const thread: NewThreadWithNotes = { + source: threadSource, + type: ThreadType.Action, notes: [note], meta: { githubIssueNumber: issue.number, @@ -824,7 +824,7 @@ export class GitHubIssues extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } /** diff --git a/tools/github/src/github.ts b/tools/github/src/github.ts index 096073b..d518a25 100644 --- a/tools/github/src/github.ts +++ b/tools/github/src/github.ts @@ -1,11 +1,11 @@ import { - type Activity, - type Link, - LinkType, - type ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, + type Thread, + type Action, + ActionType, + type ThreadMeta, + ThreadType, + type NewThread, + type NewThreadWithNotes, type Serializable, type SyncToolOptions, } from "@plotday/twister"; @@ -293,7 +293,7 @@ export class GitHub extends Tool implements SourceControlTool { */ async startSync< TArgs extends Serializable[], - TCallback extends (pr: NewActivityWithNotes, ...args: TArgs) => any, + TCallback extends (pr: NewThreadWithNotes, ...args: TArgs) => any, >( options: { repositoryId: string; @@ -545,9 +545,9 @@ export class GitHub extends Tool implements SourceControlTool { ? this.userToContact(pr.assignee) : null; - const activity: NewActivity = { + const thread: NewThread = { source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ActivityType.Action, + type: ThreadType.Action, title: pr.title, created: new Date(pr.created_at), author: authorContact, @@ -566,7 +566,7 @@ export class GitHub extends Tool implements SourceControlTool { preview: pr.body || null, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } /** @@ -592,9 +592,9 @@ export class GitHub extends Tool implements SourceControlTool { ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` : review.body || null; - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ActivityType.Action, + type: ThreadType.Action, notes: [ { key: `review-${review.id}`, @@ -614,7 +614,7 @@ export class GitHub extends Tool implements SourceControlTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } /** @@ -633,9 +633,9 @@ export class GitHub extends Tool implements SourceControlTool { const prNumber = issue.number; const commentAuthor = this.userToContact(comment.user); - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: `github:pr:${owner}/${repo}/${prNumber}`, - type: ActivityType.Action, + type: ThreadType.Action, notes: [ { key: `comment-${comment.id}`, @@ -654,7 +654,7 @@ export class GitHub extends Tool implements SourceControlTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } // ---------- Batch sync ---------- @@ -732,7 +732,7 @@ export class GitHub extends Tool implements SourceControlTool { // Process each relevant PR for (const pr of relevantPRs) { - const activity = await this.convertPRToActivity( + const thread = await this.convertPRToThread( token, owner, repo, @@ -741,13 +741,13 @@ export class GitHub extends Tool implements SourceControlTool { state.initialSync, ); - if (activity) { - activity.meta = { - ...activity.meta, + if (thread) { + thread.meta = { + ...thread.meta, syncProvider: "github", syncableId: repositoryId, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } } @@ -769,25 +769,25 @@ export class GitHub extends Tool implements SourceControlTool { } /** - * Convert a GitHub PR to a NewActivityWithNotes + * Convert a GitHub PR to a NewThreadWithNotes */ - private async convertPRToActivity( + private async convertPRToThread( token: string, owner: string, repo: string, pr: GitHubPullRequest, repositoryId: string, initialSync: boolean, - ): Promise { + ): Promise { const authorContact = this.userToContact(pr.user); const assigneeContact = pr.assignee ? this.userToContact(pr.assignee) : null; - // Build activity-level links - const activityLinks: Link[] = [ + // Build thread-level actions + const threadActions: Action[] = [ { - type: LinkType.external, + type: ActionType.external, title: `Open in GitHub`, url: pr.html_url, }, @@ -857,9 +857,9 @@ export class GitHub extends Tool implements SourceControlTool { console.error("Error fetching PR reviews:", error); } - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ActivityType.Action, + type: ThreadType.Action, title: pr.title, created: new Date(pr.created_at), author: authorContact, @@ -872,7 +872,7 @@ export class GitHub extends Tool implements SourceControlTool { prNumber: pr.number, prNodeId: pr.id, }, - links: activityLinks, + actions: threadActions, notes, preview: hasDescription ? pr.body : null, ...(initialSync ? { unread: false } : {}), @@ -883,7 +883,7 @@ export class GitHub extends Tool implements SourceControlTool { : {}), }; - return activity; + return thread; } // ---------- Bidirectional methods ---------- @@ -892,7 +892,7 @@ export class GitHub extends Tool implements SourceControlTool { * Add a general comment to a pull request */ async addPRComment( - meta: ActivityMeta, + meta: ThreadMeta, body: string, noteId?: string, ): Promise { @@ -902,7 +902,7 @@ export class GitHub extends Tool implements SourceControlTool { const syncableId = `${owner}/${repo}`; if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in activity meta"); + throw new Error("Owner, repo, and prNumber required in thread meta"); } const token = await this.getToken(syncableId); @@ -932,8 +932,8 @@ export class GitHub extends Tool implements SourceControlTool { /** * Update a PR's review status (approve or request changes) */ - async updatePRStatus(activity: Activity): Promise { - const meta = activity.meta; + async updatePRStatus(thread: Thread): Promise { + const meta = thread.meta; if (!meta) return; const owner = meta.owner as string; @@ -942,14 +942,14 @@ export class GitHub extends Tool implements SourceControlTool { const syncableId = `${owner}/${repo}`; if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in activity meta"); + throw new Error("Owner, repo, and prNumber required in thread meta"); } const token = await this.getToken(syncableId); - // Map activity done state to review event + // Map thread done state to review event // done = approved, not done = no action (can't undo approval via API easily) - if (activity.type === ActivityType.Action && activity.done !== null) { + if (thread.type === ThreadType.Action && thread.done !== null) { const response = await this.githubFetch( token, `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, @@ -973,14 +973,14 @@ export class GitHub extends Tool implements SourceControlTool { /** * Close a pull request without merging */ - async closePR(meta: ActivityMeta): Promise { + async closePR(meta: ThreadMeta): Promise { const owner = meta.owner as string; const repo = meta.repo as string; const prNumber = meta.prNumber as number; const syncableId = `${owner}/${repo}`; if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in activity meta"); + throw new Error("Owner, repo, and prNumber required in thread meta"); } const token = await this.getToken(syncableId); diff --git a/tools/gmail/src/gmail-api.ts b/tools/gmail/src/gmail-api.ts index 694cc3a..8654afb 100644 --- a/tools/gmail/src/gmail-api.ts +++ b/tools/gmail/src/gmail-api.ts @@ -1,6 +1,6 @@ -import { ActivityType } from "@plotday/twister"; +import { ThreadType } from "@plotday/twister"; import type { - NewActivityWithNotes, + NewThreadWithNotes, NewActor, } from "@plotday/twister/plot"; @@ -343,14 +343,14 @@ function extractAttachments( } /** - * Transforms a Gmail thread into a NewActivityWithNotes structure. - * The subject becomes the Activity title, and each email becomes a Note. + * Transforms a Gmail thread into a NewThreadWithNotes structure. + * The subject becomes the Thread title, and each email becomes a Note. */ -export function transformGmailThread(thread: GmailThread): NewActivityWithNotes { +export function transformGmailThread(thread: GmailThread): NewThreadWithNotes { if (!thread.messages || thread.messages.length === 0) { // Return empty structure for invalid threads return { - type: ActivityType.Note, + type: ThreadType.Note, title: "", notes: [], }; @@ -366,10 +366,10 @@ export function transformGmailThread(thread: GmailThread): NewActivityWithNotes const firstMessageBody = extractBody(parentMessage.payload); const preview = firstMessageBody || parentMessage.snippet || null; - // Create Activity - const activity: NewActivityWithNotes = { + // Create Thread + const plotThread: NewThreadWithNotes = { source: canonicalUrl, - type: ActivityType.Note, + type: ThreadType.Note, title: subject || "Email", start: new Date(parseInt(parentMessage.internalDate)), meta: { @@ -399,7 +399,7 @@ export function transformGmailThread(thread: GmailThread): NewActivityWithNotes // Create NewNote with idempotent key const note = { - activity: { source: canonicalUrl }, + thread: { source: canonicalUrl }, key: message.id, author: { email: sender.email, @@ -409,10 +409,10 @@ export function transformGmailThread(thread: GmailThread): NewActivityWithNotes mentions: mentions.length > 0 ? mentions : undefined, }; - activity.notes.push(note); + plotThread.notes.push(note); } - return activity; + return plotThread; } /** diff --git a/tools/gmail/src/gmail.ts b/tools/gmail/src/gmail.ts index 1ef1209..4e07169 100644 --- a/tools/gmail/src/gmail.ts +++ b/tools/gmail/src/gmail.ts @@ -1,6 +1,6 @@ import { - type ActivityFilter, - type NewActivityWithNotes, + type ThreadFilter, + type NewThreadWithNotes, Serializable, type SyncToolOptions, Tool, @@ -21,7 +21,7 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { - ActivityAccess, + ThreadAccess, ContactAccess, Plot, } from "@plotday/twister/tools/plot"; @@ -66,9 +66,9 @@ import { * ); * } * - * async onGmailThread(thread: ActivityWithNotes) { + * async onGmailThread(thread: NewThreadWithNotes) { * // Process Gmail email thread - * // Each thread is an Activity with Notes for each email + * // Each thread is a Thread with Notes for each email * console.log(`Email thread: ${thread.title}`); * console.log(`${thread.notes.length} messages`); * @@ -109,8 +109,8 @@ export class Gmail extends Tool implements MessagingTool { contact: { access: ContactAccess.Write, }, - activity: { - access: ActivityAccess.Create, + thread: { + access: ThreadAccess.Create, }, }), }; @@ -142,7 +142,7 @@ export class Gmail extends Tool implements MessagingTool { // Create disable callback if parent provided onSyncableDisabled if (this.options.onSyncableDisabled) { - const filter: ActivityFilter = { + const filter: ThreadFilter = { meta: { syncProvider: "google", syncableId: syncable.id }, }; const disableCallbackToken = await this.tools.callbacks.createFromParent( @@ -242,7 +242,7 @@ export class Gmail extends Tool implements MessagingTool { async startSync< TArgs extends Serializable[], - TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { channelId: string; @@ -391,7 +391,7 @@ export class Gmail extends Tool implements MessagingTool { for (const thread of threads) { try { - // Transform Gmail thread to NewActivityWithNotes + // Transform Gmail thread to NewThreadWithNotes const activityThread = transformGmailThread(thread); if (activityThread.notes.length === 0) continue; diff --git a/tools/google-calendar/src/google-api.ts b/tools/google-calendar/src/google-api.ts index bd84fab..07b1370 100644 --- a/tools/google-calendar/src/google-api.ts +++ b/tools/google-calendar/src/google-api.ts @@ -1,5 +1,5 @@ -import type { NewActivity } from "@plotday/twister"; -import { ActivityType, ConferencingProvider } from "@plotday/twister"; +import type { NewThread } from "@plotday/twister"; +import { ThreadType, ConferencingProvider } from "@plotday/twister"; export type GoogleEvent = { id: string; @@ -339,7 +339,7 @@ export function extractConferencingLinks( export function transformGoogleEvent( event: GoogleEvent, calendarId: string -): NewActivity { +): NewThread { // Determine if this is an all-day event const isAllDay = event.start?.date && !event.start?.dateTime; @@ -383,44 +383,44 @@ export function transformGoogleEvent( }, } as const; - const activity: NewActivity = + const thread: NewThread = isCancelled || isAllDay - ? { type: ActivityType.Note, ...shared } - : { type: ActivityType.Event, ...shared }; + ? { type: ThreadType.Note, ...shared } + : { type: ThreadType.Event, ...shared }; // Handle recurrence for master events (not instances) if (event.recurrence && !event.recurringEventId) { - activity.recurrenceRule = parseRRule(event.recurrence); + thread.recurrenceRule = parseRRule(event.recurrence); // Parse recurrence count (takes precedence over UNTIL) const recurrenceCount = parseGoogleRecurrenceCount(event.recurrence); if (recurrenceCount) { - activity.recurrenceCount = recurrenceCount; + thread.recurrenceCount = recurrenceCount; } else { - // Parse recurrence end date for recurring activities if no count + // Parse recurrence end date for recurring threads if no count const recurrenceUntil = parseGoogleRecurrenceEnd(event.recurrence); if (recurrenceUntil) { - activity.recurrenceUntil = recurrenceUntil; + thread.recurrenceUntil = recurrenceUntil; } } const exdates = parseExDates(event.recurrence); if (exdates.length > 0) { - activity.recurrenceExdates = exdates; + thread.recurrenceExdates = exdates; } // Parse RDATEs (additional occurrence dates not in the recurrence rule) - // and create ActivityOccurrenceUpdate entries for each + // and create ThreadOccurrenceUpdate entries for each const rdates = parseRDates(event.recurrence); if (rdates.length > 0) { - activity.occurrences = rdates.map((rdate) => ({ + thread.occurrences = rdates.map((rdate) => ({ occurrence: rdate, start: rdate, })); } } - return activity; + return thread; } export async function syncGoogleCalendar( diff --git a/tools/google-calendar/src/google-calendar.ts b/tools/google-calendar/src/google-calendar.ts index b323937..e71ca9a 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/tools/google-calendar/src/google-calendar.ts @@ -1,14 +1,14 @@ import GoogleContacts from "@plotday/tool-google-contacts"; import { - type Activity, - LinkType, - type Link, - type ActivityOccurrence, - ActivityType, + type Thread, + ActionType, + type Action, + type ThreadOccurrence, + ThreadType, type ActorId, ConferencingProvider, - type NewActivityOccurrence, - type NewActivityWithNotes, + type NewThreadOccurrence, + type NewThreadWithNotes, type NewActor, type NewContact, type NewNote, @@ -33,7 +33,7 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { - ActivityAccess, + ThreadAccess, ContactAccess, Plot, } from "@plotday/twister/tools/plot"; @@ -83,10 +83,10 @@ import { * provider: "google" * }); * - * await this.plot.createActivity({ - * type: ActivityType.Action, + * await this.plot.createThread({ + * type: ThreadType.Action, * title: "Connect Google Calendar", - * links: [authLink] + * actions: [authLink] * }); * } * @@ -109,9 +109,9 @@ import { * } * } * - * async onCalendarEvent(activity: NewActivityWithNotes, context: any) { + * async onCalendarEvent(thread: NewThreadWithNotes, context: any) { * // Process Google Calendar events - * await this.plot.createActivity(activity); + * await this.plot.createThread(thread); * } * } * ``` @@ -151,9 +151,9 @@ export class GoogleCalendar contact: { access: ContactAccess.Write, }, - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, + thread: { + access: ThreadAccess.Create, + updated: this.onThreadUpdated, }, }), googleContacts: build(GoogleContacts), @@ -369,7 +369,7 @@ export class GoogleCalendar async startSync< TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { calendarId: string; @@ -760,7 +760,7 @@ export class GoogleCalendar // Create cancellation note const cancelNote: NewNote = { - activity: { source: canonicalUrl }, + thread: { source: canonicalUrl }, key: "cancellation", content: "This event was cancelled.", contentType: "text", @@ -768,10 +768,10 @@ export class GoogleCalendar }; // Convert to Note type with blocked tag and cancellation note - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: canonicalUrl, created: event.created ? new Date(event.created) : undefined, - type: ActivityType.Note, + type: ThreadType.Note, title: activityData.title, preview: "Cancelled", start: activityData.start || null, @@ -783,10 +783,10 @@ export class GoogleCalendar }; // Inject sync metadata for the parent to identify the source - activity.meta = { ...activity.meta, syncProvider: "google", syncableId: calendarId }; + thread.meta = { ...thread.meta, syncProvider: "google", syncableId: calendarId }; - // Send activity - database handles upsert automatically - await this.tools.callbacks.run(callbackToken, activity); + // Send thread - database handles upsert automatically + await this.tools.callbacks.run(callbackToken, thread); continue; } @@ -831,8 +831,8 @@ export class GoogleCalendar } } - // Build links array for videoconferencing and calendar links - const links: Link[] = []; + // Build actions array for videoconferencing and calendar links + const actions: Action[] = []; const seenUrls = new Set(); // Extract all conferencing links (Zoom, Teams, Webex, etc.) @@ -840,8 +840,8 @@ export class GoogleCalendar for (const link of conferencingLinks) { if (!seenUrls.has(link.url)) { seenUrls.add(link.url); - links.push({ - type: LinkType.conferencing, + actions.push({ + type: ActionType.conferencing, url: link.url, provider: link.provider, }); @@ -851,8 +851,8 @@ export class GoogleCalendar // Add Google Meet link from hangoutLink if not already added if (event.hangoutLink && !seenUrls.has(event.hangoutLink)) { seenUrls.add(event.hangoutLink); - links.push({ - type: LinkType.conferencing, + actions.push({ + type: ActionType.conferencing, url: event.hangoutLink, provider: ConferencingProvider.googleMeet, }); @@ -860,8 +860,8 @@ export class GoogleCalendar // Add calendar link if (event.htmlLink) { - links.push({ - type: LinkType.external, + actions.push({ + type: ActionType.external, title: "View in Calendar", url: event.htmlLink, }); @@ -873,7 +873,7 @@ export class GoogleCalendar const description = typeof descriptionValue === "string" ? descriptionValue : null; const hasDescription = description && description.trim().length > 0; - const hasLinks = links.length > 0; + const hasActions = actions.length > 0; if (!activityData.type) { continue; @@ -882,11 +882,11 @@ export class GoogleCalendar // Canonical source for this event (required for upsert) const canonicalUrl = `google-calendar:${event.id}`; - // Create note with description (links moved to activity level) + // Create note with description (actions moved to thread level) const notes: NewNote[] = []; if (hasDescription) { notes.push({ - activity: { source: canonicalUrl }, + thread: { source: canonicalUrl }, key: "description", content: description, contentType: @@ -908,25 +908,25 @@ export class GoogleCalendar recurrenceExdates: activityData.recurrenceExdates || null, meta: activityData.meta ?? null, tags: tags || undefined, - links: hasLinks ? links : undefined, + actions: hasActions ? actions : undefined, notes, preview: hasDescription ? description : null, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only } as const; - const activity: NewActivityWithNotes = - activityData.type === ActivityType.Action - ? { type: ActivityType.Action, ...shared } - : activityData.type === ActivityType.Event - ? { type: ActivityType.Event, ...shared } - : { type: ActivityType.Note, ...shared }; + const thread: NewThreadWithNotes = + activityData.type === ThreadType.Action + ? { type: ThreadType.Action, ...shared } + : activityData.type === ThreadType.Event + ? { type: ThreadType.Event, ...shared } + : { type: ThreadType.Note, ...shared }; // Inject sync metadata for the parent to identify the source - activity.meta = { ...activity.meta, syncProvider: "google", syncableId: calendarId }; + thread.meta = { ...thread.meta, syncProvider: "google", syncableId: calendarId }; - // Send activity - database handles upsert automatically - await this.tools.callbacks.run(callbackToken, activity); + // Send thread - database handles upsert automatically + await this.tools.callbacks.run(callbackToken, thread); } } catch (error) { console.error(`Failed to process event ${event.id}:`, error); @@ -937,7 +937,7 @@ export class GoogleCalendar /** * Process a recurring event instance (occurrence) from Google Calendar. - * This updates the master recurring activity with occurrence-specific data. + * This updates the master recurring thread with occurrence-specific data. */ private async processEventInstance( event: GoogleEvent, @@ -952,7 +952,7 @@ export class GoogleCalendar return; } - // The recurring event ID points to the master activity + // The recurring event ID points to the master thread if (!event.recurringEventId) { console.warn(`No recurring event ID for instance: ${event.id}`); return; @@ -980,7 +980,7 @@ export class GoogleCalendar : null; const occurrenceUpdate = { - type: ActivityType.Event, + type: ThreadType.Event, source: masterCanonicalUrl, start: start, end: end, @@ -1027,11 +1027,11 @@ export class GoogleCalendar // Build occurrence object // Always include start to ensure upsert_activity can infer scheduling when - // creating a new master activity. Use instanceData.start if available (for + // creating a new master thread. Use instanceData.start if available (for // rescheduled instances), otherwise fall back to originalStartTime. const occurrenceStart = instanceData.start ?? new Date(originalStartTime); - const occurrence: Omit = { + const occurrence: Omit = { occurrence: new Date(originalStartTime), start: occurrenceStart, tags: Object.keys(tags).length > 0 ? tags : undefined, @@ -1046,12 +1046,12 @@ export class GoogleCalendar if (instanceData.meta) occurrence.meta = instanceData.meta; // Send occurrence data to the twist via callback - // The twist will decide whether to create or update the master activity + // The twist will decide whether to create or update the master thread - // Build a minimal NewActivity with source and occurrences - // The twist's createActivity will upsert the master activity + // Build a minimal NewThread with source and occurrences + // The twist's createThread will upsert the master thread const occurrenceUpdate = { - type: ActivityType.Event, + type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "google", syncableId: calendarId }, occurrences: [occurrence], @@ -1169,17 +1169,17 @@ export class GoogleCalendar return `${baseEventId}_${instanceDateStr}`; } - async onActivityUpdated( - activity: Activity, + async onThreadUpdated( + thread: Thread, changes: { tagsAdded: Record; tagsRemoved: Record; - occurrence?: ActivityOccurrence; + occurrence?: ThreadOccurrence; } ): Promise { try { // Only process calendar events - const source = activity.source; + const source = thread.source; if ( !source || typeof source !== "string" || @@ -1214,12 +1214,12 @@ export class GoogleCalendar // Determine new RSVP status based on most recent tag change const hasAttend = - activity.tags?.[Tag.Attend] && activity.tags[Tag.Attend].length > 0; + thread.tags?.[Tag.Attend] && thread.tags[Tag.Attend].length > 0; const hasSkip = - activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; + thread.tags?.[Tag.Skip] && thread.tags[Tag.Skip].length > 0; const hasUndecided = - activity.tags?.[Tag.Undecided] && - activity.tags[Tag.Undecided].length > 0; + thread.tags?.[Tag.Undecided] && + thread.tags[Tag.Undecided].length > 0; let newStatus: "accepted" | "declined" | "tentative" | "needsAction"; @@ -1253,15 +1253,15 @@ export class GoogleCalendar } // Extract calendar info from metadata - if (!activity.meta) { - console.error("[RSVP Sync] Missing activity metadata", { - activity_id: activity.id, + if (!thread.meta) { + console.error("[RSVP Sync] Missing thread metadata", { + thread_id: thread.id, }); return; } - const baseEventId = activity.meta.id; - const calendarId = activity.meta.calendarId; + const baseEventId = thread.meta.id; + const calendarId = thread.meta.calendarId; if ( !baseEventId || @@ -1294,7 +1294,7 @@ export class GoogleCalendar await this.tools.integrations.actAs( GoogleCalendar.PROVIDER, actorId, - activity.id, + thread.id, this.syncActorRSVP, calendarId as string, eventId, @@ -1306,7 +1306,7 @@ export class GoogleCalendar console.error("[RSVP Sync] Error in callback", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, - activity_id: activity.id, + thread_id: thread.id, }); } } diff --git a/tools/google-drive/src/google-drive.ts b/tools/google-drive/src/google-drive.ts index 1999a8a..8775c28 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/tools/google-drive/src/google-drive.ts @@ -1,11 +1,11 @@ import GoogleContacts from "@plotday/tool-google-contacts"; import { - type ActivityFilter, - ActivityKind, - type Link, - LinkType, - ActivityType, - type NewActivityWithNotes, + type ThreadFilter, + ThreadKind, + type Action, + ActionType, + ThreadType, + type NewThreadWithNotes, type NewContact, type NewNote, Serializable, @@ -180,7 +180,7 @@ export class GoogleDrive extends Tool implements DocumentTool { // Create disable callback if parent provided onSyncableDisabled if (this.options.onSyncableDisabled) { - const filter: ActivityFilter = { + const filter: ThreadFilter = { meta: { syncProvider: "google", syncableId: syncable.id }, }; const disableCallbackToken = await this.tools.callbacks.createFromParent( @@ -279,7 +279,7 @@ export class GoogleDrive extends Tool implements DocumentTool { async startSync< TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { folderId: string; @@ -359,7 +359,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const fileId = meta.fileId as string | undefined; const folderId = meta.folderId as string | undefined; if (!fileId || !folderId) { - console.warn("No fileId/folderId in activity meta, cannot add comment"); + console.warn("No fileId/folderId in thread meta, cannot add comment"); return; } @@ -377,7 +377,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const fileId = meta.fileId as string | undefined; const folderId = meta.folderId as string | undefined; if (!fileId || !folderId) { - console.warn("No fileId/folderId in activity meta, cannot add reply"); + console.warn("No fileId/folderId in thread meta, cannot add reply"); return; } @@ -588,13 +588,13 @@ export class GoogleDrive extends Tool implements DocumentTool { for (const file of result.files) { try { - const activity = await this.buildActivityFromFile( + const thread = await this.buildThreadFromFile( api, file, folderId, initialSync ); - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } catch (error) { console.error(`Failed to process file ${file.id}:`, error); } @@ -663,13 +663,13 @@ export class GoogleDrive extends Tool implements DocumentTool { processedCount++; try { - const activity = await this.buildActivityFromFile( + const thread = await this.buildThreadFromFile( api, change.file, folderId, false // incremental sync ); - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } catch (error) { console.error( `Failed to process changed file ${change.fileId}:`, @@ -705,14 +705,14 @@ export class GoogleDrive extends Tool implements DocumentTool { } } - // --- Activity Building --- + // --- Thread Building --- - private async buildActivityFromFile( + private async buildThreadFromFile( api: GoogleApi, file: GoogleDriveFile, folderId: string, initialSync: boolean - ): Promise { + ): Promise { const canonicalSource = `google-drive:file:${file.id}`; // Build author contact from file owner @@ -743,7 +743,7 @@ export class GoogleDrive extends Tool implements DocumentTool { // Summary note with description if available notes.push({ - activity: { source: canonicalSource }, + thread: { source: canonicalSource }, key: "summary", content: file.description || null, contentType: "text", @@ -777,23 +777,23 @@ export class GoogleDrive extends Tool implements DocumentTool { console.error(`Failed to fetch comments for file ${file.id}:`, error); } - // Build external link - const links: Link[] = []; + // Build external action + const actions: Action[] = []; if (file.webViewLink) { - links.push({ - type: LinkType.external, + actions.push({ + type: ActionType.external, title: "View in Drive", url: file.webViewLink, }); } - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: canonicalSource, - type: ActivityType.Note, - kind: ActivityKind.document, + type: ThreadType.Note, + kind: ThreadKind.document, title: file.name, author, - links: links.length > 0 ? links : null, + actions: actions.length > 0 ? actions : null, meta: { fileId: file.id, folderId, @@ -809,7 +809,7 @@ export class GoogleDrive extends Tool implements DocumentTool { ...(initialSync ? { archived: false } : {}), }; - return activity; + return thread; } private buildCommentNote( @@ -830,7 +830,7 @@ export class GoogleDrive extends Tool implements DocumentTool { : undefined; return { - activity: { source: canonicalSource }, + thread: { source: canonicalSource }, key: `comment-${comment.id}`, content: comment.content, contentType: comment.htmlContent ? "html" : "text", @@ -863,7 +863,7 @@ export class GoogleDrive extends Tool implements DocumentTool { : undefined; return { - activity: { source: canonicalSource }, + thread: { source: canonicalSource }, key: `reply-${commentId}-${reply.id}`, reNote: { key: `comment-${commentId}` }, content: reply.content, diff --git a/tools/jira/src/jira.ts b/tools/jira/src/jira.ts index ef3e992..66fad42 100644 --- a/tools/jira/src/jira.ts +++ b/tools/jira/src/jira.ts @@ -1,12 +1,12 @@ import { Version3Client } from "jira.js"; import { - type Activity, - type Link, - LinkType, - ActivityType, - type NewActivity, - type NewActivityWithNotes, + type Thread, + type Action, + ActionType, + ThreadType, + type NewThread, + type NewThreadWithNotes, NewContact, Serializable, type SyncToolOptions, @@ -183,7 +183,7 @@ export class Jira extends Tool implements ProjectTool { */ async startSync< TArgs extends Serializable[], - TCallback extends (issue: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (issue: NewThreadWithNotes, ...args: TArgs) => any >( options: { projectId: string; @@ -317,16 +317,16 @@ export class Jira extends Tool implements ProjectTool { // Process each issue for (const issue of searchResult.issues || []) { - const activityWithNotes = await this.convertIssueToActivity( + const threadWithNotes = await this.convertIssueToThread( issue, projectId ); // Set unread based on sync type (false for initial sync to avoid notification overload) - activityWithNotes.unread = !state.initialSync; + threadWithNotes.unread = !state.initialSync; // Inject sync metadata for filtering on disable - activityWithNotes.meta = { ...activityWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; + threadWithNotes.meta = { ...threadWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, activityWithNotes); + await this.tools.callbacks.run(callbackToken, threadWithNotes); } // Check if more pages @@ -367,12 +367,12 @@ export class Jira extends Tool implements ProjectTool { } /** - * Convert a Jira issue to a Plot Activity + * Convert a Jira issue to a Plot Thread */ - private async convertIssueToActivity( + private async convertIssueToThread( issue: any, projectId: string - ): Promise { + ): Promise { const fields = issue.fields || {}; const comments = fields.comment?.comments || []; const reporter = fields.reporter || fields.creator; @@ -430,17 +430,17 @@ export class Jira extends Tool implements ProjectTool { ? `jira:${cloudId}:issue:${issue.id}` : undefined; - // Build activity-level links - const activityLinks: Link[] = []; + // Build thread-level actions + const threadActions: Action[] = []; if (issueUrl) { - activityLinks.push({ - type: LinkType.external, + threadActions.push({ + type: ActionType.external, title: `Open in Jira`, url: issueUrl, }); } - // Create initial note with description (links moved to activity level) + // Create initial note with description (actions moved to thread level) notes.push({ key: "description", content: description, @@ -476,7 +476,7 @@ export class Jira extends Tool implements ProjectTool { return { ...(source ? { source } : {}), - type: ActivityType.Action, + type: ThreadType.Action, title: fields.summary || issue.key, created: fields.created ? new Date(fields.created) : undefined, meta: { @@ -486,7 +486,7 @@ export class Jira extends Tool implements ProjectTool { author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned issues done: fields.resolutiondate ? new Date(fields.resolutiondate) : null, - links: activityLinks.length > 0 ? activityLinks : undefined, + actions: threadActions.length > 0 ? threadActions : undefined, notes, preview: description || null, }; @@ -525,27 +525,27 @@ export class Jira extends Tool implements ProjectTool { /** * Update issue with new values * - * @param activity - The updated activity + * @param thread - The updated thread */ - async updateIssue(activity: Activity): Promise { + async updateIssue(thread: Thread): Promise { // Extract Jira issue key and project ID from meta - const issueKey = activity.meta?.issueKey as string | undefined; + const issueKey = thread.meta?.issueKey as string | undefined; if (!issueKey) { - throw new Error("Jira issue key not found in activity meta"); + throw new Error("Jira issue key not found in thread meta"); } - const projectId = activity.meta?.projectId as string; + const projectId = thread.meta?.projectId as string; const client = await this.getClient(projectId); // Handle field updates (title, assignee) const updateFields: any = {}; - if (activity.title !== null) { - updateFields.summary = activity.title; + if (thread.title !== null) { + updateFields.summary = thread.title; } - updateFields.assignee = activity.assignee - ? { id: activity.assignee.id } + updateFields.assignee = thread.assignee + ? { id: thread.assignee.id } : null; // Apply field updates if any @@ -565,7 +565,7 @@ export class Jira extends Tool implements ProjectTool { let targetTransition; // Determine target state based on combination - if (activity.type === ActivityType.Action && activity.done !== null) { + if (thread.type === ThreadType.Action && thread.done !== null) { // Completed - look for "Done", "Close", or "Resolve" transition targetTransition = transitions.transitions?.find( (t) => @@ -576,7 +576,7 @@ export class Jira extends Tool implements ProjectTool { t.to?.name?.toLowerCase() === "closed" || t.to?.name?.toLowerCase() === "resolved" ); - } else if (activity.start !== null) { + } else if (thread.start !== null) { // In Progress - look for "Start Progress" or "In Progress" transition targetTransition = transitions.transitions?.find( (t) => @@ -610,18 +610,18 @@ export class Jira extends Tool implements ProjectTool { /** * Add a comment to a Jira issue * - * @param meta - Activity metadata containing issueKey and projectId + * @param meta - Thread metadata containing issueKey and projectId * @param body - Comment text (converted to ADF format) * @param noteId - Optional Plot note ID for dedup */ async addIssueComment( - meta: import("@plotday/twister").ActivityMeta, + meta: import("@plotday/twister").ThreadMeta, body: string, noteId?: string, ): Promise { const issueKey = meta.issueKey as string | undefined; if (!issueKey) { - throw new Error("Jira issue key not found in activity meta"); + throw new Error("Jira issue key not found in thread meta"); } const projectId = meta.projectId as string; const client = await this.getClient(projectId); @@ -760,10 +760,10 @@ export class Jira extends Tool implements ProjectTool { } } - // Create partial activity update (no notes = doesn't touch existing notes) - const activity: NewActivity = { + // Create partial thread update (no notes = doesn't touch existing notes) + const thread: NewThread = { ...(source ? { source } : {}), - type: ActivityType.Action, + type: ThreadType.Action, title: fields.summary || issue.key, created: fields.created ? new Date(fields.created) : undefined, meta: { @@ -776,7 +776,7 @@ export class Jira extends Tool implements ProjectTool { preview: description || null, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } /** @@ -831,17 +831,17 @@ export class Jira extends Tool implements ProjectTool { (p: any) => p.key === "plotNoteId" )?.value; - // Create activity update with single comment note - const activity: NewActivityWithNotes = { + // Create thread update with single comment note + const thread: NewThreadWithNotes = { ...(source ? { source } : {}), - type: ActivityType.Action, // Required field (will match existing activity) + type: ThreadType.Action, // Required field (will match existing thread) notes: [ { key: `comment-${comment.id}`, // If this comment originated from Plot, identify by note ID so we update the existing note // rather than creating a duplicate ...(plotNoteId ? { id: plotNoteId } : {}), - activity: source ? { source } : undefined, + thread: source ? { source } : undefined, content: commentText, created: comment.created ? new Date(comment.created) : undefined, author: commentAuthor, @@ -853,7 +853,7 @@ export class Jira extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } /** diff --git a/tools/linear/src/linear.ts b/tools/linear/src/linear.ts index 010eeb1..a394276 100644 --- a/tools/linear/src/linear.ts +++ b/tools/linear/src/linear.ts @@ -7,12 +7,12 @@ import type { import { LinearWebhookClient } from "@linear/sdk/webhooks"; import { - type Link, - LinkType, - ActivityMeta, - ActivityType, - type NewActivity, - type NewActivityWithNotes, + type Action, + ActionType, + ThreadMeta, + ThreadType, + type NewThread, + type NewThreadWithNotes, type NewNote, Serializable, type SyncToolOptions, @@ -185,7 +185,7 @@ export class Linear extends Tool implements ProjectTool { */ async startSync< TArgs extends Serializable[], - TCallback extends (issue: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (issue: NewThreadWithNotes, ...args: TArgs) => any >( options: { projectId: string; @@ -317,21 +317,21 @@ export class Linear extends Tool implements ProjectTool { // Process each issue for (const issue of issuesConnection.nodes) { - const activity = await this.convertIssueToActivity( + const thread = await this.convertIssueToThread( issue, projectId, state.initialSync ); - if (activity) { + if (thread) { // Inject sync metadata for bulk operations (e.g. disable filtering) - activity.meta = { - ...activity.meta, + thread.meta = { + ...thread.meta, syncProvider: "linear", syncableId: projectId, }; // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, thread); } } @@ -358,13 +358,13 @@ export class Linear extends Tool implements ProjectTool { } /** - * Convert a Linear issue to a NewActivityWithNotes + * Convert a Linear issue to a NewThreadWithNotes */ - private async convertIssueToActivity( + private async convertIssueToThread( issue: Issue, projectId: string, initialSync: boolean - ): Promise { + ): Promise { let creator, assignee, comments; try { @@ -420,11 +420,11 @@ export class Linear extends Tool implements ProjectTool { const description = issue.description || ""; const hasDescription = description.trim().length > 0; - // Build activity-level links - const activityLinks: Link[] = []; + // Build thread-level actions + const threadActions: Action[] = []; if (issue.url) { - activityLinks.push({ - type: LinkType.external, + threadActions.push({ + type: ActionType.external, title: `Open in Linear`, url: issue.url, }); @@ -468,9 +468,9 @@ export class Linear extends Tool implements ProjectTool { }); } - const activity: NewActivityWithNotes = { + const newThread: NewThreadWithNotes = { source: `linear:issue:${issue.id}`, - type: ActivityType.Action, + type: ThreadType.Action, title: issue.title, created: issue.createdAt, author: authorContact, @@ -482,33 +482,33 @@ export class Linear extends Tool implements ProjectTool { linearId: issue.id, projectId, }, - links: activityLinks.length > 0 ? activityLinks : undefined, + actions: threadActions.length > 0 ? threadActions : undefined, notes, preview: hasDescription ? description : null, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; - return activity; + return newThread; } /** * Update issue with new values * - * @param activity - The updated activity + * @param thread - The updated thread */ async updateIssue( - activity: import("@plotday/twister").Activity + thread: import("@plotday/twister").Thread ): Promise { - // Get the Linear issue ID from activity meta - const issueId = activity.meta?.linearId as string | undefined; + // Get the Linear issue ID from thread meta + const issueId = thread.meta?.linearId as string | undefined; if (!issueId) { - throw new Error("Linear issue ID not found in activity meta"); + throw new Error("Linear issue ID not found in thread meta"); } - const projectId = activity.meta?.projectId as string | undefined; + const projectId = thread.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in activity meta"); + throw new Error("Project ID not found in thread meta"); } const client = await this.getClient(projectId); @@ -516,17 +516,17 @@ export class Linear extends Tool implements ProjectTool { const updateFields: any = {}; // Handle title - if (activity.title !== null) { - updateFields.title = activity.title; + if (thread.title !== null) { + updateFields.title = thread.title; } // Handle order -> sortOrder - if (activity.order !== undefined && activity.order !== null) { - updateFields.sortOrder = activity.order; + if (thread.order !== undefined && thread.order !== null) { + updateFields.sortOrder = thread.order; } // Handle assignee - map Plot actor to Linear user via email lookup - const currentAssigneeActorId = activity.assignee?.id || null; + const currentAssigneeActorId = thread.assignee?.id || null; if (!currentAssigneeActorId) { updateFields.assigneeId = null; @@ -573,7 +573,7 @@ export class Linear extends Tool implements ProjectTool { let targetState; // Determine target state based on combination - if (activity.type === ActivityType.Action && activity.done !== null) { + if (thread.type === ThreadType.Action && thread.done !== null) { // Completed targetState = states.nodes.find( (s) => @@ -581,7 +581,7 @@ export class Linear extends Tool implements ProjectTool { s.name === "Completed" || s.type === "completed" ); - } else if (activity.start !== null) { + } else if (thread.start !== null) { // In Progress (has start date, not done) targetState = states.nodes.find( (s) => s.name === "In Progress" || s.type === "started" @@ -608,21 +608,21 @@ export class Linear extends Tool implements ProjectTool { /** * Add a comment to a Linear issue * - * @param meta - Activity metadata containing linearId and projectId + * @param meta - Thread metadata containing linearId and projectId * @param body - Comment text (markdown supported) */ async addIssueComment( - meta: ActivityMeta, + meta: ThreadMeta, body: string ): Promise { const issueId = meta.linearId as string | undefined; if (!issueId) { - throw new Error("Linear issue ID not found in activity meta"); + throw new Error("Linear issue ID not found in thread meta"); } const projectId = meta.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in activity meta"); + throw new Error("Project ID not found in thread meta"); } const client = await this.getClient(projectId); @@ -728,7 +728,7 @@ export class Linear extends Tool implements ProjectTool { const creator = issue.creator || null; const assignee = issue.assignee || null; - // Build activity update with only issue fields (no notes) + // Build thread update with only issue fields (no notes) let authorContact: NewContact | undefined; let assigneeContact: NewContact | undefined; @@ -747,11 +747,11 @@ export class Linear extends Tool implements ProjectTool { }; } - // Create partial activity update (no notes = doesn't touch existing notes) + // Create partial thread update (no notes = doesn't touch existing notes) // Note: webhook payload dates are JSON strings, must convert to Date - const activity: NewActivity = { + const newThread: NewThread = { source: `linear:issue:${issue.id}`, - type: ActivityType.Action, + type: ThreadType.Action, title: issue.title, created: new Date(issue.createdAt), author: authorContact, @@ -772,7 +772,7 @@ export class Linear extends Tool implements ProjectTool { preview: issue.description || null, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, newThread); } /** @@ -807,20 +807,20 @@ export class Linear extends Tool implements ProjectTool { }; } - // Create activity update with single comment note - // Type is required by NewActivity, but upsert will use existing activity's type - const activitySource = `linear:issue:${issueId}`; + // Create thread update with single comment note + // Type is required by NewThread, but upsert will use existing thread's type + const threadSource = `linear:issue:${issueId}`; const note: NewNote = { key: `comment-${comment.id}`, - activity: { source: activitySource }, + thread: { source: threadSource }, content: comment.body, created: new Date(comment.createdAt), author: commentAuthor, }; - const activity: NewActivityWithNotes = { - source: activitySource, - type: ActivityType.Action, // Required field (will match existing activity) + const newThread: NewThreadWithNotes = { + source: threadSource, + type: ThreadType.Action, // Required field (will match existing thread) notes: [note], meta: { linearId: issueId, @@ -830,7 +830,7 @@ export class Linear extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, activity); + await this.tools.callbacks.run(callbackToken, newThread); } /** diff --git a/tools/outlook-calendar/src/graph-api.ts b/tools/outlook-calendar/src/graph-api.ts index 86342a1..46397a4 100644 --- a/tools/outlook-calendar/src/graph-api.ts +++ b/tools/outlook-calendar/src/graph-api.ts @@ -1,5 +1,5 @@ -import type { NewActivity } from "@plotday/twister"; -import { ActivityType } from "@plotday/twister"; +import type { NewThread } from "@plotday/twister"; +import { ThreadType } from "@plotday/twister"; import type { Calendar } from "@plotday/twister/common/calendar"; /** @@ -462,12 +462,12 @@ export function parseOutlookRecurrenceCount( } /** - * Transform Microsoft Graph event to Plot Activity + * Transform Microsoft Graph event to Plot Thread */ export function transformOutlookEvent( event: OutlookEvent, calendarId: string -): NewActivity | null { +): NewThread | null { // Skip deleted events if (event["@removed"]) { return null; @@ -508,38 +508,38 @@ export function transformOutlookEvent( }, } as const; - const activity: NewActivity = + const thread: NewThread = isCancelled || isAllDay - ? { type: ActivityType.Note, ...shared } - : { type: ActivityType.Event, ...shared }; + ? { type: ThreadType.Note, ...shared } + : { type: ThreadType.Event, ...shared }; // Handle recurrence for master events (not instances or exceptions) if (event.recurrence && event.type === "seriesMaster") { - activity.recurrenceRule = parseOutlookRRule(event.recurrence); + thread.recurrenceRule = parseOutlookRRule(event.recurrence); // Parse recurrence count (takes precedence over UNTIL) const recurrenceCount = parseOutlookRecurrenceCount(event.recurrence); if (recurrenceCount) { - activity.recurrenceCount = recurrenceCount; + thread.recurrenceCount = recurrenceCount; } else { // Parse recurrence end date if no count const recurrenceUntil = parseOutlookRecurrenceEnd(event.recurrence); if (recurrenceUntil) { - activity.recurrenceUntil = recurrenceUntil; + thread.recurrenceUntil = recurrenceUntil; } } // Parse exception dates (currently not available from Graph API directly) const exdates = parseOutlookExDates(event.recurrence); if (exdates.length > 0) { - activity.recurrenceExdates = exdates; + thread.recurrenceExdates = exdates; } // Parse RDATEs (additional occurrence dates not in the recurrence rule) // Note: Microsoft Graph API doesn't support RDATE, so this will always be empty const rdates = parseOutlookRDates(event.recurrence); if (rdates.length > 0) { - activity.occurrences = rdates.map((rdate) => ({ + thread.occurrences = rdates.map((rdate) => ({ occurrence: rdate, start: rdate, })); @@ -556,15 +556,15 @@ export function transformOutlookEvent( ) { // This is a modified instance of a recurring event // Store the exception info in metadata - if (activity.meta) { - activity.meta.seriesMasterId = event.seriesMasterId; - activity.meta.originalStartDate = new Date( + if (thread.meta) { + thread.meta.seriesMasterId = event.seriesMasterId; + thread.meta.originalStartDate = new Date( event.originalStart ).toISOString(); } } - return activity; + return thread; } /** diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/tools/outlook-calendar/src/outlook-calendar.ts index 378f15c..d159843 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/tools/outlook-calendar/src/outlook-calendar.ts @@ -1,14 +1,14 @@ import { - type Activity, - type Link, - LinkType, - type ActivityOccurrence, - ActivityType, + type Thread, + type Action, + ActionType, + type ThreadOccurrence, + ThreadType, type ActorId, ConferencingProvider, type ContentType, - type NewActivityOccurrence, - type NewActivityWithNotes, + type NewThreadOccurrence, + type NewThreadWithNotes, type NewActor, type NewContact, type NewNote, @@ -33,7 +33,7 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { - ActivityAccess, + ThreadAccess, ContactAccess, Plot, } from "@plotday/twister/tools/plot"; @@ -101,7 +101,7 @@ type WatchState = { * build(build: ToolBuilder) { * return { * outlookCalendar: build(OutlookCalendar), - * plot: build(Plot, { activity: { access: ActivityAccess.Create } }), + * plot: build(Plot, { thread: { access: ThreadAccess.Create } }), * }; * } * @@ -135,9 +135,9 @@ export class OutlookCalendar network: build(Network, { urls: ["https://graph.microsoft.com/*"] }), plot: build(Plot, { contact: { access: ContactAccess.Write }, - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, + thread: { + access: ThreadAccess.Create, + updated: this.onThreadUpdated, }, }), }; @@ -270,7 +270,7 @@ export class OutlookCalendar async startSync< TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { calendarId: string; @@ -478,7 +478,7 @@ export class OutlookCalendar try { // Handle deleted events if (outlookEvent["@removed"]) { - // On initial sync, skip creating activities for already-deleted events + // On initial sync, skip creating threads for already-deleted events if (initialSync) { continue; } @@ -487,7 +487,7 @@ export class OutlookCalendar // Create cancellation note const cancelNote: NewNote = { - activity: { source }, + thread: { source }, key: "cancellation", content: "This event was cancelled.", contentType: "text", @@ -497,8 +497,8 @@ export class OutlookCalendar }; // Convert to Note type with blocked tag and cancellation note - const activity: NewActivityWithNotes = { - type: ActivityType.Note, + const thread: NewThreadWithNotes = { + type: ThreadType.Note, created: outlookEvent.createdDateTime ? new Date(outlookEvent.createdDateTime) : new Date(), @@ -510,8 +510,8 @@ export class OutlookCalendar ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; - // Send activity update - await this.tools.callbacks.run(callbackToken, activity); + // Send thread update + await this.tools.callbacks.run(callbackToken, thread); continue; } @@ -551,11 +551,11 @@ export class OutlookCalendar continue; } - // Transform the Outlook event to a Plot activity (master or single events) - const activity = transformOutlookEvent(outlookEvent, calendarId); + // Transform the Outlook event to a Plot thread (master or single events) + const threadData = transformOutlookEvent(outlookEvent, calendarId); // Skip deleted events (transformOutlookEvent returns null for deleted) - if (!activity) { + if (!threadData) { continue; } @@ -568,7 +568,7 @@ export class OutlookCalendar // Tags (RSVPs) should be per-occurrence via the occurrences array // For non-recurring events, add tags normally let tags: Partial> | null = null; - if (validAttendees.length > 0 && !activity.recurrenceRule) { + if (validAttendees.length > 0 && !threadData.recurrenceRule) { const attendTags: NewActor[] = []; const skipTags: NewActor[] = []; const undecidedTags: NewActor[] = []; @@ -608,13 +608,13 @@ export class OutlookCalendar } } - // Build links array for videoconferencing and calendar links - const links: Link[] = []; + // Build actions array for videoconferencing and calendar links + const actions: Action[] = []; // Add conferencing link if available if (outlookEvent.onlineMeeting?.joinUrl) { - links.push({ - type: LinkType.conferencing, + actions.push({ + type: ActionType.conferencing, url: outlookEvent.onlineMeeting.joinUrl, provider: detectConferencingProvider( outlookEvent.onlineMeeting.joinUrl @@ -624,23 +624,23 @@ export class OutlookCalendar // Add calendar link if (outlookEvent.webLink) { - links.push({ - type: LinkType.external, + actions.push({ + type: ActionType.external, title: "View in Calendar", url: outlookEvent.webLink, }); } - // Create note with description (links moved to activity level) + // Create note with description (actions moved to thread level) const notes: NewNote[] = []; const hasDescription = outlookEvent.body?.content && outlookEvent.body.content.trim().length > 0; - const hasLinks = links.length > 0; + const hasActions = actions.length > 0; if (hasDescription) { notes.push({ - activity: { + thread: { source: `outlook-calendar:${outlookEvent.id}`, }, key: "description", @@ -651,17 +651,17 @@ export class OutlookCalendar }); } - // Build NewActivityWithNotes from the transformed activity - const activityWithNotes: NewActivityWithNotes = { - ...activity, + // Build NewThreadWithNotes from the transformed thread + const threadWithNotes: NewThreadWithNotes = { + ...threadData, author: authorContact, meta: { - ...activity.meta, + ...threadData.meta, syncProvider: "microsoft", syncableId: calendarId, }, - tags: tags && Object.keys(tags).length > 0 ? tags : activity.tags, - links: hasLinks ? links : undefined, + tags: tags && Object.keys(tags).length > 0 ? tags : threadData.tags, + actions: hasActions ? actions : undefined, notes, preview: hasDescription ? outlookEvent.body!.content! : null, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates @@ -669,7 +669,7 @@ export class OutlookCalendar }; // Call the event callback using hoisted token - await this.tools.callbacks.run(callbackToken, activityWithNotes); + await this.tools.callbacks.run(callbackToken, threadWithNotes); } catch (error) { console.error(`Error processing event ${outlookEvent.id}:`, error); // Continue processing other events @@ -679,7 +679,7 @@ export class OutlookCalendar /** * Process a recurring event instance (occurrence or exception) from Outlook Calendar. - * This updates the master recurring activity with occurrence-specific data. + * This updates the master recurring thread with occurrence-specific data. */ private async processEventInstance( event: import("./graph-api").OutlookEvent, @@ -693,7 +693,7 @@ export class OutlookCalendar return; } - // The seriesMasterId points to the master activity + // The seriesMasterId points to the master thread if (!event.seriesMasterId) { console.warn(`No series master ID for instance: ${event.id}`); return; @@ -715,7 +715,7 @@ export class OutlookCalendar const end = instanceData?.end ?? null; const occurrenceUpdate = { - type: ActivityType.Event, + type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, start: start, @@ -766,11 +766,11 @@ export class OutlookCalendar // Build occurrence object // Always include start to ensure upsert_activity can infer scheduling when - // creating a new master activity. Use instanceData.start if available (for + // creating a new master thread. Use instanceData.start if available (for // rescheduled instances), otherwise fall back to originalStart. const occurrenceStart = instanceData.start ?? new Date(originalStart); - const occurrence: Omit = { + const occurrence: Omit = { occurrence: new Date(originalStart), start: occurrenceStart, tags: Object.keys(tags).length > 0 ? tags : undefined, @@ -785,12 +785,12 @@ export class OutlookCalendar if (instanceData.meta) occurrence.meta = instanceData.meta; // Send occurrence data to the twist via callback - // The twist will decide whether to create or update the master activity + // The twist will decide whether to create or update the master thread - // Build a minimal NewActivity with source and occurrences - // The twist's createActivity will upsert the master activity + // Build a minimal NewThread with source and occurrences + // The twist's createThread will upsert the master thread const occurrenceUpdate = { - type: ActivityType.Event, + type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, occurrences: [occurrence], @@ -845,17 +845,17 @@ export class OutlookCalendar await this.runTask(callback); } - async onActivityUpdated( - activity: Activity, + async onThreadUpdated( + thread: Thread, changes: { tagsAdded: Record; tagsRemoved: Record; - occurrence?: ActivityOccurrence; + occurrence?: ThreadOccurrence; } ): Promise { try { // Only process calendar events - const source = activity.source; + const source = thread.source; if ( !source || typeof source !== "string" || @@ -890,12 +890,12 @@ export class OutlookCalendar // Determine new RSVP status based on most recent tag change const hasAttend = - activity.tags?.[Tag.Attend] && activity.tags[Tag.Attend].length > 0; + thread.tags?.[Tag.Attend] && thread.tags[Tag.Attend].length > 0; const hasSkip = - activity.tags?.[Tag.Skip] && activity.tags[Tag.Skip].length > 0; + thread.tags?.[Tag.Skip] && thread.tags[Tag.Skip].length > 0; const hasUndecided = - activity.tags?.[Tag.Undecided] && - activity.tags[Tag.Undecided].length > 0; + thread.tags?.[Tag.Undecided] && + thread.tags[Tag.Undecided].length > 0; let newStatus: "accepted" | "declined" | "tentativelyAccepted"; @@ -930,15 +930,15 @@ export class OutlookCalendar } // Extract calendar info from metadata - if (!activity.meta) { - console.error("[RSVP Sync] Missing activity metadata", { - activity_id: activity.id, + if (!thread.meta) { + console.error("[RSVP Sync] Missing thread metadata", { + thread_id: thread.id, }); return; } - const baseEventId = activity.meta.id; - const calendarId = activity.meta.calendarId; + const baseEventId = thread.meta.id; + const calendarId = thread.meta.calendarId; if ( !baseEventId || @@ -993,7 +993,7 @@ export class OutlookCalendar await this.tools.integrations.actAs( OutlookCalendar.PROVIDER, actorId, - activity.id, + thread.id, this.syncActorRSVP, calendarId as string, eventId, @@ -1005,7 +1005,7 @@ export class OutlookCalendar console.error("[RSVP Sync] Error in callback", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, - activity_id: activity.id, + thread_id: thread.id, }); } } diff --git a/tools/slack/src/slack-api.ts b/tools/slack/src/slack-api.ts index 5b831e5..8cc8375 100644 --- a/tools/slack/src/slack-api.ts +++ b/tools/slack/src/slack-api.ts @@ -1,6 +1,6 @@ -import { ActivityType } from "@plotday/twister"; +import { ThreadType } from "@plotday/twister"; import type { - NewActivityWithNotes, + NewThreadWithNotes, NewActor, } from "@plotday/twister/plot"; @@ -233,19 +233,19 @@ function formatSlackText(text: string): string { } /** - * Transforms a Slack message thread into a NewActivityWithNotes structure. - * The first message snippet becomes the Activity title, and each message becomes a Note. + * Transforms a Slack message thread into a NewThreadWithNotes structure. + * The first message snippet becomes the Thread title, and each message becomes a Note. */ export function transformSlackThread( messages: SlackMessage[], channelId: string -): NewActivityWithNotes { +): NewThreadWithNotes { const parentMessage = messages[0]; if (!parentMessage) { // Return empty structure for invalid threads return { - type: ActivityType.Note, + type: ThreadType.Note, title: "Empty thread", notes: [], }; @@ -258,10 +258,10 @@ export function transformSlackThread( // Canonical URL using Slack's app_redirect (works across all workspaces) const canonicalUrl = `https://slack.com/app_redirect?channel=${channelId}&message_ts=${threadTs}`; - // Create Activity - const activity: NewActivityWithNotes = { + // Create Thread + const thread: NewThreadWithNotes = { source: canonicalUrl, - type: ActivityType.Note, + type: ThreadType.Note, title, start: new Date(parseFloat(parentMessage.ts) * 1000), meta: { @@ -282,17 +282,17 @@ export function transformSlackThread( // Create NewNote with idempotent key const note = { - activity: { source: canonicalUrl }, + thread: { source: canonicalUrl }, key: message.ts, author: slackUserToNewActor(userId), content: text, mentions: mentions.length > 0 ? mentions : undefined, }; - activity.notes.push(note); + thread.notes.push(note); } - return activity; + return thread; } /** diff --git a/tools/slack/src/slack.ts b/tools/slack/src/slack.ts index 20deff9..eca9039 100644 --- a/tools/slack/src/slack.ts +++ b/tools/slack/src/slack.ts @@ -1,6 +1,6 @@ import { - type ActivityFilter, - type NewActivityWithNotes, + type ThreadFilter, + type NewThreadWithNotes, Serializable, type SyncToolOptions, Tool, @@ -21,7 +21,7 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { - ActivityAccess, + ThreadAccess, ContactAccess, Plot, } from "@plotday/twister/tools/plot"; @@ -75,10 +75,10 @@ import { * async activate() { * const authLink = await this.slack.requestAuth(this.onSlackAuth); * - * await this.plot.createActivity({ - * type: ActivityType.Action, + * await this.plot.createThread({ + * type: ThreadType.Action, * title: "Connect Slack", - * links: [authLink] + * actions: [authLink] * }); * } * @@ -99,9 +99,9 @@ import { * } * } * - * async onSlackThread(thread: ActivityWithNotes) { + * async onSlackThread(thread: NewThreadWithNotes) { * // Process Slack message thread - * // thread contains the Activity with thread.notes containing each message + * // thread contains the Thread with thread.notes containing each message * console.log(`Thread: ${thread.title}`); * console.log(`${thread.notes.length} messages`); * } @@ -140,7 +140,7 @@ export class Slack extends Tool implements MessagingTool { network: build(Network, { urls: ["https://slack.com/api/*"] }), plot: build(Plot, { contact: { access: ContactAccess.Write }, - activity: { access: ActivityAccess.Create }, + thread: { access: ThreadAccess.Create }, }), }; } @@ -167,7 +167,7 @@ export class Slack extends Tool implements MessagingTool { // Create disable callback if parent provided onSyncableDisabled if (this.options.onSyncableDisabled) { - const filter: ActivityFilter = { + const filter: ThreadFilter = { meta: { syncProvider: "slack", syncableId: syncable.id }, }; const disableCallbackToken = await this.tools.callbacks.createFromParent( @@ -252,7 +252,7 @@ export class Slack extends Tool implements MessagingTool { async startSync< TArgs extends Serializable[], - TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { channelId: string; @@ -390,7 +390,7 @@ export class Slack extends Tool implements MessagingTool { for (const thread of threads) { try { - // Transform Slack thread to NewActivityWithNotes + // Transform Slack thread to NewThreadWithNotes const activityThread = transformSlackThread(thread, channelId); if (activityThread.notes.length === 0) continue; diff --git a/twister/src/common/calendar.ts b/twister/src/common/calendar.ts index 00ad218..93aa8ac 100644 --- a/twister/src/common/calendar.ts +++ b/twister/src/common/calendar.ts @@ -1,4 +1,4 @@ -import type { NewActivityWithNotes, Serializable } from "../index"; +import type { NewThreadWithNotes, Serializable } from "../index"; /** * Represents a calendar from an external calendar service. @@ -56,7 +56,7 @@ export type SyncOptions = { * **Architecture: Tools Build, Twists Save** * * Calendar tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) * * This separation allows: @@ -69,17 +69,17 @@ export type SyncOptions = { * 2. Tool declares providers and lifecycle callbacks in build() * 3. onAuthorized lists available calendars and calls setSyncables() * 4. User enables calendars in the modal → onSyncEnabled fires - * 5. **Tool builds NewActivity objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createActivity/updateActivity + * 5. **Tool builds NewThread objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createThread/updateThread * * **Tool Implementation Rules:** - * - **DO** build Activity/Note objects from external data + * - **DO** build Thread/Note objects from external data * - **DO** pass them to the twist via the callback - * - **DON'T** call plot.createActivity/updateActivity directly + * - **DON'T** call plot.createThread/updateThread directly * - **DON'T** save anything to Plot database * * **Recommended Data Sync Strategy:** - * Use Activity.source and Note.key for automatic upserts without manual ID tracking. + * Use Thread.source and Note.key for automatic upserts without manual ID tracking. * See SYNC_STRATEGIES.md for detailed patterns and when to use alternative approaches. * * @example @@ -88,7 +88,7 @@ export type SyncOptions = { * build(build: ToolBuilder) { * return { * googleCalendar: build(GoogleCalendar), - * plot: build(Plot, { activity: { access: ActivityAccess.Create } }), + * plot: build(Plot, { thread: { access: ThreadAccess.Create } }), * }; * } * @@ -120,14 +120,14 @@ export type CalendarTool = { * @param options.calendarId - ID of the calendar to sync * @param options.timeMin - Earliest date to sync events from (inclusive) * @param options.timeMax - Latest date to sync events to (exclusive) - * @param callback - Function receiving (activity, ...extraArgs) for each synced event + * @param callback - Function receiving (thread, ...extraArgs) for each synced event * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete * @throws When no valid authorization or calendar doesn't exist */ startSync< TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { calendarId: string; diff --git a/twister/src/common/documents.ts b/twister/src/common/documents.ts index 40d1306..fd21045 100644 --- a/twister/src/common/documents.ts +++ b/twister/src/common/documents.ts @@ -1,6 +1,6 @@ import type { - ActivityMeta, - NewActivityWithNotes, + ThreadMeta, + NewThreadWithNotes, Serializable, } from "../index"; @@ -36,13 +36,13 @@ export type DocumentSyncOptions = { /** * Base interface for document service integration tools. * - * All synced documents are converted to ActivityWithNotes objects. - * Each document becomes an Activity with Notes for the description and comments. + * All synced documents are converted to ThreadWithNotes objects. + * Each document becomes a Thread with Notes for the description and comments. * * **Architecture: Tools Build, Twists Save** * * Document tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) * * **Implementation Pattern:** @@ -50,20 +50,20 @@ export type DocumentSyncOptions = { * 2. Tool declares providers and lifecycle callbacks in build() * 3. onAuthorized lists available folders and calls setSyncables() * 4. User enables folders in the modal → onSyncEnabled fires - * 5. **Tool builds NewActivity objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createActivity/updateActivity + * 5. **Tool builds NewThread objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createThread/updateThread * * **Recommended Data Sync Strategy:** - * Use Activity.source and Note.key for automatic upserts. + * Use Thread.source and Note.key for automatic upserts. * - * - Set `Activity.source` to `"{provider}:file:{fileId}"` (e.g., `"google-drive:file:abc123"`) + * - Set `Thread.source` to `"{provider}:file:{fileId}"` (e.g., `"google-drive:file:abc123"`) * - Use `Note.key` for document details: * - key: `"summary"` for the document description or metadata summary * - key: `"comment-{commentId}"` for individual comments (unique per comment) * - key: `"reply-{commentId}-{replyId}"` for comment replies * - No manual ID tracking needed - Plot handles deduplication automatically - * - Send NewActivityWithNotes for all documents (creates new or updates existing) - * - Set `activity.unread = false` for initial sync, omit for incremental updates + * - Send NewThreadWithNotes for all documents (creates new or updates existing) + * - Set `thread.unread = false` for initial sync, omit for incremental updates */ export type DocumentTool = { /** @@ -77,20 +77,20 @@ export type DocumentTool = { /** * Begins synchronizing documents from a specific folder. * - * Documents are converted to NewActivityWithNotes objects. + * Documents are converted to NewThreadWithNotes objects. * * Auth is obtained automatically via integrations.get(provider, folderId). * * @param options - Sync configuration options * @param options.folderId - ID of the folder to sync * @param options.timeMin - Earliest date to sync documents from (inclusive) - * @param callback - Function receiving (activity, ...extraArgs) for each synced document + * @param callback - Function receiving (thread, ...extraArgs) for each synced document * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete */ startSync< TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { folderId: string; @@ -111,18 +111,18 @@ export type DocumentTool = { * Adds a comment to a document. * * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to activities back as comments on the external document. + * sync notes added to threads back as comments on the external document. * * Auth is obtained automatically. The tool should extract its own ID * from meta (e.g., fileId). * - * @param meta - Activity metadata containing the tool's document identifier + * @param meta - Thread metadata containing the tool's document identifier * @param body - The comment text content * @param noteId - Optional Plot note ID for deduplication * @returns The external comment key (e.g. "comment-123") for dedup, or void */ addDocumentComment?( - meta: ActivityMeta, + meta: ThreadMeta, body: string, noteId?: string, ): Promise; @@ -133,14 +133,14 @@ export type DocumentTool = { * Auth is obtained automatically. The tool should extract its own ID * from meta (e.g., fileId). * - * @param meta - Activity metadata containing the tool's document identifier + * @param meta - Thread metadata containing the tool's document identifier * @param commentId - The external comment ID to reply to * @param body - The reply text content * @param noteId - Optional Plot note ID for deduplication * @returns The external reply key (e.g. "reply-123-456") for dedup, or void */ addDocumentReply?( - meta: ActivityMeta, + meta: ThreadMeta, commentId: string, body: string, noteId?: string, diff --git a/twister/src/common/messaging.ts b/twister/src/common/messaging.ts index 559ef1b..1f68f3d 100644 --- a/twister/src/common/messaging.ts +++ b/twister/src/common/messaging.ts @@ -1,4 +1,4 @@ -import type { NewActivityWithNotes, Serializable } from "../index"; +import type { NewThreadWithNotes, Serializable } from "../index"; /** * Represents a channel from an external messaging service. @@ -32,13 +32,13 @@ export type MessageSyncOptions = { /** * Base interface for email and chat integration tools. * - * All synced messages/emails are converted to ActivityWithNotes objects. - * Each email thread or chat conversation becomes an Activity with Notes for each message. + * All synced messages/emails are converted to ThreadWithNotes objects. + * Each email thread or chat conversation becomes a Thread with Notes for each message. * * **Architecture: Tools Build, Twists Save** * * Messaging tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) * * **Implementation Pattern:** @@ -46,11 +46,11 @@ export type MessageSyncOptions = { * 2. Tool declares providers and lifecycle callbacks in build() * 3. onAuthorized lists available channels and calls setSyncables() * 4. User enables channels in the modal → onSyncEnabled fires - * 5. **Tool builds NewActivity objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createActivity/updateActivity + * 5. **Tool builds NewThread objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createThread/updateThread * * **Recommended Data Sync Strategy:** - * Use Activity.source (thread URL or ID) and Note.key (message ID) for automatic upserts. + * Use Thread.source (thread URL or ID) and Note.key (message ID) for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ export type MessagingTool = { @@ -76,7 +76,7 @@ export type MessagingTool = { */ startSync< TArgs extends Serializable[], - TCallback extends (thread: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { channelId: string; diff --git a/twister/src/common/projects.ts b/twister/src/common/projects.ts index 47bdb45..a2215e4 100644 --- a/twister/src/common/projects.ts +++ b/twister/src/common/projects.ts @@ -1,7 +1,7 @@ import type { - Activity, - ActivityMeta, - NewActivityWithNotes, + Thread, + ThreadMeta, + NewThreadWithNotes, Serializable, } from "../index"; @@ -37,13 +37,13 @@ export type ProjectSyncOptions = { /** * Base interface for project management integration tools. * - * All synced issues/tasks are converted to ActivityWithNotes objects. - * Each issue becomes an Activity with Notes for the description and comments. + * All synced issues/tasks are converted to ThreadWithNotes objects. + * Each issue becomes a Thread with Notes for the description and comments. * * **Architecture: Tools Build, Twists Save** * * Project tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) * * **Implementation Pattern:** @@ -51,11 +51,11 @@ export type ProjectSyncOptions = { * 2. Tool declares providers and lifecycle callbacks in build() * 3. onAuthorized lists available projects and calls setSyncables() * 4. User enables projects in the modal → onSyncEnabled fires - * 5. **Tool builds NewActivity objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createActivity/updateActivity + * 5. **Tool builds NewThread objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createThread/updateThread * * **Recommended Data Sync Strategy:** - * Use Activity.source (issue URL) and Note.key for automatic upserts. + * Use Thread.source (issue URL) and Note.key for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ export type ProjectTool = { @@ -75,13 +75,13 @@ export type ProjectTool = { * @param options - Sync configuration options * @param options.projectId - ID of the project to sync * @param options.timeMin - Earliest date to sync issues from (inclusive) - * @param callback - Function receiving (activity, ...extraArgs) for each synced issue + * @param callback - Function receiving (thread, ...extraArgs) for each synced issue * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete */ startSync< TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { projectId: string; @@ -102,32 +102,32 @@ export type ProjectTool = { * Updates an issue/task with new values. * * Optional method for bidirectional sync. When implemented, allows Plot to - * sync activity updates back to the external service. + * sync thread updates back to the external service. * * Auth is obtained automatically via integrations.get(provider, projectId) - * using the projectId from activity.meta. + * using the projectId from thread.meta. * - * @param activity - The updated activity + * @param thread - The updated thread * @returns Promise that resolves when the update is synced */ - updateIssue?(activity: Activity): Promise; + updateIssue?(thread: Thread): Promise; /** * Adds a comment to an issue/task. * * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to activities back as comments on the external service. + * sync notes added to threads back as comments on the external service. * * Auth is obtained automatically. The tool should extract its own ID * from meta (e.g., linearId, taskGid, issueKey). * - * @param meta - Activity metadata containing the tool's issue/task identifier + * @param meta - Thread metadata containing the tool's issue/task identifier * @param body - The comment text content * @param noteId - Optional Plot note ID, used by tools that support comment metadata (e.g. Jira) * @returns The external comment key (e.g. "comment-123") for dedup, or void */ addIssueComment?( - meta: ActivityMeta, + meta: ThreadMeta, body: string, noteId?: string, ): Promise; diff --git a/twister/src/common/source-control.ts b/twister/src/common/source-control.ts index a8996fb..cd78b34 100644 --- a/twister/src/common/source-control.ts +++ b/twister/src/common/source-control.ts @@ -1,7 +1,7 @@ import type { - Activity, - ActivityMeta, - NewActivityWithNotes, + Thread, + ThreadMeta, + NewThreadWithNotes, Serializable, } from "../index"; @@ -43,14 +43,14 @@ export type SourceControlSyncOptions = { /** * Base interface for source control integration tools. * - * All synced pull requests are converted to ActivityWithNotes objects. - * Each PR becomes an Activity with Notes for the description, comments, + * All synced pull requests are converted to ThreadWithNotes objects. + * Each PR becomes a Thread with Notes for the description, comments, * and review summaries. * * **Architecture: Tools Build, Twists Save** * * Source control tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewActivity objects) + * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) * * **Implementation Pattern:** @@ -58,11 +58,11 @@ export type SourceControlSyncOptions = { * 2. Tool declares providers and lifecycle callbacks in build() * 3. onAuthorized lists available repositories and calls setSyncables() * 4. User enables repositories in the modal → onSyncEnabled fires - * 5. **Tool builds NewActivity objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createActivity/updateActivity + * 5. **Tool builds NewThread objects** and passes them to the twist via callback + * 6. **Twist decides** whether to save using createThread/updateThread * * **Recommended Data Sync Strategy:** - * Use Activity.source (PR URL) and Note.key for automatic upserts. + * Use Thread.source (PR URL) and Note.key for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ export type SourceControlTool = { @@ -82,13 +82,13 @@ export type SourceControlTool = { * @param options - Sync configuration options * @param options.repositoryId - ID of the repository to sync (owner/repo format) * @param options.timeMin - Earliest date to sync PRs from (inclusive) - * @param callback - Function receiving (activity, ...extraArgs) for each synced PR + * @param callback - Function receiving (thread, ...extraArgs) for each synced PR * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete */ startSync< TArgs extends Serializable[], - TCallback extends (activity: NewActivityWithNotes, ...args: TArgs) => any + TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any >( options: { repositoryId: string; @@ -109,18 +109,18 @@ export type SourceControlTool = { * Adds a general comment to a pull request. * * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to activities back as comments on the external service. + * sync notes added to threads back as comments on the external service. * * Auth is obtained automatically. The tool should extract its own ID * from meta (e.g., prNumber, owner, repo). * - * @param meta - Activity metadata containing the tool's PR identifier + * @param meta - Thread metadata containing the tool's PR identifier * @param body - The comment text content * @param noteId - Optional Plot note ID for dedup * @returns The external comment key (e.g. "comment-123") for dedup, or void */ addPRComment?( - meta: ActivityMeta, + meta: ThreadMeta, body: string, noteId?: string, ): Promise; @@ -129,20 +129,20 @@ export type SourceControlTool = { * Updates a pull request's review status (approve, request changes). * * Optional method for bidirectional sync. When implemented, allows Plot to - * sync activity status changes back to the external service. + * sync thread status changes back to the external service. * - * @param activity - The updated activity with review status + * @param thread - The updated thread with review status * @returns Promise that resolves when the update is synced */ - updatePRStatus?(activity: Activity): Promise; + updatePRStatus?(thread: Thread): Promise; /** * Closes a pull request without merging. * * Optional method for bidirectional sync. * - * @param meta - Activity metadata containing the tool's PR identifier + * @param meta - Thread metadata containing the tool's PR identifier * @returns Promise that resolves when the PR is closed */ - closePR?(meta: ActivityMeta): Promise; + closePR?(meta: ThreadMeta): Promise; }; diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 21c53d7..9172c69 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -11,15 +11,15 @@ export { type AuthProvider } from "./tools/integrations"; /** * @fileoverview - * Core Plot entity types for working with activities, notes, priorities, and contacts. + * Core Plot entity types for working with threads, notes, priorities, and contacts. * * ## Type Pattern: Null vs Undefined Semantics * * Plot entity types use a consistent pattern to distinguish between missing, unset, and explicitly cleared values: * - * ### Entity Types (Activity, Priority, Note, Actor) + * ### Entity Types (Thread, Priority, Note, Actor) * - **Required fields**: No `?`, cannot be `undefined` - * - Example: `id: Uuid`, `type: ActivityType` + * - Example: `id: Uuid`, `type: ThreadType` * - **Nullable fields**: Use `| null` to allow explicit clearing * - Example: `assignee: ActorId | null`, `done: Date | null` * - `null` = field is explicitly unset/cleared @@ -30,10 +30,10 @@ export { type AuthProvider } from "./tools/integrations"; * - `null` = field included but not set * - Value = field has a value * - * ### New* Types (NewActivity, NewNote, NewPriority) + * ### New* Types (NewThread, NewNote, NewPriority) * Used for creating or updating entities. Support partial updates by distinguishing omitted vs cleared fields: * - **Required fields**: Must be provided (no `?`) - * - Example: `type: ActivityType` in NewActivity + * - Example: `type: ThreadType` in NewThread * - **Optional fields**: Use `?` to make them optional * - Example: `title?: string`, `author?: NewActor` * - `undefined` (omitted) = don't set/update this field @@ -51,17 +51,17 @@ export { type AuthProvider } from "./tools/integrations"; * * @example * ```typescript - * // Creating a new activity - * const newActivity: NewActivity = { - * type: ActivityType.Action, // Required + * // Creating a new thread + * const newThread: NewThread = { + * type: ThreadType.Action, // Required * title: "Review PR", // Optional, provided * assignee: null, // Optional nullable, explicitly clearing * // priority is omitted (undefined), will auto-select or use default * }; * - * // Updating an activity - only change what's specified - * const update: ActivityUpdate = { - * id: activityId, + * // Updating a thread - only change what's specified + * const update: ThreadUpdate = { + * id: threadId, * done: new Date(), // Mark as done * assignee: null, // Clear assignee * // title is omitted, won't be changed @@ -168,12 +168,12 @@ export type PriorityUpdate = ({ id: Uuid } | { key: string }) & Partial>; /** - * Enumeration of supported activity types in Plot. + * Enumeration of supported thread types in Plot. * - * Each activity type has different behaviors and rendering characteristics + * Each thread type has different behaviors and rendering characteristics * within the Plot application. */ -export enum ActivityType { +export enum ThreadType { /** A note or piece of information without actionable requirements */ Note, /** An actionable item that can be completed */ @@ -182,10 +182,15 @@ export enum ActivityType { Event, } +/** @deprecated Use ThreadType instead */ +export const ActivityType = ThreadType; +/** @deprecated Use ThreadType instead */ +export type ActivityType = ThreadType; + /** - * Kinds of activities. Used only for visual categorization (icon). + * Kinds of threads. Used only for visual categorization (icon). */ -export enum ActivityKind { +export enum ThreadKind { document = "document", // any external document or item in an external system messages = "messages", // emails and chat threads meeting = "meeting", // in-person meeting @@ -201,17 +206,17 @@ export enum ActivityKind { } /** - * Enumeration of supported activity link types. + * Enumeration of supported action types. * - * Different link types have different behaviors when clicked by users + * Different action types have different behaviors when clicked by users * and may require different rendering approaches. */ -export enum LinkType { +export enum ActionType { /** External web links that open in browser */ external = "external", /** Authentication flows for connecting services */ auth = "auth", - /** Callback links that trigger twist methods when clicked */ + /** Callback actions that trigger twist methods when clicked */ callback = "callback", /** Video conferencing links with provider-specific handling */ conferencing = "conferencing", @@ -219,6 +224,11 @@ export enum LinkType { file = "file", } +/** @deprecated Use ActionType instead */ +export const LinkType = ActionType; +/** @deprecated Use ActionType instead */ +export type LinkType = ActionType; + /** * Video conferencing providers for conferencing links. * @@ -239,64 +249,64 @@ export enum ConferencingProvider { } /** - * Represents a clickable link attached to an activity. + * Represents a clickable action attached to a thread. * - * Activity links are rendered as buttons that enable user interaction with activities. - * Different link types have specific behaviors and required fields for proper functionality. + * Thread actions are rendered as buttons that enable user interaction with threads. + * Different action types have specific behaviors and required fields for proper functionality. * * @example * ```typescript - * // External link - opens URL in browser - * const externalLink: Link = { - * type: LinkType.external, + * // External action - opens URL in browser + * const externalAction: Action = { + * type: ActionType.external, * title: "Open in Google Calendar", * url: "https://calendar.google.com/event/123", * }; * - * // Conferencing link - opens video conference with provider info - * const conferencingLink: Link = { - * type: LinkType.conferencing, + * // Conferencing action - opens video conference with provider info + * const conferencingAction: Action = { + * type: ActionType.conferencing, * url: "https://meet.google.com/abc-defg-hij", * provider: ConferencingProvider.googleMeet, * }; * - * // Integrations link - initiates OAuth flow - * const authLink: Link = { - * type: LinkType.auth, + * // Integrations action - initiates OAuth flow + * const authAction: Action = { + * type: ActionType.auth, * title: "Continue with Google", * provider: AuthProvider.Google, * scopes: ["https://www.googleapis.com/auth/calendar.readonly"], * callback: "callback-token-for-auth-completion" * }; * - * // Callback link - triggers a twist method - * const callbackLink: Link = { - * type: LinkType.callback, + * // Callback action - triggers a twist method + * const callbackAction: Action = { + * type: ActionType.callback, * title: "📅 Primary Calendar", * token: "callback-token-here" * }; * ``` */ -export type Link = +export type Action = | { /** External web link that opens in browser */ - type: LinkType.external; - /** Display text for the link button */ + type: ActionType.external; + /** Display text for the action button */ title: string; /** URL to open when clicked */ url: string; } | { - /** Video conferencing link with provider-specific handling */ - type: LinkType.conferencing; + /** Video conferencing action with provider-specific handling */ + type: ActionType.conferencing; /** URL to join the conference */ url: string; /** Conferencing provider for UI customization */ provider: ConferencingProvider; } | { - /** Authentication link that initiates an OAuth flow */ - type: LinkType.auth; + /** Authentication action that initiates an OAuth flow */ + type: ActionType.auth; /** Display text for the auth button */ title: string; /** OAuth provider (e.g., "google", "microsoft") */ @@ -307,16 +317,16 @@ export type Link = callback: Callback; } | { - /** Callback link that triggers a twist method when clicked */ - type: LinkType.callback; + /** Callback action that triggers a twist method when clicked */ + type: ActionType.callback; /** Display text for the callback button */ title: string; /** Token identifying the callback to execute */ callback: Callback; } | { - /** File attachment link stored in R2 */ - type: LinkType.file; + /** File attachment action stored in R2 */ + type: ActionType.file; /** Unique identifier for the stored file */ fileId: string; /** Original filename */ @@ -328,9 +338,9 @@ export type Link = }; /** - * Represents metadata about an activity, typically from an external system. + * Represents metadata about a thread, typically from an external system. * - * Activity metadata enables storing additional information about activities, + * Thread metadata enables storing additional information about threads, * which is useful for synchronization, linking back to external systems, * and storing tool-specific data. * @@ -340,8 +350,8 @@ export type Link = * @example * ```typescript * // Calendar event metadata - * await plot.createActivity({ - * type: ActivityType.Event, + * await plot.createThread({ + * type: ThreadType.Event, * title: "Team Meeting", * start: new Date("2024-01-15T10:00:00Z"), * meta: { @@ -352,8 +362,8 @@ export type Link = * }); * * // Project issue metadata - * await plot.createActivity({ - * type: ActivityType.Action, + * await plot.createThread({ + * type: ThreadType.Action, * title: "Fix login bug", * meta: { * projectId: "TEAM", @@ -363,11 +373,14 @@ export type Link = * }); * ``` */ -export type ActivityMeta = { +export type ThreadMeta = { /** Source-specific properties and metadata */ [key: string]: JSONValue; }; +/** @deprecated Use ThreadMeta instead */ +export type ActivityMeta = ThreadMeta; + /** * Tags on an item, along with the actors who added each tag. */ @@ -379,38 +392,38 @@ export type Tags = { [K in Tag]?: ActorId[] }; export type NewTags = { [K in Tag]?: NewActor[] }; /** - * Common fields shared by both Activity and Note entities. + * Common fields shared by both Thread and Note entities. */ -export type ActivityCommon = { - /** Unique identifier for the activity */ +export type ThreadCommon = { + /** Unique identifier for the thread */ id: Uuid; /** - * When this activity was originally created in its source system. + * When this thread was originally created in its source system. * - * For activities created in Plot, this is when the user created it. - * For activities synced from external systems (GitHub issues, emails, calendar events), + * For threads created in Plot, this is when the user created it. + * For threads synced from external systems (GitHub issues, emails, calendar events), * this is the original creation time in that system. * - * Defaults to the current time when creating new activities. + * Defaults to the current time when creating new threads. */ created: Date; - /** Information about who created the activity */ + /** Information about who created the thread */ author: Actor; - /** Whether this activity is private (only visible to author) */ + /** Whether this thread is private (only visible to author) */ private: boolean; - /** Whether this activity has been archived */ + /** Whether this thread has been archived */ archived: boolean; - /** Tags attached to this activity. Maps tag ID to array of actor IDs who added that tag. */ + /** Tags attached to this thread. Maps tag ID to array of actor IDs who added that tag. */ tags: Tags; - /** Array of actor IDs (users, contacts, or twists) mentioned in this activity via @-mentions */ + /** Array of actor IDs (users, contacts, or twists) mentioned in this thread via @-mentions */ mentions: ActorId[]; }; /** - * Common fields shared by all activity types (Note, Action, Event). + * Common fields shared by all thread types (Note, Action, Event). * Does not include the discriminant `type` field or type-specific fields like `done`. */ -type ActivityFields = ActivityCommon & { +type ThreadFields = ThreadCommon & { /** * Globally unique, stable identifier for the item in an external system. * MUST use immutable system-generated IDs, not human-readable slugs or titles. @@ -426,15 +439,15 @@ type ActivityFields = ActivityCommon & { * - Bad: `https://linear.app/team/issue/TEAM-123/title` (team and title can change) * - Bad: `jira:issue:PROJECT-42` (issue key can change) * - * When set, uniquely identifies the activity within a priority tree for upsert operations. + * When set, uniquely identifies the thread within a priority tree for upsert operations. */ source: string | null; - /** The display title/summary of the activity */ + /** The display title/summary of the thread */ title: string; - /** Optional kind for additional categorization within the activity */ - kind: ActivityKind | null; + /** Optional kind for additional categorization within the thread */ + kind: ThreadKind | null; /** - * The actor assigned to this activity. + * The actor assigned to this thread. * * **For actions (tasks):** * - If not provided (undefined), defaults to the user who installed the twist (twist owner) @@ -442,28 +455,28 @@ type ActivityFields = ActivityCommon & { * - For synced tasks from external systems, typically set `assignee: null` for unassigned items * * **For notes and events:** Assignee is optional and typically null. - * When marking an activity as done, it becomes an Action; if no assignee is set, + * When marking a thread as done, it becomes an Action; if no assignee is set, * the twist owner is assigned automatically. * * @example * ```typescript * // Create action assigned to twist owner (default behavior) - * const task: NewActivity = { - * type: ActivityType.Action, + * const task: NewThread = { + * type: ThreadType.Action, * title: "Follow up on email" * // assignee omitted → defaults to twist owner * }; * * // Create UNASSIGNED action (for backlog items) - * const backlogTask: NewActivity = { - * type: ActivityType.Action, + * const backlogTask: NewThread = { + * type: ThreadType.Action, * title: "Review PR #123", * assignee: null // Explicitly set to null * }; * * // Create action with explicit assignee - * const assignedTask: NewActivity = { - * type: ActivityType.Action, + * const assignedTask: NewThread = { + * type: ThreadType.Action, * title: "Deploy to production", * assignee: { * id: userId as ActorId, @@ -475,11 +488,11 @@ type ActivityFields = ActivityCommon & { */ assignee: Actor | null; /** - * Start time of a scheduled activity. Notes are not typically scheduled unless they're about specific times. + * Start time of a scheduled thread. Notes are not typically scheduled unless they're about specific times. * For recurring events, this represents the start of the first occurrence. * Can be a Date object for timed events or a date string in "YYYY-MM-DD" format for all-day events. * - * **Activity Scheduling States** (for Actions): + * **Thread Scheduling States** (for Actions): * - **Do Now** (current/actionable): When creating an Action, omitting `start` defaults to current time * - **Do Later** (future scheduled): Set `start` to a future Date or date string * - **Do Someday** (unscheduled backlog): Explicitly set `start: null` @@ -490,23 +503,23 @@ type ActivityFields = ActivityCommon & { * @example * ```typescript * // "Do Now" - assigned to twist owner, actionable immediately - * await this.tools.plot.createActivity({ - * type: ActivityType.Action, + * await this.tools.plot.createThread({ + * type: ThreadType.Action, * title: "Urgent task" * // start omitted → defaults to now * // assignee omitted → defaults to twist owner * }); * * // "Do Later" - scheduled for a specific time - * await this.tools.plot.createActivity({ - * type: ActivityType.Action, + * await this.tools.plot.createThread({ + * type: ThreadType.Action, * title: "Future task", * start: new Date("2025-02-01") * }); * * // "Do Someday" - unassigned backlog item (common for synced tasks) - * await this.tools.plot.createActivity({ - * type: ActivityType.Action, + * await this.tools.plot.createThread({ + * type: ThreadType.Action, * title: "Backlog task", * start: null, // Explicitly unscheduled * assignee: null // Explicitly unassigned @@ -515,65 +528,74 @@ type ActivityFields = ActivityCommon & { */ start: Date | string | null; /** - * End time of a scheduled activity. Notes are not typically scheduled unless they're about specific times. + * End time of a scheduled thread. Notes are not typically scheduled unless they're about specific times. * For recurring events, this represents the end of the first occurrence. * Can be a Date object for timed events or a date string in "YYYY-MM-DD" format for all-day events. - * Null for tasks or activities without defined end times. + * Null for tasks or threads without defined end times. */ end: Date | string | null; /** - * For recurring activities, the last occurrence date (inclusive). + * For recurring threads, the last occurrence date (inclusive). * Can be a Date object, date string in "YYYY-MM-DD" format, or null if recurring indefinitely. * When both recurrenceCount and recurrenceUntil are provided, recurrenceCount takes precedence. */ recurrenceUntil: Date | string | null; /** - * For recurring activities, the number of occurrences to generate. + * For recurring threads, the number of occurrences to generate. * Takes precedence over recurrenceUntil if both are provided. - * Null for non-recurring activities or indefinite recurrence. + * Null for non-recurring threads or indefinite recurrence. */ recurrenceCount: number | null; - /** The priority context this activity belongs to */ + /** The priority context this thread belongs to */ priority: Priority; /** Recurrence rule in RFC 5545 RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") */ recurrenceRule: string | null; /** Array of dates to exclude from the recurrence pattern */ recurrenceExdates: Date[] | null; - /** Metadata about the activity, typically from an external system that created it */ - meta: ActivityMeta | null; - /** Sort order for the activity (fractional positioning) */ + /** Metadata about the thread, typically from an external system that created it */ + meta: ThreadMeta | null; + /** Sort order for the thread (fractional positioning) */ order: number; - /** Array of interactive links attached to the activity (external, conferencing, callback) */ - links: Array | null; + /** Array of interactive actions attached to the thread (external, conferencing, callback) */ + actions: Array | null; }; -export type Activity = ActivityFields & +export type Thread = ThreadFields & ( - | { type: ActivityType.Note } + | { type: ThreadType.Note } | { - type: ActivityType.Action; + type: ThreadType.Action; /** - * Timestamp when the activity was marked as complete. Null if not completed. + * Timestamp when the thread was marked as complete. Null if not completed. */ done: Date | null; } - | { type: ActivityType.Event } + | { type: ThreadType.Event } ); -export type ActivityWithNotes = Activity & { +/** @deprecated Use Thread instead */ +export type Activity = Thread; + +export type ThreadWithNotes = Thread & { notes: Note[]; }; -export type NewActivityWithNotes = NewActivity & { - notes: Omit[]; +/** @deprecated Use ThreadWithNotes instead */ +export type ActivityWithNotes = ThreadWithNotes; + +export type NewThreadWithNotes = NewThread & { + notes: Omit[]; }; +/** @deprecated Use NewThreadWithNotes instead */ +export type NewActivityWithNotes = NewThreadWithNotes; + /** - * Represents a specific instance of a recurring activity. - * All field values are computed by merging the recurring activity's + * Represents a specific instance of a recurring thread. + * All field values are computed by merging the recurring thread's * defaults with any occurrence-specific overrides. */ -export type ActivityOccurrence = { +export type ThreadOccurrence = { /** * Original date/datetime of this occurrence. * Use start for the occurrence's current start time. @@ -582,9 +604,9 @@ export type ActivityOccurrence = { occurrence: Date | string; /** - * The recurring activity of which this is an occurrence. + * The recurring thread of which this is an occurrence. */ - activity: Activity; + thread: Thread; /** * Effective values for this occurrence (series defaults + overrides). @@ -597,7 +619,7 @@ export type ActivityOccurrence = { /** * Meta is merged, with the occurrence's meta taking precedence. */ - meta: ActivityMeta | null; + meta: ThreadMeta | null; /** * Tags for this occurrence (merged with the recurring tags). @@ -610,18 +632,21 @@ export type ActivityOccurrence = { archived: boolean; }; +/** @deprecated Use ThreadOccurrence instead */ +export type ActivityOccurrence = ThreadOccurrence; + /** - * Type for creating or updating activity occurrences. + * Type for creating or updating thread occurrences. * - * Follows the same pattern as Activity/NewActivity: + * Follows the same pattern as Thread/NewThread: * - Required fields: `occurrence` (key) and `start` (for scheduling) - * - Optional fields: All others from ActivityOccurrence + * - Optional fields: All others from ThreadOccurrence * - Additional fields: `twistTags` for add/remove, `unread` for notification control * * @example * ```typescript - * const activity: NewActivity = { - * type: ActivityType.Event, + * const thread: NewThread = { + * type: ThreadType.Event, * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", * occurrences: [ * { @@ -633,12 +658,12 @@ export type ActivityOccurrence = { * }; * ``` */ -export type NewActivityOccurrence = Pick< - ActivityOccurrence, +export type NewThreadOccurrence = Pick< + ThreadOccurrence, "occurrence" | "start" > & Partial< - Omit + Omit > & { /** * Tags specific to this occurrence. @@ -665,27 +690,33 @@ export type NewActivityOccurrence = Pick< unread?: boolean; }; +/** @deprecated Use NewThreadOccurrence instead */ +export type NewActivityOccurrence = NewThreadOccurrence; + /** - * Inline type for creating/updating occurrences within NewActivity/ActivityUpdate. - * Used to specify occurrence-specific overrides when creating or updating a recurring activity. + * Inline type for creating/updating occurrences within NewThread/ThreadUpdate. + * Used to specify occurrence-specific overrides when creating or updating a recurring thread. */ -export type ActivityOccurrenceUpdate = Pick< - NewActivityOccurrence, +export type ThreadOccurrenceUpdate = Pick< + NewThreadOccurrence, "occurrence" > & - Partial>; + Partial>; + +/** @deprecated Use ThreadOccurrenceUpdate instead */ +export type ActivityOccurrenceUpdate = ThreadOccurrenceUpdate; /** - * Configuration for automatic priority selection based on activity similarity. + * Configuration for automatic priority selection based on thread similarity. * - * Maps activity fields to scoring weights or required exact matches: + * Maps thread fields to scoring weights or required exact matches: * - Number value: Maximum score for similarity matching on this field - * - `true` value: Required exact match - activities must match exactly or be excluded + * - `true` value: Required exact match - threads must match exactly or be excluded * * Scoring rules: - * - content: Uses vector similarity on activity embedding (cosine similarity) - * - type: Exact match on ActivityType - * - mentions: Percentage of existing activity's mentions that appear in new activity + * - content: Uses vector similarity on thread embedding (cosine similarity) + * - type: Exact match on ThreadType + * - mentions: Percentage of existing thread's mentions that appear in new thread * - meta.field: Exact match on top-level meta fields (e.g., "meta.sourceId") * * When content is `true`, applies a strong similarity threshold to ensure only close matches. @@ -711,16 +742,16 @@ export type PickPriorityConfig = { }; /** - * Type for creating new activities. + * Type for creating new threads. * - * Requires only the activity type, with all other fields optional. + * Requires only the thread type, with all other fields optional. * The author will be automatically assigned by the Plot system based on * the current execution context. The ID can be optionally provided by * tools for tracking and update detection purposes. * * **Important: Defaults for Actions** * - * When creating an Activity of type `Action`: + * When creating a Thread of type `Action`: * - **`start` omitted** → Defaults to current time (now) → "Do Now" * - **`assignee` omitted** → Defaults to twist owner → Assigned action * @@ -741,61 +772,61 @@ export type PickPriorityConfig = { * @example * ```typescript * // "Do Now" - Assigned to twist owner, actionable immediately - * const urgentTask: NewActivity = { - * type: ActivityType.Action, + * const urgentTask: NewThread = { + * type: ThreadType.Action, * title: "Review pull request" * // start omitted → defaults to now * // assignee omitted → defaults to twist owner * }; * * // "Do Someday" - UNASSIGNED backlog item (for synced tasks) - * const backlogTask: NewActivity = { - * type: ActivityType.Action, + * const backlogTask: NewThread = { + * type: ThreadType.Action, * title: "Refactor user service", * start: null, // Must explicitly set to null * assignee: null // Must explicitly set to null * }; * * // "Do Later" - Scheduled for specific date - * const futureTask: NewActivity = { - * type: ActivityType.Action, + * const futureTask: NewThread = { + * type: ThreadType.Action, * title: "Prepare Q1 review", * start: new Date("2025-03-15") * }; * * // Note (typically unscheduled) - * const note: NewActivity = { - * type: ActivityType.Note, + * const note: NewThread = { + * type: ThreadType.Note, * title: "Meeting notes", * content: "Discussed Q4 roadmap...", * start: null // Notes typically don't have scheduled times * }; * * // Event (always has explicit start/end times) - * const event: NewActivity = { - * type: ActivityType.Event, + * const event: NewThread = { + * type: ThreadType.Event, * title: "Team standup", * start: new Date("2025-01-15T10:00:00"), * end: new Date("2025-01-15T10:30:00") * }; * ``` */ -export type NewActivity = ( - | { type: ActivityType.Note; done?: never } - | { type: ActivityType.Action; done?: Date | null } - | { type: ActivityType.Event; done?: never } +export type NewThread = ( + | { type: ThreadType.Note; done?: never } + | { type: ThreadType.Action; done?: Date | null } + | { type: ThreadType.Event; done?: never } ) & Partial< Omit< - ActivityFields, + ThreadFields, "author" | "assignee" | "priority" | "tags" | "mentions" | "id" | "source" > > & ( | { /** - * Unique identifier for the activity, generated by Uuid.Generate(). - * Specifying an ID allows tools to track and upsert activities. + * Unique identifier for the thread, generated by Uuid.Generate(). + * Specifying an ID allows tools to track and upsert threads. */ id: Uuid; } @@ -803,7 +834,7 @@ export type NewActivity = ( /** * Canonical URL for the item in an external system. * For example, https://acme.atlassian.net/browse/PROJ-42 could represent a Jira issue. - * When set, it uniquely identifies the activity within a priority tree. This performs + * When set, it uniquely identifies the thread within a priority tree. This performs * an upsert. */ source: string; @@ -833,16 +864,16 @@ export type NewActivity = ( assignee?: NewActor | null; /** - * All tags to set on the new activity. + * All tags to set on the new thread. */ tags?: NewTags; /** - * Whether the activity should be marked as unread for users. - * - undefined/omitted (default): Activity is unread for users, except auto-marked + * Whether the thread should be marked as unread for users. + * - undefined/omitted (default): Thread is unread for users, except auto-marked * as read for the author if they are the twist owner (user) - * - true: Activity is explicitly unread for ALL users (use sparingly) - * - false: Activity is marked as read for all users in the priority at creation time + * - true: Thread is explicitly unread for ALL users (use sparingly) + * - false: Thread is marked as read for all users in the priority at creation time * * For the default behavior, omit this field entirely. * Use false for initial sync to avoid marking historical items as unread. @@ -850,30 +881,30 @@ export type NewActivity = ( unread?: boolean; /** - * Whether the activity is archived. - * - true: Archive the activity - * - false: Unarchive the activity + * Whether the thread is archived. + * - true: Archive the thread + * - false: Unarchive the thread * - undefined (default): Preserve current archive state * - * Best practice: Set to false during initial syncs to ensure activities + * Best practice: Set to false during initial syncs to ensure threads * are unarchived. Omit during incremental syncs to preserve user's choice. */ archived?: boolean; /** - * Optional preview content for the activity. Can be Markdown formatted. + * Optional preview content for the thread. Can be Markdown formatted. * The preview will be automatically generated from this content (truncated to 100 chars). * * - string: Use this content for preview generation * - null: Explicitly disable preview (no preview will be shown) * - undefined (default): Fall back to legacy behavior (generate from first note with content) * - * This field is write-only and won't be returned when reading activities. + * This field is write-only and won't be returned when reading threads. */ preview?: string | null; /** - * Create or update specific occurrences of a recurring activity. + * Create or update specific occurrences of a recurring thread. * Each entry specifies overrides for a specific occurrence. * * When occurrence matches the recurrence rule but only tags are specified, @@ -884,8 +915,8 @@ export type NewActivity = ( * @example * ```typescript * // Create recurring event with per-occurrence RSVPs - * const meeting: NewActivity = { - * type: ActivityType.Event, + * const meeting: NewThread = { + * type: ThreadType.Event, * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", * start: new Date("2025-01-20T14:00:00Z"), * duration: 1800000, // 30 minutes @@ -903,7 +934,7 @@ export type NewActivity = ( * }; * ``` */ - occurrences?: NewActivityOccurrence[]; + occurrences?: NewThreadOccurrence[]; /** * Dates to add to the recurrence exclusion list. @@ -919,37 +950,43 @@ export type NewActivity = ( removeRecurrenceExdates?: Date[]; }; -export type ActivityFilter = { +/** @deprecated Use NewThread instead */ +export type NewActivity = NewThread; + +export type ThreadFilter = { type?: ActorType; meta?: { [key: string]: JSONValue; }; }; +/** @deprecated Use ThreadFilter instead */ +export type ActivityFilter = ThreadFilter; + /** * Fields supported by bulk updates via `match`. Only simple scalar fields - * that can be applied uniformly across many activities are included. + * that can be applied uniformly across many threads are included. */ -type ActivityBulkUpdateFields = Partial< - Pick +type ThreadBulkUpdateFields = Partial< + Pick > & { - /** Update the type of all matching activities. */ - type?: ActivityType; + /** Update the type of all matching threads. */ + type?: ThreadType; /** - * Timestamp when the activities were marked as complete. Null to clear. + * Timestamp when the threads were marked as complete. Null to clear. * Setting done will automatically set the type to Action if not already. */ done?: Date | null; }; /** - * Fields supported by single-activity updates via `id` or `source`. + * Fields supported by single-thread updates via `id` or `source`. * Includes all bulk fields plus scheduling, recurrence, tags, and occurrences. */ -type ActivitySingleUpdateFields = ActivityBulkUpdateFields & +type ThreadSingleUpdateFields = ThreadBulkUpdateFields & Partial< Pick< - ActivityFields, + ThreadFields, | "start" | "end" | "assignee" @@ -960,7 +997,7 @@ type ActivitySingleUpdateFields = ActivityBulkUpdateFields & > > & { /** - * Tags to change on the activity. Use an empty array of NewActor to remove a tag. + * Tags to change on the thread. Use an empty array of NewActor to remove a tag. * Use twistTags to add/remove the twist from tags to avoid clearing other actors' tags. */ tags?: NewTags; @@ -968,24 +1005,24 @@ type ActivitySingleUpdateFields = ActivityBulkUpdateFields & /** * 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 threads the twist has access to. */ twistTags?: Partial>; /** - * Optional preview content for the activity. Can be Markdown formatted. + * Optional preview content for the thread. Can be Markdown formatted. * The preview will be automatically generated from this content (truncated to 100 chars). * * - string: Use this content for preview generation * - null: Explicitly disable preview (no preview will be shown) * - undefined (omitted): Preserve current preview value * - * This field is write-only and won't be returned when reading activities. + * This field is write-only and won't be returned when reading threads. */ preview?: string | null; /** - * Create or update specific occurrences of this recurring activity. + * Create or update specific occurrences of this recurring thread. * Each entry specifies overrides for a specific occurrence. * * Setting a field to null reverts it to the series default. @@ -994,7 +1031,7 @@ type ActivitySingleUpdateFields = ActivityBulkUpdateFields & * @example * ```typescript * // Update RSVPs for specific occurrences - * await plot.updateActivity({ + * await plot.updateThread({ * id: meetingId, * occurrences: [ * { @@ -1013,7 +1050,7 @@ type ActivitySingleUpdateFields = ActivityBulkUpdateFields & * }); * ``` */ - occurrences?: (NewActivityOccurrence | ActivityOccurrenceUpdate)[]; + occurrences?: (NewThreadOccurrence | ThreadOccurrenceUpdate)[]; /** * Dates to add to the recurrence exclusion list. @@ -1029,25 +1066,28 @@ type ActivitySingleUpdateFields = ActivityBulkUpdateFields & removeRecurrenceExdates?: Date[]; }; -export type ActivityUpdate = - | (({ id: Uuid } | { source: string }) & ActivitySingleUpdateFields) +export type ThreadUpdate = + | (({ id: Uuid } | { source: string }) & ThreadSingleUpdateFields) | ({ /** - * Update all activities matching the specified criteria. Only activities + * Update all threads matching the specified criteria. Only threads * that match all provided fields and were created by the twist will be updated. */ - match: ActivityFilter; - } & ActivityBulkUpdateFields); + match: ThreadFilter; + } & ThreadBulkUpdateFields); + +/** @deprecated Use ThreadUpdate instead */ +export type ActivityUpdate = ThreadUpdate; /** - * Represents a note within an activity. + * Represents a note within a thread. * - * Notes contain the detailed content (note text, links) associated with an activity. - * They are always ordered by creation time within their parent activity. + * Notes contain the detailed content (note text, actions) associated with a thread. + * They are always ordered by creation time within their parent thread. */ -export type Note = ActivityCommon & { +export type Note = ThreadCommon & { /** - * Globally unique, stable identifier for the note within its activity. + * Globally unique, stable identifier for the note within its thread. * Can be used to upsert without knowing the id. * * Use one of these patterns: @@ -1059,15 +1099,15 @@ export type Note = ActivityCommon & { * - `"comment:12345"` (for a specific comment by ID) * - `"gmail:msg:18d4e5f2a3b1c9d7"` (for a Gmail message within a thread) * - * ⚠️ Ensure IDs are immutable - avoid human-readable slugs or titles. + * Ensure IDs are immutable - avoid human-readable slugs or titles. */ key: string | null; - /** The parent activity this note belongs to */ - activity: Activity; + /** The parent thread this note belongs to */ + thread: Thread; /** Primary content for the note (markdown) */ content: string | null; - /** Array of interactive links attached to the note */ - links: Array | null; + /** Array of interactive actions attached to the note */ + actions: Array | null; /** The note this is a reply to, or null if not a reply */ reNote: { id: Uuid } | null; }; @@ -1075,22 +1115,22 @@ export type Note = ActivityCommon & { /** * Type for creating new notes. * - * Requires the activity reference, with all other fields optional. + * Requires the thread reference, with all other fields optional. * Can provide id, key, or neither for note identification: * - id: Provide a specific UUID for the note - * - key: Provide an external identifier for upsert within the activity + * - key: Provide an external identifier for upsert within the thread * - neither: A new note with auto-generated UUID will be created */ export type NewNote = Partial< Omit< Note, - "author" | "activity" | "tags" | "mentions" | "id" | "key" | "reNote" + "author" | "thread" | "tags" | "mentions" | "id" | "key" | "reNote" > > & ({ id: Uuid } | { key: string } | {}) & { - /** Reference to the parent activity (required) */ - activity: - | Pick + /** Reference to the parent thread (required) */ + thread: + | Pick | { source: string; }; @@ -1109,7 +1149,7 @@ export type NewNote = Partial< contentType?: ContentType; /** - * Tags to change on the activity. Use an empty array of NewActor to remove a tag. + * Tags to change on the thread. Use an empty array of NewActor to remove a tag. * Use twistTags to add/remove the twist from tags to avoid clearing other actors' tags. */ tags?: NewTags; @@ -1120,11 +1160,11 @@ export type NewNote = Partial< mentions?: NewActor[]; /** - * Whether the note should mark the parent activity as unread for users. - * - undefined/omitted (default): Activity is unread for users, except auto-marked + * Whether the note should mark the parent thread as unread for users. + * - undefined/omitted (default): Thread is unread for users, except auto-marked * as read for the author if they are the twist owner (user) - * - true: Activity is explicitly unread for ALL users (use sparingly) - * - false: Activity is marked as read for all users in the priority at note creation time + * - true: Thread is explicitly unread for ALL users (use sparingly) + * - false: Thread is marked as read for all users in the priority at note creation time * * For the default behavior, omit this field entirely. * Use false for initial sync to avoid marking historical items as unread. @@ -1147,7 +1187,7 @@ export type NewNote = Partial< */ export type NoteUpdate = ({ id: Uuid; key?: string } | { key: string }) & Partial< - Pick + Pick > & { /** * Format of the note content. Determines how the note is processed: @@ -1179,7 +1219,7 @@ export type NoteUpdate = ({ id: Uuid; key?: string } | { key: string }) & /** * Represents an actor in Plot - a user, contact, or twist. * - * Actors can be associated with activities as authors, assignees, or mentions. + * Actors can be associated with threads as authors, assignees, or mentions. * The email field is only included when ContactAccess.Read permission is granted. * * @example @@ -1224,17 +1264,17 @@ export type NewActor = | NewContact; /** - * Enumeration of author types that can create activities. + * Enumeration of author types that can create threads. * - * The author type affects how activities are displayed and processed + * The author type affects how threads are displayed and processed * within the Plot system. */ export enum ActorType { - /** Activities created by human users */ + /** Threads created by human users */ User, - /** Activities created by external contacts */ + /** Threads created by external contacts */ Contact, - /** Activities created by automated twists */ + /** Threads created by automated twists */ Twist, } @@ -1270,9 +1310,22 @@ export type NewContact = { export type ContentType = "text" | "markdown" | "html"; -/** @deprecated Use LinkType instead */ -export const ActivityLinkType = LinkType; -/** @deprecated Use LinkType instead */ -export type ActivityLinkType = LinkType; -/** @deprecated Use Link instead */ -export type ActivityLink = Link; +/** @deprecated Use Action instead */ +export type Link = Action; + +/** @deprecated Use ActionType instead */ +export const ActivityLinkType = ActionType; +/** @deprecated Use ActionType instead */ +export type ActivityLinkType = ActionType; +/** @deprecated Use Action instead */ +export type ActivityLink = Action; + +/** @deprecated Use ThreadKind instead */ +export const ActivityKind = ThreadKind; +/** @deprecated Use ThreadKind instead */ +export type ActivityKind = ThreadKind; + +/** @deprecated Use ThreadCommon instead */ +export type ActivityCommon = ThreadCommon; +/** @deprecated Use ThreadFields instead */ +export type ActivityFields = ThreadFields; diff --git a/twister/src/tag.ts b/twister/src/tag.ts index fee415a..05c06b1 100644 --- a/twister/src/tag.ts +++ b/twister/src/tag.ts @@ -1,5 +1,5 @@ /** - * Activity tags. Three types: + * Thread tags. Three types: * 1. Special tags, which trigger other behaviors * 2. Toggle tags, which anyone can toggle a shared value on or off * 3. Count tags, where everyone can add or remove their own diff --git a/twister/src/tool.ts b/twister/src/tool.ts index e6f2cac..6e9bee6 100644 --- a/twister/src/tool.ts +++ b/twister/src/tool.ts @@ -1,7 +1,7 @@ import { type Actor, - type ActivityFilter, - type NewActivityWithNotes, + type ThreadFilter, + type NewThreadWithNotes, type Priority, } from "./plot"; import type { Callback } from "./tools/callbacks"; @@ -16,7 +16,7 @@ import type { export type { ToolBuilder }; /** - * Options for tools that sync activities from external services. + * Options for tools that sync threads from external services. * * @example * ```typescript @@ -25,9 +25,9 @@ export type { ToolBuilder }; */ export type SyncToolOptions = { /** Callback invoked for each synced item. The tool adds sync metadata before passing it. */ - onItem: (item: NewActivityWithNotes) => Promise; - /** Callback invoked when a syncable is disabled, receiving an ActivityFilter for bulk operations. */ - onSyncableDisabled?: (filter: ActivityFilter) => Promise; + onItem: (item: NewThreadWithNotes) => Promise; + /** Callback invoked when a syncable is disabled, receiving a ThreadFilter for bulk operations. */ + onSyncableDisabled?: (filter: ThreadFilter) => Promise; }; /** diff --git a/twister/src/tools/callbacks.ts b/twister/src/tools/callbacks.ts index 821fd02..687476f 100644 --- a/twister/src/tools/callbacks.ts +++ b/twister/src/tools/callbacks.ts @@ -32,7 +32,7 @@ export type Callback = string & { readonly __brand: "Callback" }; * **When to use callbacks:** * - Webhook handlers that need persistent function references * - Scheduled operations that run after worker timeouts - * - User interaction links (LinkType.callback) + * - User interaction actions (ActionType.callback) * - Cross-tool communication that survives restarts * * **Type Safety:** diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index 52b72db..94b26a3 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -1,12 +1,12 @@ import { - type Activity, - type ActivityOccurrence, - type ActivityUpdate, + type Thread, + type ThreadOccurrence, + type ThreadUpdate, type Actor, type ActorId, ITool, - type NewActivity, - type NewActivityWithNotes, + type NewThread, + type NewThreadWithNotes, type NewContact, type NewNote, type NewPriority, @@ -18,20 +18,25 @@ import { Uuid, } from ".."; -export enum ActivityAccess { +export enum ThreadAccess { /** - * Create new Note on an Activity where the twist was mentioned. - * Add/remove tags on Activity or Note where the twist was mentioned. + * Create new Note on a Thread where the twist was mentioned. + * Add/remove tags on Thread or Note where the twist was mentioned. */ Respond, /** - * Create new Activity. - * Create new Note in an Activity the twist created. + * Create new Thread. + * Create new Note in a Thread the twist created. * All Respond permissions. */ Create, } +/** @deprecated Use ThreadAccess instead */ +export const ActivityAccess = ThreadAccess; +/** @deprecated Use ThreadAccess instead */ +export type ActivityAccess = ThreadAccess; + export enum PriorityAccess { /** * Create a new Priority within the twist's Priority. @@ -54,8 +59,8 @@ export enum ContactAccess { } /** - * Intent handler for activity mentions. - * Defines how the twist should respond when mentioned in an activity. + * Intent handler for thread mentions. + * Defines how the twist should respond when mentioned in a thread. */ export type NoteIntentHandler = { /** Human-readable description of what this intent handles */ @@ -69,7 +74,7 @@ export type NoteIntentHandler = { /** * Built-in tool for interacting with the core Plot data layer. * - * The Plot tool provides twists with the ability to create and manage activities, + * The Plot tool provides twists with the ability to create and manage threads, * priorities, and contacts within the Plot system. This is the primary interface * for twists to persist data and interact with the Plot database. * @@ -84,13 +89,13 @@ export type NoteIntentHandler = { * } * * async activate(priority) { - * // Create a welcome activity - * await this.plot.createActivity({ - * type: ActivityType.Note, + * // Create a welcome thread + * await this.plot.createThread({ + * type: ThreadType.Note, * title: "Welcome to Plot!", - * links: [{ + * actions: [{ * title: "Get Started", - * type: LinkType.external, + * type: ActionType.external, * url: "https://plot.day/docs" * }] * }); @@ -110,8 +115,8 @@ export abstract class Plot extends ITool { * build(build: ToolBuilder) { * return { * plot: build(Plot, { - * activity: { - * access: ActivityAccess.Create + * thread: { + * access: ThreadAccess.Create * } * }) * }; @@ -121,9 +126,9 @@ export abstract class Plot extends ITool { * build(build: ToolBuilder) { * return { * plot: build(Plot, { - * activity: { - * access: ActivityAccess.Create, - * updated: this.onActivityUpdated + * thread: { + * access: ThreadAccess.Create, + * updated: this.onThreadUpdated * }, * note: { * intents: [{ @@ -145,28 +150,28 @@ export abstract class Plot extends ITool { * ``` */ static readonly Options: { - activity?: { + thread?: { /** * Capability to create Notes and modify tags. * Must be explicitly set to grant permissions. */ - access?: ActivityAccess; + access?: ThreadAccess; /** - * Called when an activity created by this twist is updated. + * Called when a thread 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 - Changes to the activity and the previous version + * @param thread - The updated thread + * @param changes - Changes to the thread and the previous version */ updated?: ( - activity: Activity, + thread: Thread, changes: { tagsAdded: Record; tagsRemoved: Record; /** - * If present, this update is for a specific occurrence of a recurring activity. + * If present, this update is for a specific occurrence of a recurring thread. */ - occurrence?: ActivityOccurrence; + occurrence?: ThreadOccurrence; } ) => Promise; }; @@ -192,12 +197,12 @@ export abstract class Plot extends ITool { */ intents?: NoteIntentHandler[]; /** - * Called when a note is created on an activity created by this twist. + * Called when a note is created on a thread created by this twist. * This is often used to implement two-way sync with an external system, * such as syncing notes as comments back to the source system. * * Notes created by the twist itself are automatically filtered out to prevent loops. - * The parent activity is available via note.activity. + * The parent thread is available via note.thread. * * @param note - The newly created note */ @@ -212,77 +217,77 @@ export abstract class Plot extends ITool { }; /** - * Creates a new activity in the Plot system. + * Creates a new thread in the Plot system. * - * The activity will be automatically assigned an ID and author information - * based on the current execution context. All other fields from NewActivity - * will be preserved in the created activity. + * The thread will be automatically assigned an ID and author information + * based on the current execution context. All other fields from NewThread + * will be preserved in the created thread. * - * @param activity - The activity data to create - * @returns Promise resolving to the created activity's ID + * @param thread - The thread data to create + * @returns Promise resolving to the created thread's ID */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract createActivity( - activity: NewActivity | NewActivityWithNotes + abstract createThread( + thread: NewThread | NewThreadWithNotes ): Promise; /** - * Creates multiple activities in a single batch operation. + * Creates multiple threads in a single batch operation. * - * This method efficiently creates multiple activities at once, which is - * more performant than calling createActivity() multiple times individually. - * All activities are created with the same author and access control rules. + * This method efficiently creates multiple threads at once, which is + * more performant than calling createThread() multiple times individually. + * All threads are created with the same author and access control rules. * - * @param activities - Array of activity data to create - * @returns Promise resolving to array of created activity IDs + * @param threads - Array of thread data to create + * @returns Promise resolving to array of created thread IDs */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract createActivities( - activities: (NewActivity | NewActivityWithNotes)[] + abstract createThreads( + threads: (NewThread | NewThreadWithNotes)[] ): Promise; /** - * Updates an existing activity in the Plot system. + * Updates an existing thread in the Plot system. * - * **Important**: This method only updates existing activities. It will throw an error - * if the activity does not exist. Use `createActivity()` to create or update (upsert) - * activities. + * **Important**: This method only updates existing threads. It will throw an error + * if the thread does not exist. Use `createThread()` to create or update (upsert) + * threads. * * 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 activity object. + * the entire thread object. * * For tags, provide a Record where true adds a tag and false removes it. * Tags not included in the update remain unchanged. * - * When updating the parent, the activity's path will be automatically recalculated to + * When updating the parent, the thread's path will be automatically recalculated to * maintain the correct hierarchical structure. * * When updating scheduling fields (start, end, recurrence*), the database will * automatically recalculate duration and range values to maintain consistency. * - * @param activity - The activity update containing the ID or source and fields to change + * @param thread - The thread update containing the ID or source and fields to change * @returns Promise that resolves when the update is complete - * @throws Error if the activity does not exist + * @throws Error if the thread does not exist * * @example * ```typescript * // Mark a task as complete - * await this.plot.updateActivity({ + * await this.plot.updateThread({ * id: "task-123", * done: new Date() * }); * * // Reschedule an event - * await this.plot.updateActivity({ + * await this.plot.updateThread({ * id: "event-456", * start: new Date("2024-03-15T10:00:00Z"), * end: new Date("2024-03-15T11:00:00Z") * }); * * // Add and remove tags - * await this.plot.updateActivity({ - * id: "activity-789", + * await this.plot.updateThread({ + * id: "thread-789", * tags: { * 1: true, // Add tag with ID 1 * 2: false // Remove tag with ID 2 @@ -290,7 +295,7 @@ export abstract class Plot extends ITool { * }); * * // Update a recurring event exception - * await this.plot.updateActivity({ + * await this.plot.updateThread({ * id: "exception-123", * occurrence: new Date("2024-03-20T09:00:00Z"), * title: "Rescheduled meeting" @@ -298,26 +303,26 @@ export abstract class Plot extends ITool { * ``` */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract updateActivity(activity: ActivityUpdate): Promise; + abstract updateThread(thread: ThreadUpdate): Promise; /** - * Retrieves all notes within an activity. + * Retrieves all notes within a thread. * - * 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. + * Notes are detailed entries within a thread, ordered by creation time. + * Each note can contain markdown content, actions, and other detailed information + * related to the parent thread. * - * @param activity - The activity whose notes to retrieve - * @returns Promise resolving to array of notes in the activity + * @param thread - The thread whose notes to retrieve + * @returns Promise resolving to array of notes in the thread */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract getNotes(activity: Activity): Promise; + abstract getNotes(thread: Thread): Promise; /** - * Creates a new note in an activity. + * Creates a new note in a thread. * - * Notes provide detailed content within an activity, supporting markdown, - * links, and other rich content. The note will be automatically assigned + * Notes provide detailed content within a thread, supporting markdown, + * actions, 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 @@ -327,17 +332,17 @@ export abstract class Plot extends ITool { * ```typescript * // Create a note with content * await this.plot.createNote({ - * activity: { id: "activity-123" }, + * thread: { id: "thread-123" }, * note: "Discussion notes from the meeting...", * contentType: "markdown" * }); * - * // Create a note with links + * // Create a note with actions * await this.plot.createNote({ - * activity: { id: "activity-456" }, + * thread: { id: "thread-456" }, * note: "Meeting recording available", - * links: [{ - * type: LinkType.external, + * actions: [{ + * type: ActionType.external, * title: "View Recording", * url: "https://example.com/recording" * }] @@ -362,11 +367,11 @@ export abstract class Plot extends ITool { * // Create multiple notes in one batch * await this.plot.createNotes([ * { - * activity: { id: "activity-123" }, + * thread: { id: "thread-123" }, * note: "First message in thread" * }, * { - * activity: { id: "activity-123" }, + * thread: { id: "thread-123" }, * note: "Second message in thread" * } * ]); @@ -410,25 +415,25 @@ export abstract class Plot extends ITool { abstract updateNote(note: NoteUpdate): Promise; /** - * Retrieves an activity by ID or source. + * Retrieves a thread by ID or source. * - * This method enables lookup of activities either by their unique ID or by their - * source identifier (canonical URL from an external system). Archived activities + * This method enables lookup of threads either by their unique ID or by their + * source identifier (canonical URL from an external system). Archived threads * are included in the results. * - * @param activity - Activity lookup by ID or source - * @returns Promise resolving to the matching activity or null if not found + * @param thread - Thread lookup by ID or source + * @returns Promise resolving to the matching thread or null if not found */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract getActivity( - activity: { id: Uuid } | { source: string } - ): Promise; + abstract getThread( + thread: { id: Uuid } | { source: string } + ): Promise; /** * Retrieves a note by ID or key. * * This method enables lookup of notes either by their unique ID or by their - * key (unique identifier within the activity). Archived notes are included + * key (unique identifier within the thread). Archived notes are included * in the results. * * @param note - Note lookup by ID or key @@ -440,7 +445,7 @@ export abstract class Plot extends ITool { /** * Creates a new priority in the Plot system. * - * Priorities serve as organizational containers for activities and twists. + * Priorities serve as organizational containers for threads and twists. * The created priority will be automatically assigned a unique ID. * * @param priority - The priority data to create @@ -477,7 +482,7 @@ export abstract class Plot extends ITool { /** * Adds contacts to the Plot system. * - * Contacts are used for associating people with activities, such as + * Contacts are used for associating people with threads, such as * event attendees or task assignees. Duplicate contacts (by email) * will be merged or updated as appropriate. * This method requires ContactAccess.Write permission. diff --git a/twister/src/tools/twists.ts b/twister/src/tools/twists.ts index 0e49b27..087a12f 100644 --- a/twister/src/tools/twists.ts +++ b/twister/src/tools/twists.ts @@ -45,7 +45,7 @@ export type Log = { * "https://googleapis.com/*": ["use"] * }, * "plot": { - * "activity:mentioned": ["read", "write", "update"], + * "thread:mentioned": ["read", "write", "update"], * "priority": ["read", "write", "update"] * } * } diff --git a/twister/src/twist.ts b/twister/src/twist.ts index 993f21a..1d580eb 100644 --- a/twister/src/twist.ts +++ b/twister/src/twist.ts @@ -1,4 +1,4 @@ -import { type Link, type Actor, type Priority, Uuid } from "./plot"; +import { type Action, type Actor, type Priority, Uuid } from "./plot"; import { type ITool } from "./tool"; import type { Callback } from "./tools/callbacks"; import type { Serializable } from "./utils/serializable"; @@ -23,8 +23,8 @@ import type { InferTools, ToolBuilder, ToolShed } from "./utils/types"; * * async activate(priority: Pick) { * // Initialize twist for the given priority - * await this.tools.plot.createActivity({ - * type: ActivityType.Note, + * await this.tools.plot.createThread({ + * type: ThreadType.Note, * note: "Hello, good looking!", * }); * } @@ -92,25 +92,25 @@ export abstract class Twist { } /** - * Like callback(), but for a Link, which receives the link as the first argument. + * Like callback(), but for an Action, which receives the action as the first argument. * * @param fn - The method to callback - * @param extraArgs - Additional arguments to pass after the link + * @param extraArgs - Additional arguments to pass after the action * @returns Promise resolving to a persistent callback token * * @example * ```typescript - * const callback = await this.linkCallback(this.doSomething, 123); - * const link: Link = { - * type: LinkType.Callback, + * const callback = await this.actionCallback(this.doSomething, 123); + * const action: Action = { + * type: ActionType.callback, * title: "Do Something", * callback, * }; * ``` */ - protected async linkCallback< + protected async actionCallback< TArgs extends Serializable[], - Fn extends (link: Link, ...extraArgs: TArgs) => any + Fn extends (action: Action, ...extraArgs: TArgs) => any >(fn: Fn, ...extraArgs: TArgs): Promise { return this.tools.callbacks.create(fn, ...extraArgs); } @@ -263,7 +263,7 @@ export abstract class Twist { * Called when the twist is activated for a specific priority. * * This method should contain initialization logic such as setting up - * initial activities, configuring webhooks, or establishing external connections. + * initial threads, configuring webhooks, or establishing external connections. * * @param priority - The priority context containing the priority ID * @param context - Optional context containing the actor who triggered activation diff --git a/twists/calendar-sync/src/index.ts b/twists/calendar-sync/src/index.ts index f322bcd..71c32c4 100644 --- a/twists/calendar-sync/src/index.ts +++ b/twists/calendar-sync/src/index.ts @@ -1,13 +1,13 @@ import { GoogleCalendar } from "@plotday/tool-google-calendar"; import { OutlookCalendar } from "@plotday/tool-outlook-calendar"; import { - type ActivityFilter, - type NewActivityWithNotes, + type ThreadFilter, + type NewThreadWithNotes, type Priority, type ToolBuilder, Twist, } from "@plotday/twister"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; export default class CalendarSyncTwist extends Twist { build(build: ToolBuilder) { @@ -21,8 +21,8 @@ export default class CalendarSyncTwist extends Twist { onSyncableDisabled: this.handleSyncableDisabled, }), plot: build(Plot, { - activity: { - access: ActivityAccess.Create, + thread: { + access: ThreadAccess.Create, }, }), }; @@ -32,13 +32,13 @@ export default class CalendarSyncTwist extends Twist { // Auth and calendar selection are now handled in the twist edit modal. } - async handleSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); + async handleSyncableDisabled(filter: ThreadFilter): Promise { + await this.tools.plot.updateThread({ match: filter, archived: true }); } - async handleEvent(activity: NewActivityWithNotes): Promise { + async handleEvent(thread: NewThreadWithNotes): Promise { // Just create/upsert - database handles everything automatically // Note: The unread field is already set by the tool based on sync type - await this.tools.plot.createActivity(activity); + await this.tools.plot.createThread(thread); } } diff --git a/twists/chat/src/index.ts b/twists/chat/src/index.ts index b272c09..5eefdd6 100644 --- a/twists/chat/src/index.ts +++ b/twists/chat/src/index.ts @@ -1,8 +1,8 @@ import { Type } from "typebox"; import { - type Link, - LinkType, + type Action, + ActionType, ActorType, type Note, Tag, @@ -11,7 +11,7 @@ import { } from "@plotday/twister"; import { Options } from "@plotday/twister/options"; import { AI, type AIMessage, AIModel } from "@plotday/twister/tools/ai"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; export default class ChatTwist extends Twist { build(build: ToolBuilder) { @@ -33,8 +33,8 @@ export default class ChatTwist extends Twist { }), ai: build(AI), plot: build(Plot, { - activity: { - access: ActivityAccess.Respond, + thread: { + access: ThreadAccess.Respond, }, note: { intents: [ @@ -54,14 +54,14 @@ export default class ChatTwist extends Twist { } async respond(note: Note) { - const activity = note.activity; + const thread = note.thread; - // Get all notes in this activity (conversation history) - const previousNotes = await this.tools.plot.getNotes(activity); + // Get all notes in this thread (conversation history) + const previousNotes = await this.tools.plot.getNotes(thread); // Add Thinking tag to indicate processing has started - await this.tools.plot.updateActivity({ - id: activity.id, + await this.tools.plot.updateThread({ + id: thread.id, twistTags: { [Tag.Twist]: true, }, @@ -75,12 +75,12 @@ You respond helpfully to user requests. You can also create tasks, but should only do so when the user explicitly asks you to. You can provide either or both inline and standalone links. Only use standalone links for key references, such as a website that answers the user's question in detail.`, }, - // Include activity title as context - ...(activity.title + // Include thread title as context + ...(thread.title ? [ { role: "user" as const, - content: activity.title, + content: thread.title, }, ] : []), @@ -132,24 +132,24 @@ You can provide either or both inline and standalone links. Only use standalone outputSchema: schema, }); - // Convert AI links to Link format - const activityLinks: Link[] | null = + // Convert AI links to Action format + const threadActions: Action[] | null = response.output!.links?.map((link) => ({ - type: LinkType.external, + type: ActionType.external, title: link.title, url: link.url, })) || null; - // Create AI response as a note on the existing activity + // Create AI response as a note on the existing thread await Promise.all([ this.tools.plot.createNote({ - activity, + thread, content: response.output!.response, - links: activityLinks, + actions: threadActions, }), ...(response.output!.tasks?.map((task) => this.tools.plot.createNote({ - activity, + thread, content: task, tags: { [Tag.Now]: [{ id: note.author.id }], @@ -159,8 +159,8 @@ You can provide either or both inline and standalone links. Only use standalone ]); // Remove Thinking tag after response is created - await this.tools.plot.updateActivity({ - id: activity.id, + await this.tools.plot.updateThread({ + id: thread.id, twistTags: { [Tag.Twist]: false, }, diff --git a/twists/code-review/src/index.ts b/twists/code-review/src/index.ts index b7f619b..95269f1 100644 --- a/twists/code-review/src/index.ts +++ b/twists/code-review/src/index.ts @@ -1,16 +1,16 @@ import { GitHub } from "@plotday/tool-github"; import { - type Activity, - type ActivityFilter, + type Thread, + type ThreadFilter, ActorType, - type NewActivityWithNotes, + type NewThreadWithNotes, type Note, type Priority, type ToolBuilder, Twist, } from "@plotday/twister"; import type { SourceControlTool } from "@plotday/twister/common/source-control"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; type SourceControlProvider = "github"; @@ -29,9 +29,9 @@ export default class CodeReview extends Twist { onSyncableDisabled: this.onSyncableDisabled, }), plot: build(Plot, { - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, + thread: { + access: ThreadAccess.Create, + updated: this.onThreadUpdated, }, note: { created: this.onNoteCreated, @@ -56,12 +56,12 @@ export default class CodeReview extends Twist { // Auth and repository selection are handled in the twist edit modal. } - async onGitHubItem(item: NewActivityWithNotes) { + async onGitHubItem(item: NewThreadWithNotes) { return this.onPullRequest(item, "github"); } - async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); + async onSyncableDisabled(filter: ThreadFilter): Promise { + await this.tools.plot.updateThread({ match: filter, archived: true }); } /** @@ -84,7 +84,7 @@ export default class CodeReview extends Twist { * Creates or updates Plot activities based on PR state. */ async onPullRequest( - pr: NewActivityWithNotes, + pr: NewThreadWithNotes, provider: SourceControlProvider, ) { // Add provider to meta for routing updates back to the correct tool @@ -94,21 +94,21 @@ export default class CodeReview extends Twist { pr.notes = pr.notes?.filter((note) => !this.isNoteEmpty(note)); // Create/upsert - database handles everything automatically - await this.tools.plot.createActivity(pr); + await this.tools.plot.createThread(pr); } /** - * Called when an activity created by this twist is updated. + * Called when a thread created by this twist is updated. * Syncs changes back to the external service. */ - private async onActivityUpdated( - activity: Activity, + private async onThreadUpdated( + thread: Thread, _changes: { tagsAdded: Record; tagsRemoved: Record; }, ): Promise { - const provider = activity.meta?.provider as + const provider = thread.meta?.provider as | SourceControlProvider | undefined; if (!provider) return; @@ -117,22 +117,22 @@ export default class CodeReview extends Twist { try { if (tool.updatePRStatus) { - await tool.updatePRStatus(activity); + await tool.updatePRStatus(thread); } } catch (error) { console.error( - `Failed to sync activity update to ${provider}:`, + `Failed to sync thread update to ${provider}:`, error, ); } } /** - * Called when a note is created on an activity created by this twist. + * Called when a note is created on a thread created by this twist. * Syncs the note as a comment to the external service. */ private async onNoteCreated(note: Note): Promise { - const activity = note.activity; + const thread = note.thread; // Filter out notes created by twists to prevent loops if (note.author.type === ActorType.Twist) { @@ -144,10 +144,10 @@ export default class CodeReview extends Twist { return; } - const provider = activity.meta?.provider as + const provider = thread.meta?.provider as | SourceControlProvider | undefined; - if (!provider || !activity.meta) { + if (!provider || !thread.meta) { return; } @@ -161,7 +161,7 @@ export default class CodeReview extends Twist { try { const commentKey = await tool.addPRComment( - activity.meta, + thread.meta, note.content, note.id, ); diff --git a/twists/document-actions/src/index.ts b/twists/document-actions/src/index.ts index 0b17ebe..3b4a1ae 100644 --- a/twists/document-actions/src/index.ts +++ b/twists/document-actions/src/index.ts @@ -1,7 +1,7 @@ import { GoogleDrive } from "@plotday/tool-google-drive"; import { - type ActivityFilter, - type NewActivityWithNotes, + type ThreadFilter, + type NewThreadWithNotes, type Note, type Priority, ActorType, @@ -9,13 +9,13 @@ import { Twist, } from "@plotday/twister"; import type { DocumentTool } from "@plotday/twister/common/documents"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; /** * Document Actions Twist * * Syncs documents, comments, and action items from Google Drive with Plot. - * Converts documents into Plot activities with notes for comments, + * Converts documents into Plot threads with notes for comments, * syncs Plot notes back as comments on the documents, * and tags action items with Tag.Now for assigned users. */ @@ -27,8 +27,8 @@ export default class DocumentActions extends Twist { onSyncableDisabled: this.onSyncableDisabled, }), plot: build(Plot, { - activity: { - access: ActivityAccess.Create, + thread: { + access: ThreadAccess.Create, }, note: { created: this.onNoteCreated, @@ -49,26 +49,26 @@ export default class DocumentActions extends Twist { // Auth and folder selection are now handled in the twist edit modal. } - async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); + async onSyncableDisabled(filter: ThreadFilter): Promise { + await this.tools.plot.updateThread({ match: filter, archived: true }); } /** * Called for each document synced from Google Drive. */ - async onDocument(doc: NewActivityWithNotes) { + async onDocument(doc: NewThreadWithNotes) { // Add provider to meta for routing updates back doc.meta = { ...doc.meta, provider: "google-drive" }; - await this.tools.plot.createActivity(doc); + await this.tools.plot.createThread(doc); } /** - * Called when a note is created on an activity created by this twist. + * Called when a note is created on a thread created by this twist. * Syncs the note as a comment or reply to Google Drive. */ private async onNoteCreated(note: Note): Promise { - const activity = note.activity; + const thread = note.thread; // Filter out twist-authored notes to prevent loops if (note.author.type === ActorType.Twist) { @@ -81,8 +81,8 @@ export default class DocumentActions extends Twist { } // Get provider from meta - const provider = activity.meta?.provider as string | undefined; - if (!provider || !activity.meta) { + const provider = thread.meta?.provider as string | undefined; + if (!provider || !thread.meta) { return; } @@ -100,7 +100,7 @@ export default class DocumentActions extends Twist { if (commentId && tool.addDocumentReply) { // Reply to existing comment thread commentKey = await tool.addDocumentReply( - activity.meta, + thread.meta, commentId, note.content, note.id @@ -108,7 +108,7 @@ export default class DocumentActions extends Twist { } else if (tool.addDocumentComment) { // Top-level comment commentKey = await tool.addDocumentComment( - activity.meta, + thread.meta, note.content, note.id ); @@ -129,8 +129,8 @@ export default class DocumentActions extends Twist { * or null if the chain doesn't lead to a synced comment. */ private async resolveCommentId(note: Note): Promise { - // Fetch all notes for the activity to build the lookup map - const notes = await this.tools.plot.getNotes(note.activity); + // Fetch all notes for the thread to build the lookup map + const notes = await this.tools.plot.getNotes(note.thread); const noteMap = new Map(notes.map((n) => [n.id, n])); // Walk up the reNote chain diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index 2d5bc82..fda9deb 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -3,9 +3,9 @@ import { Type } from "typebox"; import { Gmail } from "@plotday/tool-gmail"; import { Slack } from "@plotday/tool-slack"; import { - type ActivityFilter, - ActivityType, - type NewActivityWithNotes, + type ThreadFilter, + ThreadType, + type NewThreadWithNotes, type NewContact, type Note, type Priority, @@ -13,7 +13,7 @@ import { Twist, } from "@plotday/twister"; import { AI, type AIMessage } from "@plotday/twister/tools/ai"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; import { Uuid } from "@plotday/twister/utils/uuid"; type MessageProvider = "slack" | "gmail"; @@ -46,8 +46,8 @@ export default class MessageTasksTwist extends Twist { }), ai: build(AI), plot: build(Plot, { - activity: { - access: ActivityAccess.Create, + thread: { + access: ThreadAccess.Create, }, note: { intents: [ @@ -91,18 +91,18 @@ export default class MessageTasksTwist extends Twist { // Auth and channel selection are now handled in the twist edit modal. } - async onSlackThread(thread: NewActivityWithNotes): Promise { + async onSlackThread(thread: NewThreadWithNotes): Promise { const channelId = thread.meta?.syncableId as string; return this.onMessageThread(thread, "slack", channelId); } - async onGmailThread(thread: NewActivityWithNotes): Promise { + async onGmailThread(thread: NewThreadWithNotes): Promise { const channelId = thread.meta?.syncableId as string; return this.onMessageThread(thread, "gmail", channelId); } - async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); + async onSyncableDisabled(filter: ThreadFilter): Promise { + await this.tools.plot.updateThread({ match: filter, archived: true }); } // ============================================================================ @@ -157,7 +157,7 @@ export default class MessageTasksTwist extends Twist { const instructions = await this.getInstructions(); if (instructions.length >= 20) { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: "You've reached the limit of 20 instructions. Remove one first with \"forget instruction\" before adding more.", }); @@ -174,7 +174,7 @@ export default class MessageTasksTwist extends Twist { if (summary === "UNCLEAR") { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `I didn't understand that as an instruction. Try something like:\n- "Ignore threads from #random"\n- "Always create tasks for messages from Sarah"\n- "Never create tasks for bot messages"`, }); return; @@ -192,7 +192,7 @@ export default class MessageTasksTwist extends Twist { await this.setInstructions(instructions); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `Saved: "${summary}"`, }); } @@ -202,7 +202,7 @@ export default class MessageTasksTwist extends Twist { if (instructions.length === 0) { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `No instructions yet. Mention me with an instruction like "Ignore threads from #random" to add one.`, }); return; @@ -213,7 +213,7 @@ export default class MessageTasksTwist extends Twist { .join("\n"); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `**Instructions:**\n${list}`, }); } @@ -226,7 +226,7 @@ export default class MessageTasksTwist extends Twist { if (instructions.length === 0) { await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: "No instructions to remove.", }); return; @@ -286,7 +286,7 @@ export default class MessageTasksTwist extends Twist { .join("\n"); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `Couldn't find a matching instruction. Here are the current ones:\n${list}`, }); return; @@ -295,7 +295,7 @@ export default class MessageTasksTwist extends Twist { await this.setInstructions(instructions.filter((i) => i.id !== target.id)); await this.tools.plot.createNote({ - activity: { id: note.activity.id }, + thread: { id: note.thread.id }, content: `Removed: "${target.summary}"`, }); } @@ -305,7 +305,7 @@ export default class MessageTasksTwist extends Twist { // ============================================================================ async onMessageThread( - thread: NewActivityWithNotes, + thread: NewThreadWithNotes, provider: MessageProvider, channelId: string ): Promise { @@ -338,7 +338,7 @@ export default class MessageTasksTwist extends Twist { await this.createTaskFromThread(thread, analysis, provider, channelId); } - private async analyzeThread(thread: NewActivityWithNotes): Promise<{ + private async analyzeThread(thread: NewThreadWithNotes): Promise<{ needsTask: boolean; taskTitle: string | null; taskNote: string | null; @@ -449,7 +449,7 @@ If a task is needed, create a clear, actionable title that describes what the us } private formatSourceReference( - thread: NewActivityWithNotes, + thread: NewThreadWithNotes, provider: MessageProvider, channelId: string ): string { @@ -470,7 +470,7 @@ If a task is needed, create a clear, actionable title that describes what the us } private async createTaskFromThread( - thread: NewActivityWithNotes, + thread: NewThreadWithNotes, analysis: { needsTask: boolean; taskTitle: string | null; @@ -488,10 +488,10 @@ If a task is needed, create a clear, actionable title that describes what the us const sourceRef = this.formatSourceReference(thread, provider, channelId); - // Create task activity - database handles upsert automatically - const taskId = await this.tools.plot.createActivity({ + // Create task thread - database handles upsert automatically + const taskId = await this.tools.plot.createThread({ source: `message-tasks:${threadId}`, - type: ActivityType.Action, + type: ThreadType.Action, title: analysis.taskTitle || thread.title || "Action needed from message", start: new Date(), notes: analysis.taskNote @@ -521,7 +521,7 @@ If a task is needed, create a clear, actionable title that describes what the us } private async checkThreadForCompletion( - thread: NewActivityWithNotes, + thread: NewThreadWithNotes, taskInfo: ThreadTask ): Promise { // Only check the last few messages for completion signals @@ -572,7 +572,7 @@ Return true only if there's clear evidence the task is done.`, }; if (result.isCompleted && result.confidence >= 0.7) { - await this.tools.plot.updateActivity({ + await this.tools.plot.updateThread({ id: taskInfo.taskId, done: new Date(), }); diff --git a/twists/project-sync/src/index.ts b/twists/project-sync/src/index.ts index 004674b..7aa1536 100644 --- a/twists/project-sync/src/index.ts +++ b/twists/project-sync/src/index.ts @@ -3,17 +3,17 @@ import { GitHubIssues } from "@plotday/tool-github-issues"; import { Jira } from "@plotday/tool-jira"; import { Linear } from "@plotday/tool-linear"; import { - type Activity, - type ActivityFilter, + type Thread, + type ThreadFilter, ActorType, - type NewActivityWithNotes, + type NewThreadWithNotes, type Note, type Priority, type ToolBuilder, Twist, } from "@plotday/twister"; import type { ProjectTool } from "@plotday/twister/common/projects"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; type ProjectProvider = "linear" | "jira" | "asana" | "github-issues"; @@ -43,9 +43,9 @@ export default class ProjectSync extends Twist { onSyncableDisabled: this.onSyncableDisabled, }), plot: build(Plot, { - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, + thread: { + access: ThreadAccess.Create, + updated: this.onThreadUpdated, }, note: { created: this.onNoteCreated, @@ -76,24 +76,24 @@ export default class ProjectSync extends Twist { // Auth and project selection are now handled in the twist edit modal. } - async onLinearItem(item: NewActivityWithNotes) { + async onLinearItem(item: NewThreadWithNotes) { return this.onIssue(item, "linear"); } - async onJiraItem(item: NewActivityWithNotes) { + async onJiraItem(item: NewThreadWithNotes) { return this.onIssue(item, "jira"); } - async onAsanaItem(item: NewActivityWithNotes) { + async onAsanaItem(item: NewThreadWithNotes) { return this.onIssue(item, "asana"); } - async onGitHubIssuesItem(item: NewActivityWithNotes) { + async onGitHubIssuesItem(item: NewThreadWithNotes) { return this.onIssue(item, "github-issues"); } - async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); + async onSyncableDisabled(filter: ThreadFilter): Promise { + await this.tools.plot.updateThread({ match: filter, archived: true }); } /** @@ -116,7 +116,7 @@ export default class ProjectSync extends Twist { * Creates or updates Plot activities based on issue state. */ async onIssue( - issue: NewActivityWithNotes, + issue: NewThreadWithNotes, provider: ProjectProvider ) { // Add provider to meta for routing updates back to the correct tool @@ -127,44 +127,44 @@ export default class ProjectSync extends Twist { // Just create/upsert - database handles everything automatically // Note: The unread field is already set by the tool based on sync type - await this.tools.plot.createActivity(issue); + await this.tools.plot.createThread(issue); } /** - * Called when an activity created by this twist is updated. + * Called when a thread created by this twist is updated. * Syncs changes back to the external service. */ - private async onActivityUpdated( - activity: Activity, + private async onThreadUpdated( + thread: Thread, _changes: { tagsAdded: Record; tagsRemoved: Record; } ): Promise { - // Get provider from meta (set by this twist when creating the activity) - const provider = activity.meta?.provider as ProjectProvider | undefined; + // Get provider from meta (set by this twist when creating the thread) + const provider = thread.meta?.provider as ProjectProvider | undefined; if (!provider) return; const tool = this.getProviderTool(provider); try { // Sync all changes using the generic updateIssue method - // Tool reads its own IDs from activity.meta (e.g., linearId, taskGid, issueKey) + // Tool reads its own IDs from thread.meta (e.g., linearId, taskGid, issueKey) // Tool resolves auth token internally via integrations if (tool.updateIssue) { - await tool.updateIssue(activity); + await tool.updateIssue(thread); } } catch (error) { - console.error(`Failed to sync activity update to ${provider}:`, error); + console.error(`Failed to sync thread update to ${provider}:`, error); } } /** - * Called when a note is created on an activity created by this twist. + * Called when a note is created on a thread created by this twist. * Syncs the note as a comment to the external service. */ private async onNoteCreated(note: Note): Promise { - const activity = note.activity; + const thread = note.thread; // Filter out notes created by twists to prevent loops if (note.author.type === ActorType.Twist) { @@ -176,9 +176,9 @@ export default class ProjectSync extends Twist { return; } - // Get provider from meta (set by this twist when creating the activity) - const provider = activity.meta?.provider as ProjectProvider | undefined; - if (!provider || !activity.meta) { + // Get provider from meta (set by this twist when creating the thread) + const provider = thread.meta?.provider as ProjectProvider | undefined; + if (!provider || !thread.meta) { return; } @@ -191,7 +191,7 @@ export default class ProjectSync extends Twist { try { // Tool resolves auth token internally via integrations const commentKey = await tool.addIssueComment( - activity.meta, + thread.meta, note.content, note.id ); From dd8a5274e7293f20dea8d0add50ac89dfd5c45f0 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 24 Feb 2026 22:13:37 -0500 Subject: [PATCH 02/25] Separate scheduling from threads: new Schedule type, update tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Schedule as first-class type in Twister SDK (schedule.ts) - Remove scheduling fields from Thread/NewThread types - Add schedules/scheduleOccurrences to NewThread for calendar tools - Update all tools to use new types (calendar tools use schedules array, project/messaging tools remove start field references) - Refactor common tool interfaces (CalendarTool → CalendarSource, etc.) - Add Source base class and channel-based integrations API Co-Authored-By: Claude Opus 4.6 --- tools/github-issues/src/github-issues.ts | 2 - tools/gmail/src/gmail-api.ts | 2 +- tools/google-calendar/src/google-api.ts | 63 ++-- tools/google-calendar/src/google-calendar.ts | 68 ++-- tools/jira/src/jira.ts | 8 +- tools/linear/src/linear.ts | 10 +- tools/outlook-calendar/src/graph-api.ts | 61 ++-- .../outlook-calendar/src/outlook-calendar.ts | 77 ++-- tools/slack/src/slack-api.ts | 2 +- twister/package.json | 10 + twister/src/common/calendar.ts | 67 ++-- twister/src/common/documents.ts | 34 +- twister/src/common/messaging.ts | 34 +- twister/src/common/projects.ts | 34 +- twister/src/common/source-control.ts | 34 +- twister/src/index.ts | 2 + twister/src/plot.ts | 332 +++--------------- twister/src/schedule.ts | 213 +++++++++++ twister/src/source.ts | 47 +++ twister/src/tool.ts | 12 +- twister/src/tools/integrations.ts | 123 +++++-- twister/src/tools/plot.ts | 84 +++-- 22 files changed, 686 insertions(+), 633 deletions(-) create mode 100644 twister/src/schedule.ts create mode 100644 twister/src/source.ts diff --git a/tools/github-issues/src/github-issues.ts b/tools/github-issues/src/github-issues.ts index 7cb1a18..9a2ab2e 100644 --- a/tools/github-issues/src/github-issues.ts +++ b/tools/github-issues/src/github-issues.ts @@ -526,7 +526,6 @@ export class GitHubIssues extends Tool implements ProjectTool { author: authorContact, assignee: assigneeContact ?? null, done: issue.closed_at ?? null, - start: assigneeContact ? undefined : null, meta: { githubIssueNumber: issue.number, githubRepoId: repoId, @@ -757,7 +756,6 @@ export class GitHubIssues extends Tool implements ProjectTool { author: authorContact, assignee: assigneeContact ?? null, done: issue.closed_at ?? null, - start: assigneeContact ? undefined : null, meta: { githubIssueNumber: issue.number, githubRepoId: repoId, diff --git a/tools/gmail/src/gmail-api.ts b/tools/gmail/src/gmail-api.ts index 8654afb..7050923 100644 --- a/tools/gmail/src/gmail-api.ts +++ b/tools/gmail/src/gmail-api.ts @@ -371,7 +371,7 @@ export function transformGmailThread(thread: GmailThread): NewThreadWithNotes { source: canonicalUrl, type: ThreadType.Note, title: subject || "Email", - start: new Date(parseInt(parentMessage.internalDate)), + created: new Date(parseInt(parentMessage.internalDate)), meta: { threadId: thread.id, historyId: thread.historyId, diff --git a/tools/google-calendar/src/google-api.ts b/tools/google-calendar/src/google-api.ts index 07b1370..59056bb 100644 --- a/tools/google-calendar/src/google-api.ts +++ b/tools/google-calendar/src/google-api.ts @@ -1,5 +1,6 @@ import type { NewThread } from "@plotday/twister"; import { ThreadType, ConferencingProvider } from "@plotday/twister"; +import type { NewSchedule, NewScheduleOccurrence } from "@plotday/twister/schedule"; export type GoogleEvent = { id: string; @@ -361,8 +362,6 @@ export function transformGoogleEvent( const shared = { source: `google-calendar:${event.id}`, title: event.summary || (isCancelled ? "Cancelled event" : ""), - start: isCancelled ? null : start, - end: isCancelled ? null : end, meta: { id: event.id, calendarId: calendarId, @@ -388,36 +387,46 @@ export function transformGoogleEvent( ? { type: ThreadType.Note, ...shared } : { type: ThreadType.Event, ...shared }; - // Handle recurrence for master events (not instances) - if (event.recurrence && !event.recurringEventId) { - thread.recurrenceRule = parseRRule(event.recurrence); + // Build schedule from start/end if not cancelled + if (!isCancelled && start) { + const schedule: Omit = { + start, + end: end ?? null, + }; - // Parse recurrence count (takes precedence over UNTIL) - const recurrenceCount = parseGoogleRecurrenceCount(event.recurrence); - if (recurrenceCount) { - thread.recurrenceCount = recurrenceCount; - } else { - // Parse recurrence end date for recurring threads if no count - const recurrenceUntil = parseGoogleRecurrenceEnd(event.recurrence); - if (recurrenceUntil) { - thread.recurrenceUntil = recurrenceUntil; + // Handle recurrence for master events (not instances) + if (event.recurrence && !event.recurringEventId) { + schedule.recurrenceRule = parseRRule(event.recurrence) ?? null; + + // Parse recurrence count (takes precedence over UNTIL) + const recurrenceCount = parseGoogleRecurrenceCount(event.recurrence); + if (recurrenceCount) { + schedule.recurrenceCount = recurrenceCount; + } else { + // Parse recurrence end date for recurring schedules if no count + const recurrenceUntil = parseGoogleRecurrenceEnd(event.recurrence); + if (recurrenceUntil) { + schedule.recurrenceUntil = recurrenceUntil; + } } - } - const exdates = parseExDates(event.recurrence); - if (exdates.length > 0) { - thread.recurrenceExdates = exdates; - } + const exdates = parseExDates(event.recurrence); + if (exdates.length > 0) { + schedule.recurrenceExdates = exdates; + } - // Parse RDATEs (additional occurrence dates not in the recurrence rule) - // and create ThreadOccurrenceUpdate entries for each - const rdates = parseRDates(event.recurrence); - if (rdates.length > 0) { - thread.occurrences = rdates.map((rdate) => ({ - occurrence: rdate, - start: rdate, - })); + // Parse RDATEs (additional occurrence dates not in the recurrence rule) + // and create schedule occurrence entries for each + const rdates = parseRDates(event.recurrence); + if (rdates.length > 0) { + thread.scheduleOccurrences = rdates.map((rdate) => ({ + occurrence: rdate, + start: rdate, + })); + } } + + thread.schedules = [schedule]; } return thread; diff --git a/tools/google-calendar/src/google-calendar.ts b/tools/google-calendar/src/google-calendar.ts index e71ca9a..ff20bfc 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/tools/google-calendar/src/google-calendar.ts @@ -3,11 +3,9 @@ import { type Thread, ActionType, type Action, - type ThreadOccurrence, ThreadType, type ActorId, ConferencingProvider, - type NewThreadOccurrence, type NewThreadWithNotes, type NewActor, type NewContact, @@ -18,6 +16,7 @@ import { Tool, type ToolBuilder, } from "@plotday/twister"; +import type { NewScheduleOccurrence } from "@plotday/twister/schedule"; import { type Calendar, type CalendarTool, @@ -774,8 +773,6 @@ export class GoogleCalendar type: ThreadType.Note, title: activityData.title, preview: "Cancelled", - start: activityData.start || null, - end: activityData.end || null, meta: activityData.meta ?? null, notes: [cancelNote], ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates @@ -791,10 +788,11 @@ export class GoogleCalendar } // For recurring events, DON'T add tags at series level - // Tags (RSVPs) should be per-occurrence via the occurrences array + // Tags (RSVPs) should be per-occurrence via the scheduleOccurrences array // For non-recurring events, add tags normally + const isRecurring = !!activityData.schedules?.[0]?.recurrenceRule; let tags: Partial> | null = null; - if (validAttendees.length > 0 && !activityData.recurrenceRule) { + if (validAttendees.length > 0 && !isRecurring) { const attendTags: NewActor[] = []; const skipTags: NewActor[] = []; const undecidedTags: NewActor[] = []; @@ -898,19 +896,15 @@ export class GoogleCalendar const shared = { source: canonicalUrl, created: event.created ? new Date(event.created) : undefined, - start: activityData.start || null, - end: activityData.end || null, - recurrenceUntil: activityData.recurrenceUntil || null, - recurrenceCount: activityData.recurrenceCount || null, title: activityData.title || "", author: authorContact, - recurrenceRule: activityData.recurrenceRule || null, - recurrenceExdates: activityData.recurrenceExdates || null, meta: activityData.meta ?? null, tags: tags || undefined, actions: hasActions ? actions : undefined, notes, preview: hasDescription ? description : null, + schedules: activityData.schedules, + scheduleOccurrences: activityData.scheduleOccurrences, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only } as const; @@ -964,9 +958,9 @@ export class GoogleCalendar // Transform the instance data const instanceData = transformGoogleEvent(event, calendarId); - // Handle cancelled recurring instances by adding to recurrence exdates + // Handle cancelled recurring instances via archived schedule occurrence if (event.status === "cancelled") { - // Extract start/end from the event (they're present even for cancelled events) + // Extract start from the event for the occurrence const start = event.start?.dateTime ? new Date(event.start.dateTime) : event.start?.date @@ -979,13 +973,18 @@ export class GoogleCalendar ? event.end.date : null; + const cancelledOccurrence: NewScheduleOccurrence = { + occurrence: new Date(originalStartTime), + start: start, + end: end, + archived: true, + }; + const occurrenceUpdate = { type: ThreadType.Event, source: masterCanonicalUrl, - start: start, - end: end, meta: { syncProvider: "google", syncableId: calendarId }, - addRecurrenceExdates: [new Date(originalStartTime)], + scheduleOccurrences: [cancelledOccurrence], }; await this.tools.callbacks.run(callbackToken, occurrenceUpdate); @@ -1025,36 +1024,35 @@ export class GoogleCalendar if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; } - // Build occurrence object - // Always include start to ensure upsert_activity can infer scheduling when - // creating a new master thread. Use instanceData.start if available (for - // rescheduled instances), otherwise fall back to originalStartTime. - const occurrenceStart = instanceData.start ?? new Date(originalStartTime); + // Build schedule occurrence object + // Always include start to ensure upsert can infer scheduling when + // creating a new master thread. Use instanceData schedule start if available + // (for rescheduled instances), otherwise fall back to originalStartTime. + const instanceSchedule = instanceData.schedules?.[0]; + const occurrenceStart = instanceSchedule?.start ?? new Date(originalStartTime); - const occurrence: Omit = { + const occurrence: NewScheduleOccurrence = { occurrence: new Date(originalStartTime), start: occurrenceStart, tags: Object.keys(tags).length > 0 ? tags : undefined, ...(initialSync ? { unread: false } : {}), }; - // Add additional field overrides if present - if (instanceData.end !== undefined && instanceData.end !== null) { - occurrence.end = instanceData.end; + // Add end override if present on the instance + if (instanceSchedule?.end !== undefined && instanceSchedule?.end !== null) { + occurrence.end = instanceSchedule.end; } - if (instanceData.title) occurrence.title = instanceData.title; - if (instanceData.meta) occurrence.meta = instanceData.meta; // Send occurrence data to the twist via callback // The twist will decide whether to create or update the master thread - // Build a minimal NewThread with source and occurrences + // Build a minimal NewThread with source and scheduleOccurrences // The twist's createThread will upsert the master thread const occurrenceUpdate = { type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "google", syncableId: calendarId }, - occurrences: [occurrence], + scheduleOccurrences: [occurrence], }; await this.tools.callbacks.run(callbackToken, occurrenceUpdate); @@ -1174,7 +1172,6 @@ export class GoogleCalendar changes: { tagsAdded: Record; tagsRemoved: Record; - occurrence?: ThreadOccurrence; } ): Promise { try { @@ -1279,13 +1276,8 @@ export class GoogleCalendar } // Determine the event ID to update - let eventId = baseEventId; - if (changes.occurrence) { - eventId = this.constructInstanceId( - baseEventId, - changes.occurrence.occurrence - ); - } + // Note: occurrence-level RSVP changes are handled at the master event level + const eventId = baseEventId; // For each actor who changed RSVP, use actAs() to sync with their credentials. // If the actor has auth, the callback fires immediately. diff --git a/tools/jira/src/jira.ts b/tools/jira/src/jira.ts index 66fad42..b09c1f4 100644 --- a/tools/jira/src/jira.ts +++ b/tools/jira/src/jira.ts @@ -556,7 +556,7 @@ export class Jira extends Tool implements ProjectTool { }); } - // Handle workflow state transitions based on start + done combination + // Handle workflow state transitions based on assignee + done combination // Get available transitions for this issue const transitions = await client.issues.getTransitions({ issueIdOrKey: issueKey, @@ -576,8 +576,8 @@ export class Jira extends Tool implements ProjectTool { t.to?.name?.toLowerCase() === "closed" || t.to?.name?.toLowerCase() === "resolved" ); - } else if (thread.start !== null) { - // In Progress - look for "Start Progress" or "In Progress" transition + } else if (thread.assignee !== null) { + // In Progress (has assignee, not done) - look for "Start Progress" or "In Progress" transition targetTransition = transitions.transitions?.find( (t) => t.name?.toLowerCase() === "start progress" || @@ -585,7 +585,7 @@ export class Jira extends Tool implements ProjectTool { t.to?.name?.toLowerCase() === "in progress" ); } else { - // Backlog/Todo - look for "To Do", "Open", or "Reopen" transition + // Backlog/Todo (no assignee, not done) - look for "To Do", "Open", or "Reopen" transition targetTransition = transitions.transitions?.find( (t) => t.name?.toLowerCase() === "reopen" || diff --git a/tools/linear/src/linear.ts b/tools/linear/src/linear.ts index a394276..ef2abea 100644 --- a/tools/linear/src/linear.ts +++ b/tools/linear/src/linear.ts @@ -476,7 +476,6 @@ export class Linear extends Tool implements ProjectTool { author: authorContact, assignee: assigneeContact ?? null, done: issue.completedAt ?? issue.canceledAt ?? null, - start: assigneeContact ? undefined : null, order: issue.sortOrder, meta: { linearId: issue.id, @@ -566,7 +565,7 @@ export class Linear extends Tool implements ProjectTool { } } - // Handle state based on start + done combination + // Handle state based on assignee + done combination const team = await issue.team; if (team) { const states = await team.states(); @@ -581,13 +580,13 @@ export class Linear extends Tool implements ProjectTool { s.name === "Completed" || s.type === "completed" ); - } else if (thread.start !== null) { - // In Progress (has start date, not done) + } else if (thread.assignee !== null) { + // In Progress (has assignee, not done) targetState = states.nodes.find( (s) => s.name === "In Progress" || s.type === "started" ); } else { - // Backlog/Todo (no start date, not done) + // Backlog/Todo (no assignee, not done) targetState = states.nodes.find( (s) => s.name === "Todo" || s.name === "Backlog" || s.type === "unstarted" @@ -761,7 +760,6 @@ export class Linear extends Tool implements ProjectTool { : issue.canceledAt ? new Date(issue.canceledAt) : null, - start: assigneeContact ? undefined : null, order: issue.sortOrder, meta: { linearId: issue.id, diff --git a/tools/outlook-calendar/src/graph-api.ts b/tools/outlook-calendar/src/graph-api.ts index 46397a4..aaf4e8c 100644 --- a/tools/outlook-calendar/src/graph-api.ts +++ b/tools/outlook-calendar/src/graph-api.ts @@ -1,5 +1,6 @@ import type { NewThread } from "@plotday/twister"; import { ThreadType } from "@plotday/twister"; +import type { NewSchedule, NewScheduleOccurrence } from "@plotday/twister/schedule"; import type { Calendar } from "@plotday/twister/common/calendar"; /** @@ -495,8 +496,6 @@ export function transformOutlookEvent( ? `Cancelled: ${event.subject}` : "Cancelled Event" : event.subject || "", - start: isCancelled ? null : start, - end: isCancelled ? null : end, meta: { eventId: event.id, calendarId: calendarId, @@ -513,36 +512,48 @@ export function transformOutlookEvent( ? { type: ThreadType.Note, ...shared } : { type: ThreadType.Event, ...shared }; - // Handle recurrence for master events (not instances or exceptions) - if (event.recurrence && event.type === "seriesMaster") { - thread.recurrenceRule = parseOutlookRRule(event.recurrence); + // Build the primary schedule from start/end + if (!isCancelled && start) { + const schedule: Omit = { + start, + end: end ?? null, + }; - // Parse recurrence count (takes precedence over UNTIL) - const recurrenceCount = parseOutlookRecurrenceCount(event.recurrence); - if (recurrenceCount) { - thread.recurrenceCount = recurrenceCount; - } else { - // Parse recurrence end date if no count - const recurrenceUntil = parseOutlookRecurrenceEnd(event.recurrence); - if (recurrenceUntil) { - thread.recurrenceUntil = recurrenceUntil; + // Handle recurrence for master events (not instances or exceptions) + if (event.recurrence && event.type === "seriesMaster") { + schedule.recurrenceRule = parseOutlookRRule(event.recurrence) ?? null; + + // Parse recurrence count (takes precedence over UNTIL) + const recurrenceCount = parseOutlookRecurrenceCount(event.recurrence); + if (recurrenceCount) { + schedule.recurrenceCount = recurrenceCount; + } else { + // Parse recurrence end date if no count + const recurrenceUntil = parseOutlookRecurrenceEnd(event.recurrence); + if (recurrenceUntil) { + schedule.recurrenceUntil = recurrenceUntil; + } } - } - // Parse exception dates (currently not available from Graph API directly) - const exdates = parseOutlookExDates(event.recurrence); - if (exdates.length > 0) { - thread.recurrenceExdates = exdates; + // Parse exception dates (currently not available from Graph API directly) + const exdates = parseOutlookExDates(event.recurrence); + if (exdates.length > 0) { + schedule.recurrenceExdates = exdates; + } } + thread.schedules = [schedule]; + // Parse RDATEs (additional occurrence dates not in the recurrence rule) // Note: Microsoft Graph API doesn't support RDATE, so this will always be empty - const rdates = parseOutlookRDates(event.recurrence); - if (rdates.length > 0) { - thread.occurrences = rdates.map((rdate) => ({ - occurrence: rdate, - start: rdate, - })); + if (event.recurrence && event.type === "seriesMaster") { + const rdates = parseOutlookRDates(event.recurrence); + if (rdates.length > 0) { + thread.scheduleOccurrences = rdates.map((rdate) => ({ + occurrence: rdate, + start: rdate, + })); + } } } diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/tools/outlook-calendar/src/outlook-calendar.ts index d159843..85b8a3d 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/tools/outlook-calendar/src/outlook-calendar.ts @@ -2,12 +2,10 @@ import { type Thread, type Action, ActionType, - type ThreadOccurrence, ThreadType, type ActorId, ConferencingProvider, type ContentType, - type NewThreadOccurrence, type NewThreadWithNotes, type NewActor, type NewContact, @@ -18,6 +16,7 @@ import { Tool, type ToolBuilder, } from "@plotday/twister"; +import type { NewScheduleOccurrence } from "@plotday/twister/schedule"; import type { Calendar, CalendarTool, @@ -565,10 +564,11 @@ export class OutlookCalendar } // For recurring events, DON'T add tags at series level - // Tags (RSVPs) should be per-occurrence via the occurrences array + // Tags (RSVPs) should be per-occurrence via the scheduleOccurrences array // For non-recurring events, add tags normally let tags: Partial> | null = null; - if (validAttendees.length > 0 && !threadData.recurrenceRule) { + const hasRecurrence = !!threadData.schedules?.[0]?.recurrenceRule; + if (validAttendees.length > 0 && !hasRecurrence) { const attendTags: NewActor[] = []; const skipTags: NewActor[] = []; const undecidedTags: NewActor[] = []; @@ -709,18 +709,19 @@ export class OutlookCalendar return; // Skip deleted events } - // Handle cancelled recurring instances by adding to recurrence exdates + // Handle cancelled recurring instances by archiving the occurrence if (event.isCancelled) { - const start = instanceData?.start ?? new Date(originalStart); - const end = instanceData?.end ?? null; + const cancelledOccurrence: NewScheduleOccurrence = { + occurrence: new Date(originalStart), + start: new Date(originalStart), + archived: true, + }; const occurrenceUpdate = { type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, - start: start, - end: end, - addRecurrenceExdates: [new Date(originalStart)], + scheduleOccurrences: [cancelledOccurrence], }; await this.tools.callbacks.run(callbackToken, occurrenceUpdate); @@ -764,36 +765,35 @@ export class OutlookCalendar if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; } - // Build occurrence object - // Always include start to ensure upsert_activity can infer scheduling when - // creating a new master thread. Use instanceData.start if available (for - // rescheduled instances), otherwise fall back to originalStart. - const occurrenceStart = instanceData.start ?? new Date(originalStart); + // Build schedule occurrence object + // Always include start to ensure upsert can infer scheduling when + // creating a new master thread. Use schedule start from instanceData if + // available (for rescheduled instances), otherwise fall back to originalStart. + const instanceSchedule = instanceData.schedules?.[0]; + const occurrenceStart = instanceSchedule?.start ?? new Date(originalStart); - const occurrence: Omit = { + const occurrence: NewScheduleOccurrence = { occurrence: new Date(originalStart), start: occurrenceStart, tags: Object.keys(tags).length > 0 ? tags : undefined, ...(initialSync ? { unread: false } : {}), }; - // Add additional field overrides if present - if (instanceData.end !== undefined && instanceData.end !== null) { - occurrence.end = instanceData.end; + // Add end time override if present + if (instanceSchedule?.end !== undefined && instanceSchedule?.end !== null) { + occurrence.end = instanceSchedule.end; } - if (instanceData.title) occurrence.title = instanceData.title; - if (instanceData.meta) occurrence.meta = instanceData.meta; // Send occurrence data to the twist via callback // The twist will decide whether to create or update the master thread - // Build a minimal NewThread with source and occurrences + // Build a minimal NewThread with source and scheduleOccurrences // The twist's createThread will upsert the master thread const occurrenceUpdate = { type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, - occurrences: [occurrence], + scheduleOccurrences: [occurrence], }; await this.tools.callbacks.run(callbackToken, occurrenceUpdate); @@ -850,7 +850,6 @@ export class OutlookCalendar changes: { tagsAdded: Record; tagsRemoved: Record; - occurrence?: ThreadOccurrence; } ): Promise { try { @@ -956,35 +955,7 @@ export class OutlookCalendar } // Determine the event ID to update - // If this is an occurrence-level change, look up the instance ID - let eventId = baseEventId; - if (changes.occurrence) { - const occurrenceDate = - changes.occurrence.occurrence instanceof Date - ? changes.occurrence.occurrence - : new Date(changes.occurrence.occurrence); - - try { - const api = await this.getApi(calendarId as string); - const instanceId = await this.getEventInstanceIdWithApi( - api, - calendarId as string, - baseEventId, - occurrenceDate - ); - if (instanceId) { - eventId = instanceId; - } else { - console.warn( - `Could not find instance ID for occurrence ${occurrenceDate.toISOString()}` - ); - return; - } - } catch (error) { - console.error(`Failed to look up instance ID:`, error); - return; - } - } + const eventId = baseEventId; // For each actor who changed RSVP, use actAs() to sync with their credentials. // If the actor has auth, the callback fires immediately. diff --git a/tools/slack/src/slack-api.ts b/tools/slack/src/slack-api.ts index 8cc8375..c6d0c74 100644 --- a/tools/slack/src/slack-api.ts +++ b/tools/slack/src/slack-api.ts @@ -263,7 +263,7 @@ export function transformSlackThread( source: canonicalUrl, type: ThreadType.Note, title, - start: new Date(parseFloat(parentMessage.ts) * 1000), + created: new Date(parseFloat(parentMessage.ts) * 1000), meta: { channelId: channelId, threadTs: threadTs, diff --git a/twister/package.json b/twister/package.json index 49d1e78..09ab7ab 100644 --- a/twister/package.json +++ b/twister/package.json @@ -25,11 +25,21 @@ "types": "./dist/tool.d.ts", "default": "./dist/tool.js" }, + "./source": { + "@plotday/source": "./src/source.ts", + "types": "./dist/source.d.ts", + "default": "./dist/source.js" + }, "./plot": { "@plotday/source": "./src/plot.ts", "types": "./dist/plot.d.ts", "default": "./dist/plot.js" }, + "./schedule": { + "@plotday/source": "./src/schedule.ts", + "types": "./dist/schedule.d.ts", + "default": "./dist/schedule.js" + }, "./tag": { "@plotday/source": "./src/tag.ts", "types": "./dist/tag.d.ts", diff --git a/twister/src/common/calendar.ts b/twister/src/common/calendar.ts index 93aa8ac..93f9791 100644 --- a/twister/src/common/calendar.ts +++ b/twister/src/common/calendar.ts @@ -1,5 +1,3 @@ -import type { NewThreadWithNotes, Serializable } from "../index"; - /** * Represents a calendar from an external calendar service. * @@ -47,36 +45,21 @@ export type SyncOptions = { }; /** - * Base interface for calendar integration tools. + * Base interface for calendar integration sources. * - * Defines the standard operations that all calendar tools must implement + * Defines the standard operations that all calendar sources must implement * to integrate with external calendar services like Google Calendar, * Outlook Calendar, etc. * - * **Architecture: Tools Build, Twists Save** - * - * Calendar tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) - * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) - * - * This separation allows: - * - Tools to be reusable across different twists with different behaviors - * - Twists to have full control over what gets saved and how - * - Easier testing of tools in isolation + * Sources save threads directly via `integrations.saveThread()` rather than + * passing data through callbacks to a separate twist. * * **Implementation Pattern:** * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Tool declares providers and lifecycle callbacks in build() - * 3. onAuthorized lists available calendars and calls setSyncables() - * 4. User enables calendars in the modal → onSyncEnabled fires - * 5. **Tool builds NewThread objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createThread/updateThread - * - * **Tool Implementation Rules:** - * - **DO** build Thread/Note objects from external data - * - **DO** pass them to the twist via the callback - * - **DON'T** call plot.createThread/updateThread directly - * - **DON'T** save anything to Plot database + * 2. Source declares providers and lifecycle callbacks in build() + * 3. getChannels returns available calendars + * 4. User enables calendars in the modal -> onChannelEnabled fires + * 5. Source fetches events and saves them directly via integrations.saveThread() * * **Recommended Data Sync Strategy:** * Use Thread.source and Note.key for automatic upserts without manual ID tracking. @@ -84,20 +67,24 @@ export type SyncOptions = { * * @example * ```typescript - * class MyCalendarTwist extends Twist { + * class MyCalendarSource extends Source { * build(build: ToolBuilder) { * return { - * googleCalendar: build(GoogleCalendar), - * plot: build(Plot, { thread: { access: ThreadAccess.Create } }), + * integrations: build(Integrations, { + * providers: [{ + * provider: AuthProvider.Google, + * scopes: MyCalendarSource.SCOPES, + * getChannels: this.getChannels, + * onChannelEnabled: this.onChannelEnabled, + * onChannelDisabled: this.onChannelDisabled, + * }] + * }), * }; * } - * - * // Auth and calendar selection handled in the twist edit modal. - * // Events are delivered via the startSync callback. * } * ``` */ -export type CalendarTool = { +export type CalendarSource = { /** * Retrieves the list of calendars accessible to the authenticated user. * @@ -111,8 +98,8 @@ export type CalendarTool = { * Begins synchronizing events from a specific calendar. * * Sets up real-time sync for the specified calendar, including initial - * event import and ongoing change notifications. The callback function - * will be invoked for each synced event. + * event import and ongoing change notifications. Events are saved + * directly via integrations.saveThread(). * * Auth is obtained automatically via integrations.get(provider, calendarId). * @@ -120,20 +107,13 @@ export type CalendarTool = { * @param options.calendarId - ID of the calendar to sync * @param options.timeMin - Earliest date to sync events from (inclusive) * @param options.timeMax - Latest date to sync events to (exclusive) - * @param callback - Function receiving (thread, ...extraArgs) for each synced event - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete * @throws When no valid authorization or calendar doesn't exist */ - startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + startSync( options: { calendarId: string; } & SyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise; /** @@ -144,3 +124,6 @@ export type CalendarTool = { */ stopSync(calendarId: string): Promise; }; + +/** @deprecated Use CalendarSource instead */ +export type CalendarTool = CalendarSource; diff --git a/twister/src/common/documents.ts b/twister/src/common/documents.ts index fd21045..75947ab 100644 --- a/twister/src/common/documents.ts +++ b/twister/src/common/documents.ts @@ -1,7 +1,5 @@ import type { ThreadMeta, - NewThreadWithNotes, - Serializable, } from "../index"; /** @@ -34,24 +32,20 @@ export type DocumentSyncOptions = { }; /** - * Base interface for document service integration tools. + * Base interface for document service integration sources. * * All synced documents are converted to ThreadWithNotes objects. * Each document becomes a Thread with Notes for the description and comments. * - * **Architecture: Tools Build, Twists Save** - * - * Document tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) - * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) + * Sources save threads directly via `integrations.saveThread()` rather than + * passing data through callbacks to a separate twist. * * **Implementation Pattern:** * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Tool declares providers and lifecycle callbacks in build() - * 3. onAuthorized lists available folders and calls setSyncables() - * 4. User enables folders in the modal → onSyncEnabled fires - * 5. **Tool builds NewThread objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createThread/updateThread + * 2. Source declares providers and lifecycle callbacks in build() + * 3. getChannels returns available folders + * 4. User enables folders in the modal -> onChannelEnabled fires + * 5. Source fetches documents and saves them directly via integrations.saveThread() * * **Recommended Data Sync Strategy:** * Use Thread.source and Note.key for automatic upserts. @@ -65,7 +59,7 @@ export type DocumentSyncOptions = { * - Send NewThreadWithNotes for all documents (creates new or updates existing) * - Set `thread.unread = false` for initial sync, omit for incremental updates */ -export type DocumentTool = { +export type DocumentSource = { /** * Retrieves the list of folders accessible to the user. * @@ -84,19 +78,12 @@ export type DocumentTool = { * @param options - Sync configuration options * @param options.folderId - ID of the folder to sync * @param options.timeMin - Earliest date to sync documents from (inclusive) - * @param callback - Function receiving (thread, ...extraArgs) for each synced document - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete */ - startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + startSync( options: { folderId: string; } & DocumentSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise; /** @@ -146,3 +133,6 @@ export type DocumentTool = { noteId?: string, ): Promise; }; + +/** @deprecated Use DocumentSource instead */ +export type DocumentTool = DocumentSource; diff --git a/twister/src/common/messaging.ts b/twister/src/common/messaging.ts index 1f68f3d..4147844 100644 --- a/twister/src/common/messaging.ts +++ b/twister/src/common/messaging.ts @@ -1,5 +1,3 @@ -import type { NewThreadWithNotes, Serializable } from "../index"; - /** * Represents a channel from an external messaging service. * @@ -30,30 +28,26 @@ export type MessageSyncOptions = { }; /** - * Base interface for email and chat integration tools. + * Base interface for email and chat integration sources. * * All synced messages/emails are converted to ThreadWithNotes objects. * Each email thread or chat conversation becomes a Thread with Notes for each message. * - * **Architecture: Tools Build, Twists Save** - * - * Messaging tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) - * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) + * Sources save threads directly via `integrations.saveThread()` rather than + * passing data through callbacks to a separate twist. * * **Implementation Pattern:** * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Tool declares providers and lifecycle callbacks in build() - * 3. onAuthorized lists available channels and calls setSyncables() - * 4. User enables channels in the modal → onSyncEnabled fires - * 5. **Tool builds NewThread objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createThread/updateThread + * 2. Source declares providers and lifecycle callbacks in build() + * 3. getChannels returns available messaging channels + * 4. User enables channels in the modal -> onChannelEnabled fires + * 5. Source fetches messages and saves them directly via integrations.saveThread() * * **Recommended Data Sync Strategy:** * Use Thread.source (thread URL or ID) and Note.key (message ID) for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ -export type MessagingTool = { +export type MessagingSource = { /** * Retrieves the list of conversation channels (inboxes, channels) accessible to the user. * @@ -70,19 +64,12 @@ export type MessagingTool = { * @param options - Sync configuration options * @param options.channelId - ID of the channel (e.g., channel, inbox) to sync * @param options.timeMin - Earliest date to sync events from (inclusive) - * @param callback - Function receiving (thread, ...extraArgs) for each synced conversation - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete */ - startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + startSync( options: { channelId: string; } & MessageSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise; /** @@ -93,3 +80,6 @@ export type MessagingTool = { */ stopSync(channelId: string): Promise; }; + +/** @deprecated Use MessagingSource instead */ +export type MessagingTool = MessagingSource; diff --git a/twister/src/common/projects.ts b/twister/src/common/projects.ts index a2215e4..0cb21d3 100644 --- a/twister/src/common/projects.ts +++ b/twister/src/common/projects.ts @@ -1,8 +1,6 @@ import type { Thread, ThreadMeta, - NewThreadWithNotes, - Serializable, } from "../index"; /** @@ -35,30 +33,26 @@ export type ProjectSyncOptions = { }; /** - * Base interface for project management integration tools. + * Base interface for project management integration sources. * * All synced issues/tasks are converted to ThreadWithNotes objects. * Each issue becomes a Thread with Notes for the description and comments. * - * **Architecture: Tools Build, Twists Save** - * - * Project tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) - * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) + * Sources save threads directly via `integrations.saveThread()` rather than + * passing data through callbacks to a separate twist. * * **Implementation Pattern:** * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Tool declares providers and lifecycle callbacks in build() - * 3. onAuthorized lists available projects and calls setSyncables() - * 4. User enables projects in the modal → onSyncEnabled fires - * 5. **Tool builds NewThread objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createThread/updateThread + * 2. Source declares providers and lifecycle callbacks in build() + * 3. getChannels returns available projects + * 4. User enables projects in the modal -> onChannelEnabled fires + * 5. Source fetches issues and saves them directly via integrations.saveThread() * * **Recommended Data Sync Strategy:** * Use Thread.source (issue URL) and Note.key for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ -export type ProjectTool = { +export type ProjectSource = { /** * Retrieves the list of projects accessible to the user. * @@ -75,19 +69,12 @@ export type ProjectTool = { * @param options - Sync configuration options * @param options.projectId - ID of the project to sync * @param options.timeMin - Earliest date to sync issues from (inclusive) - * @param callback - Function receiving (thread, ...extraArgs) for each synced issue - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete */ - startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + startSync( options: { projectId: string; } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise; /** @@ -132,3 +119,6 @@ export type ProjectTool = { noteId?: string, ): Promise; }; + +/** @deprecated Use ProjectSource instead */ +export type ProjectTool = ProjectSource; diff --git a/twister/src/common/source-control.ts b/twister/src/common/source-control.ts index cd78b34..4ff4dac 100644 --- a/twister/src/common/source-control.ts +++ b/twister/src/common/source-control.ts @@ -1,8 +1,6 @@ import type { Thread, ThreadMeta, - NewThreadWithNotes, - Serializable, } from "../index"; /** @@ -41,31 +39,27 @@ export type SourceControlSyncOptions = { }; /** - * Base interface for source control integration tools. + * Base interface for source control integration sources. * * All synced pull requests are converted to ThreadWithNotes objects. * Each PR becomes a Thread with Notes for the description, comments, * and review summaries. * - * **Architecture: Tools Build, Twists Save** - * - * Source control tools follow Plot's core architectural principle: - * - **Tools**: Fetch external data and transform it into Plot format (NewThread objects) - * - **Twists**: Receive the data and decide what to do with it (create, update, filter, etc.) + * Sources save threads directly via `integrations.saveThread()` rather than + * passing data through callbacks to a separate twist. * * **Implementation Pattern:** * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Tool declares providers and lifecycle callbacks in build() - * 3. onAuthorized lists available repositories and calls setSyncables() - * 4. User enables repositories in the modal → onSyncEnabled fires - * 5. **Tool builds NewThread objects** and passes them to the twist via callback - * 6. **Twist decides** whether to save using createThread/updateThread + * 2. Source declares providers and lifecycle callbacks in build() + * 3. getChannels returns available repositories + * 4. User enables repositories in the modal -> onChannelEnabled fires + * 5. Source fetches PRs and saves them directly via integrations.saveThread() * * **Recommended Data Sync Strategy:** * Use Thread.source (PR URL) and Note.key for automatic upserts. * See SYNC_STRATEGIES.md for detailed patterns. */ -export type SourceControlTool = { +export type SourceControlSource = { /** * Retrieves the list of repositories accessible to the user. * @@ -82,19 +76,12 @@ export type SourceControlTool = { * @param options - Sync configuration options * @param options.repositoryId - ID of the repository to sync (owner/repo format) * @param options.timeMin - Earliest date to sync PRs from (inclusive) - * @param callback - Function receiving (thread, ...extraArgs) for each synced PR - * @param extraArgs - Additional arguments to pass to the callback (type-checked, no functions allowed) * @returns Promise that resolves when sync setup is complete */ - startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + startSync( options: { repositoryId: string; } & SourceControlSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise; /** @@ -146,3 +133,6 @@ export type SourceControlTool = { */ closePR?(meta: ThreadMeta): Promise; }; + +/** @deprecated Use SourceControlSource instead */ +export type SourceControlTool = SourceControlSource; diff --git a/twister/src/index.ts b/twister/src/index.ts index a0bc135..8f3a172 100644 --- a/twister/src/index.ts +++ b/twister/src/index.ts @@ -1,5 +1,7 @@ export * from "./twist"; +export * from "./source"; export * from "./plot"; +export * from "./schedule"; export * from "./tag"; export * from "./tool"; export * from "./tools"; diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 9172c69..6038646 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -1,3 +1,4 @@ +import type { NewSchedule, NewScheduleOccurrence } from "./schedule"; import { type Tag } from "./tag"; import { type Callback } from "./tools/callbacks"; import { type AuthProvider } from "./tools/integrations"; @@ -487,71 +488,8 @@ type ThreadFields = ThreadCommon & { * ``` */ assignee: Actor | null; - /** - * Start time of a scheduled thread. Notes are not typically scheduled unless they're about specific times. - * For recurring events, this represents the start of the first occurrence. - * Can be a Date object for timed events or a date string in "YYYY-MM-DD" format for all-day events. - * - * **Thread Scheduling States** (for Actions): - * - **Do Now** (current/actionable): When creating an Action, omitting `start` defaults to current time - * - **Do Later** (future scheduled): Set `start` to a future Date or date string - * - **Do Someday** (unscheduled backlog): Explicitly set `start: null` - * - * **Important for synced tasks**: When syncing unassigned backlog items from external systems, - * set BOTH `start: null` AND `assignee: null` to create unscheduled, unassigned actions. - * - * @example - * ```typescript - * // "Do Now" - assigned to twist owner, actionable immediately - * await this.tools.plot.createThread({ - * type: ThreadType.Action, - * title: "Urgent task" - * // start omitted → defaults to now - * // assignee omitted → defaults to twist owner - * }); - * - * // "Do Later" - scheduled for a specific time - * await this.tools.plot.createThread({ - * type: ThreadType.Action, - * title: "Future task", - * start: new Date("2025-02-01") - * }); - * - * // "Do Someday" - unassigned backlog item (common for synced tasks) - * await this.tools.plot.createThread({ - * type: ThreadType.Action, - * title: "Backlog task", - * start: null, // Explicitly unscheduled - * assignee: null // Explicitly unassigned - * }); - * ``` - */ - start: Date | string | null; - /** - * End time of a scheduled thread. Notes are not typically scheduled unless they're about specific times. - * For recurring events, this represents the end of the first occurrence. - * Can be a Date object for timed events or a date string in "YYYY-MM-DD" format for all-day events. - * Null for tasks or threads without defined end times. - */ - end: Date | string | null; - /** - * For recurring threads, the last occurrence date (inclusive). - * Can be a Date object, date string in "YYYY-MM-DD" format, or null if recurring indefinitely. - * When both recurrenceCount and recurrenceUntil are provided, recurrenceCount takes precedence. - */ - recurrenceUntil: Date | string | null; - /** - * For recurring threads, the number of occurrences to generate. - * Takes precedence over recurrenceUntil if both are provided. - * Null for non-recurring threads or indefinite recurrence. - */ - recurrenceCount: number | null; /** The priority context this thread belongs to */ priority: Priority; - /** Recurrence rule in RFC 5545 RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") */ - recurrenceRule: string | null; - /** Array of dates to exclude from the recurrence pattern */ - recurrenceExdates: Date[] | null; /** Metadata about the thread, typically from an external system that created it */ meta: ThreadMeta | null; /** Sort order for the thread (fractional positioning) */ @@ -590,121 +528,23 @@ export type NewThreadWithNotes = NewThread & { /** @deprecated Use NewThreadWithNotes instead */ export type NewActivityWithNotes = NewThreadWithNotes; -/** - * Represents a specific instance of a recurring thread. - * All field values are computed by merging the recurring thread's - * defaults with any occurrence-specific overrides. - */ -export type ThreadOccurrence = { - /** - * Original date/datetime of this occurrence. - * Use start for the occurrence's current start time. - * Format: Date object or "YYYY-MM-DD" for all-day events. - */ - occurrence: Date | string; +/** @deprecated ThreadOccurrence has moved to Schedule. Use ScheduleOccurrence from @plotday/twister/schedule instead. */ +export type ThreadOccurrence = never; - /** - * The recurring thread of which this is an occurrence. - */ - thread: Thread; +/** @deprecated Use ScheduleOccurrence from @plotday/twister/schedule instead */ +export type ActivityOccurrence = never; - /** - * Effective values for this occurrence (series defaults + overrides). - * These are the actual values that apply to this specific instance. - */ - start: Date | string; - end: Date | string | null; - done: Date | null; - title: string; - /** - * Meta is merged, with the occurrence's meta taking precedence. - */ - meta: ThreadMeta | null; +/** @deprecated Use NewScheduleOccurrence from @plotday/twister/schedule instead */ +export type NewThreadOccurrence = never; - /** - * Tags for this occurrence (merged with the recurring tags). - */ - tags: Tags; +/** @deprecated Use NewScheduleOccurrence from @plotday/twister/schedule instead */ +export type NewActivityOccurrence = never; - /** - * True if the occurrence is archived. - */ - archived: boolean; -}; +/** @deprecated Use ScheduleOccurrenceUpdate from @plotday/twister/schedule instead */ +export type ThreadOccurrenceUpdate = never; -/** @deprecated Use ThreadOccurrence instead */ -export type ActivityOccurrence = ThreadOccurrence; - -/** - * Type for creating or updating thread occurrences. - * - * Follows the same pattern as Thread/NewThread: - * - Required fields: `occurrence` (key) and `start` (for scheduling) - * - Optional fields: All others from ThreadOccurrence - * - Additional fields: `twistTags` for add/remove, `unread` for notification control - * - * @example - * ```typescript - * const thread: NewThread = { - * type: ThreadType.Event, - * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", - * occurrences: [ - * { - * occurrence: new Date("2025-01-27T14:00:00Z"), - * start: new Date("2025-01-27T14:00:00Z"), - * tags: { [Tag.Skip]: [user] } - * } - * ] - * }; - * ``` - */ -export type NewThreadOccurrence = Pick< - ThreadOccurrence, - "occurrence" | "start" -> & - Partial< - Omit - > & { - /** - * Tags specific to this occurrence. - * These replace any recurrence-level tags for this occurrence. - */ - tags?: NewTags; - - /** - * Add or remove the twist's tags on this occurrence. - * Maps tag ID to boolean: true = add tag, false = remove tag. - */ - twistTags?: Partial>; - - /** - * Whether this occurrence should be marked as unread for users. - * - undefined/omitted (default): Occurrence is unread for users, except auto-marked - * as read for the author if they are the twist owner (user) - * - true: Occurrence is explicitly unread for ALL users (use sparingly) - * - false: Occurrence is marked as read for all users - * - * For the default behavior, omit this field entirely. - * Use false for initial sync to avoid marking historical items as unread. - */ - unread?: boolean; - }; - -/** @deprecated Use NewThreadOccurrence instead */ -export type NewActivityOccurrence = NewThreadOccurrence; - -/** - * Inline type for creating/updating occurrences within NewThread/ThreadUpdate. - * Used to specify occurrence-specific overrides when creating or updating a recurring thread. - */ -export type ThreadOccurrenceUpdate = Pick< - NewThreadOccurrence, - "occurrence" -> & - Partial>; - -/** @deprecated Use ThreadOccurrenceUpdate instead */ -export type ActivityOccurrenceUpdate = ThreadOccurrenceUpdate; +/** @deprecated Use ScheduleOccurrenceUpdate from @plotday/twister/schedule instead */ +export type ActivityOccurrenceUpdate = never; /** * Configuration for automatic priority selection based on thread similarity. @@ -752,17 +592,13 @@ export type PickPriorityConfig = { * **Important: Defaults for Actions** * * When creating a Thread of type `Action`: - * - **`start` omitted** → Defaults to current time (now) → "Do Now" * - **`assignee` omitted** → Defaults to twist owner → Assigned action * - * To create unassigned backlog items (common for synced tasks), you MUST explicitly set BOTH: - * - `start: null` → "Do Someday" (unscheduled) + * To create unassigned backlog items (common for synced tasks), you MUST explicitly set: * - `assignee: null` → Unassigned * - * **Scheduling States**: - * - **"Do Now"** (actionable today): Omit `start` or set to current time - * - **"Do Later"** (scheduled): Set `start` to a future Date - * - **"Do Someday"** (backlog): Set `start: null` + * Scheduling is handled separately via the Schedule type. + * Use `plot.createSchedule()` to schedule threads. * * Priority can be specified in three ways: * 1. Explicit priority: `priority: { id: "..." }` - Use specific priority (disables pickPriority) @@ -771,43 +607,30 @@ export type PickPriorityConfig = { * * @example * ```typescript - * // "Do Now" - Assigned to twist owner, actionable immediately + * // Action assigned to twist owner * const urgentTask: NewThread = { * type: ThreadType.Action, * title: "Review pull request" - * // start omitted → defaults to now * // assignee omitted → defaults to twist owner * }; * - * // "Do Someday" - UNASSIGNED backlog item (for synced tasks) + * // UNASSIGNED backlog item (for synced tasks) * const backlogTask: NewThread = { * type: ThreadType.Action, * title: "Refactor user service", - * start: null, // Must explicitly set to null * assignee: null // Must explicitly set to null * }; * - * // "Do Later" - Scheduled for specific date - * const futureTask: NewThread = { - * type: ThreadType.Action, - * title: "Prepare Q1 review", - * start: new Date("2025-03-15") - * }; - * - * // Note (typically unscheduled) + * // Note * const note: NewThread = { * type: ThreadType.Note, - * title: "Meeting notes", - * content: "Discussed Q4 roadmap...", - * start: null // Notes typically don't have scheduled times + * title: "Meeting notes" * }; * - * // Event (always has explicit start/end times) + * // Event (schedule separately with plot.createSchedule()) * const event: NewThread = { * type: ThreadType.Event, - * title: "Team standup", - * start: new Date("2025-01-15T10:00:00"), - * end: new Date("2025-01-15T10:30:00") + * title: "Team standup" * }; * ``` */ @@ -904,50 +727,38 @@ export type NewThread = ( preview?: string | null; /** - * Create or update specific occurrences of a recurring thread. - * Each entry specifies overrides for a specific occurrence. + * Optional schedules to create alongside the thread. * - * When occurrence matches the recurrence rule but only tags are specified, - * the occurrence is created with just tags in activity_tag.occurrence (no activity_exception). + * When provided, schedules are created after the thread is inserted. + * The threadId is automatically filled from the created thread. * - * When any other field is specified, creates/updates an activity_exception row. + * For calendar integrations, this replaces the old start/end/recurrenceRule + * fields that were previously on the thread itself. * * @example * ```typescript - * // Create recurring event with per-occurrence RSVPs - * const meeting: NewThread = { + * const event: NewThread = { * type: ThreadType.Event, - * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", - * start: new Date("2025-01-20T14:00:00Z"), - * duration: 1800000, // 30 minutes - * occurrences: [ - * { - * occurrence: new Date("2025-01-27T14:00:00Z"), - * tags: { [Tag.Skip]: [{ email: "user@example.com" }] } - * }, - * { - * occurrence: new Date("2025-02-03T14:00:00Z"), - * start: new Date("2025-02-03T15:00:00Z"), // Reschedule this one - * tags: { [Tag.Attend]: [{ email: "user@example.com" }] } - * } - * ] + * title: "Team standup", + * schedules: [{ + * start: new Date("2025-01-15T10:00:00Z"), + * end: new Date("2025-01-15T10:30:00Z"), + * recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" + * }] * }; * ``` */ - occurrences?: NewThreadOccurrence[]; + schedules?: Array>; /** - * Dates to add to the recurrence exclusion list. - * These are merged with existing exdates. Use this for incremental updates - * (e.g., cancelling a single occurrence) instead of replacing the full list. - */ - addRecurrenceExdates?: Date[]; - - /** - * Dates to remove from the recurrence exclusion list. - * Use this to "uncancel" a previously excluded occurrence. + * Optional schedule occurrence overrides. For recurring schedules, + * these define per-occurrence modifications (e.g., rescheduled meetings, + * per-occurrence RSVP tags). + * + * Requires a schedule to be present (either via `schedules` field or + * an existing schedule on the thread). */ - removeRecurrenceExdates?: Date[]; + scheduleOccurrences?: NewScheduleOccurrence[]; }; /** @deprecated Use NewThread instead */ @@ -981,21 +792,10 @@ type ThreadBulkUpdateFields = Partial< /** * Fields supported by single-thread updates via `id` or `source`. - * Includes all bulk fields plus scheduling, recurrence, tags, and occurrences. + * Includes all bulk fields plus assignee, tags, and preview. */ type ThreadSingleUpdateFields = ThreadBulkUpdateFields & - Partial< - Pick< - ThreadFields, - | "start" - | "end" - | "assignee" - | "recurrenceRule" - | "recurrenceExdates" - | "recurrenceUntil" - | "recurrenceCount" - > - > & { + Partial> & { /** * Tags to change on the thread. Use an empty array of NewActor to remove a tag. * Use twistTags to add/remove the twist from tags to avoid clearing other actors' tags. @@ -1020,50 +820,6 @@ type ThreadSingleUpdateFields = ThreadBulkUpdateFields & * This field is write-only and won't be returned when reading threads. */ preview?: string | null; - - /** - * Create or update specific occurrences of this recurring thread. - * Each entry specifies overrides for a specific occurrence. - * - * Setting a field to null reverts it to the series default. - * Omitting a field leaves it unchanged. - * - * @example - * ```typescript - * // Update RSVPs for specific occurrences - * await plot.updateThread({ - * id: meetingId, - * occurrences: [ - * { - * occurrence: new Date("2025-01-27T14:00:00Z"), - * tags: { [Tag.Skip]: [user] } - * }, - * { - * occurrence: new Date("2025-02-03T14:00:00Z"), - * tags: { [Tag.Attend]: [user] } - * }, - * { - * occurrence: new Date("2025-02-10T14:00:00Z"), - * archived: true // Cancel this occurrence - * } - * ] - * }); - * ``` - */ - occurrences?: (NewThreadOccurrence | ThreadOccurrenceUpdate)[]; - - /** - * Dates to add to the recurrence exclusion list. - * These are merged with existing exdates. Use this for incremental updates - * (e.g., cancelling a single occurrence) instead of replacing the full list. - */ - addRecurrenceExdates?: Date[]; - - /** - * Dates to remove from the recurrence exclusion list. - * Use this to "uncancel" a previously excluded occurrence. - */ - removeRecurrenceExdates?: Date[]; }; export type ThreadUpdate = diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts new file mode 100644 index 0000000..96dbf73 --- /dev/null +++ b/twister/src/schedule.ts @@ -0,0 +1,213 @@ +import { type Tag } from "./tag"; +import { type ActorId, type NewTags, type Tags } from "./plot"; +import { Uuid } from "./utils/uuid"; + +export { Uuid } from "./utils/uuid"; + +/** + * Represents a schedule entry for a thread. + * + * Schedules define when a thread occurs in time. A thread may have zero or more schedules: + * - Shared schedules (userId is null): visible to all members of the thread's priority + * - Per-user schedules (userId set): private ordering/scheduling for a specific user + * + * For recurring events, start/end represent the first occurrence, with recurrenceRule + * defining the pattern. + */ +export type Schedule = { + /** Unique identifier for the schedule */ + id: Uuid; + /** When this schedule was created */ + createdAt: Date; + /** When this schedule was last updated */ + updatedAt: Date; + /** Whether this schedule has been archived */ + archivedAt: Date | null; + /** If set, this is a per-user schedule visible only to this user */ + userId: ActorId | null; + /** Per-user ordering within a day (only set for per-user schedules) */ + order: number | null; + /** + * Start time of the schedule. + * Date object for timed events, date string in "YYYY-MM-DD" format for all-day events. + */ + start: Date | string | null; + /** + * End time of the schedule. + * Date object for timed events, date string in "YYYY-MM-DD" format for all-day events. + */ + end: Date | string | null; + /** Recurrence rule in RFC 5545 RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") */ + recurrenceRule: string | null; + /** Duration of each occurrence in milliseconds (required for recurring schedules) */ + duration: number | null; + /** Array of dates to exclude from the recurrence pattern */ + recurrenceExdates: Date[] | null; + /** + * For occurrence exceptions: the original date/time of this occurrence in the series. + * Format: Date object or "YYYY-MM-DD" for all-day events. + */ + occurrence: Date | string | null; + /** The thread this schedule belongs to */ + threadId: Uuid; +}; + +/** + * Type for creating new schedules. + * + * Requires `threadId` and `start`. All other fields are optional. + * + * @example + * ```typescript + * // Simple timed event + * const schedule: NewSchedule = { + * threadId: threadId, + * start: new Date("2025-03-15T10:00:00Z"), + * end: new Date("2025-03-15T11:00:00Z") + * }; + * + * // All-day event + * const allDay: NewSchedule = { + * threadId: threadId, + * start: "2025-03-15", + * end: "2025-03-16" + * }; + * + * // Recurring weekly event + * const recurring: NewSchedule = { + * threadId: threadId, + * start: new Date("2025-01-20T14:00:00Z"), + * end: new Date("2025-01-20T15:00:00Z"), + * recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", + * recurrenceUntil: new Date("2025-06-30") + * }; + * ``` + */ +export type NewSchedule = { + /** The thread this schedule belongs to */ + threadId: Uuid; + /** + * Start time. Date for timed events, "YYYY-MM-DD" for all-day. + * Determines whether the schedule uses `at` (timed) or `on` (all-day) storage. + */ + start: Date | string; + /** End time. Date for timed events, "YYYY-MM-DD" for all-day. */ + end?: Date | string | null; + /** Recurrence rule in RFC 5545 RRULE format */ + recurrenceRule?: string | null; + /** + * For recurring schedules, the last occurrence date (inclusive). + * When both recurrenceCount and recurrenceUntil are provided, recurrenceCount takes precedence. + */ + recurrenceUntil?: Date | string | null; + /** + * For recurring schedules, the number of occurrences to generate. + * Takes precedence over recurrenceUntil if both are provided. + */ + recurrenceCount?: number | null; + /** Array of dates to exclude from the recurrence pattern */ + recurrenceExdates?: Date[] | null; + /** + * For occurrence exceptions: the original date/time of this occurrence. + */ + occurrence?: Date | string | null; + /** If set, this is a per-user schedule for the specified user */ + userId?: ActorId | null; + /** Per-user ordering (only valid with userId) */ + order?: number | null; + /** Whether to archive this schedule */ + archived?: boolean; +} & ( + | { + /** + * Unique identifier for the schedule, generated by Uuid.Generate(). + * Specifying an ID allows tools to track and upsert schedules. + */ + id: Uuid; + } + | { + /* No id required. An id will be generated and returned. */ + } +); + +/** + * Type for updating existing schedules. + * + * Must provide `id` to identify the schedule. All other fields are optional + * and only provided fields will be updated. + * + * @example + * ```typescript + * // Reschedule + * await plot.updateSchedule({ + * id: scheduleId, + * start: new Date("2025-03-20T10:00:00Z"), + * end: new Date("2025-03-20T11:00:00Z") + * }); + * + * // Archive a schedule + * await plot.updateSchedule({ + * id: scheduleId, + * archived: true + * }); + * ``` + */ +export type ScheduleUpdate = { + id: Uuid; +} & Partial>; + +/** + * Represents a specific instance of a recurring schedule. + * All field values are computed by merging the recurring schedule's + * defaults with any occurrence-specific overrides. + */ +export type ScheduleOccurrence = { + /** + * Original date/datetime of this occurrence. + * Use start for the occurrence's current start time. + */ + occurrence: Date | string; + + /** The recurring schedule of which this is an occurrence */ + schedule: Schedule; + + /** Effective start for this occurrence (series default + override) */ + start: Date | string; + /** Effective end for this occurrence */ + end: Date | string | null; + + /** Tags for this occurrence */ + tags: Tags; + + /** True if the occurrence is archived */ + archived: boolean; +}; + +/** + * Type for creating or updating schedule occurrences. + */ +export type NewScheduleOccurrence = Pick< + ScheduleOccurrence, + "occurrence" | "start" +> & + Partial< + Omit + > & { + /** Tags for this occurrence */ + tags?: NewTags; + + /** Add or remove the twist's tags on this occurrence */ + twistTags?: Partial>; + + /** Whether this occurrence should be marked as unread */ + unread?: boolean; + }; + +/** + * Type for updating schedule occurrences inline. + */ +export type ScheduleOccurrenceUpdate = Pick< + NewScheduleOccurrence, + "occurrence" +> & + Partial>; diff --git a/twister/src/source.ts b/twister/src/source.ts new file mode 100644 index 0000000..a2bd366 --- /dev/null +++ b/twister/src/source.ts @@ -0,0 +1,47 @@ +import { Twist } from "./twist"; + +/** + * Base class for sources - twists that sync data from external services. + * + * Sources are a specialization of Twist that save threads directly via + * `integrations.saveThread()` instead of using the Plot tool. They cannot + * access the Plot tool directly. + * + * Sources replace the old Tool + Twist pass-through pattern where Tools + * built data and passed it via callbacks to Twists which simply called + * `plot.createThread()`. + * + * @example + * ```typescript + * class GoogleCalendarSource extends Source { + * build(build: ToolBuilder) { + * return { + * integrations: build(Integrations, { + * providers: [{ + * provider: AuthProvider.Google, + * scopes: GoogleCalendarSource.SCOPES, + * getChannels: this.getChannels, + * onChannelEnabled: this.onChannelEnabled, + * onChannelDisabled: this.onChannelDisabled, + * }] + * }), + * }; + * } + * + * async onChannelEnabled(channel: Channel) { + * // Fetch and save events directly + * const events = await this.fetchEvents(channel.id); + * for (const event of events) { + * await this.tools.integrations.saveThread(event); + * } + * } + * } + * ``` + */ +export abstract class Source extends Twist { + /** + * Static marker to identify Source subclasses without instanceof checks + * across worker boundaries. + */ + static readonly isSource = true; +} diff --git a/twister/src/tool.ts b/twister/src/tool.ts index 6e9bee6..8df5157 100644 --- a/twister/src/tool.ts +++ b/twister/src/tool.ts @@ -16,17 +16,13 @@ import type { export type { ToolBuilder }; /** - * Options for tools that sync threads from external services. - * - * @example - * ```typescript - * static readonly Options: SyncToolOptions; - * ``` + * @deprecated Sources now save threads directly via integrations.saveThread() + * instead of using callbacks. Use Source class instead of Tool + SyncToolOptions. */ export type SyncToolOptions = { - /** Callback invoked for each synced item. The tool adds sync metadata before passing it. */ + /** @deprecated Callback invoked for each synced item. */ onItem: (item: NewThreadWithNotes) => Promise; - /** Callback invoked when a syncable is disabled, receiving a ThreadFilter for bulk operations. */ + /** @deprecated Callback invoked when a syncable is disabled. */ onSyncableDisabled?: (filter: ThreadFilter) => Promise; }; diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index 0993624..bdafde6 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -1,21 +1,35 @@ -import { type Actor, type ActorId, ITool, Serializable } from ".."; +import { + type Actor, + type ActorId, + type NewContact, + type NewThreadWithNotes, + type Note, + type Thread, + type ThreadFilter, + type ThreadMeta, + ITool, + Serializable, +} from ".."; import type { Uuid } from "../utils/uuid"; /** * A resource that can be synced (e.g., a calendar, project, channel). - * Returned by getSyncables() and managed by users in the twist setup/edit modal. + * Returned by getChannels() and managed by users in the twist setup/edit modal. */ -export type Syncable = { +export type Channel = { /** External ID shared across users (e.g., Google calendar ID) */ id: string; /** Display name shown in the UI */ title: string; - /** Optional nested syncable resources (e.g., subfolders) */ - children?: Syncable[]; + /** Optional nested channel resources (e.g., subfolders) */ + children?: Channel[]; }; +/** @deprecated Use Channel instead */ +export type Syncable = Channel; + /** - * Configuration for an OAuth provider in a tool's build options. + * Configuration for an OAuth provider in a source's build options. * Declares the provider, scopes, and lifecycle callbacks. */ export type IntegrationProviderConfig = { @@ -23,12 +37,30 @@ export type IntegrationProviderConfig = { provider: AuthProvider; /** OAuth scopes to request */ scopes: string[]; - /** Returns available syncables for the authorized actor. Must not use Plot tool. */ - getSyncables: (auth: Authorization, token: AuthToken) => Promise; - /** Called when a syncable resource is enabled for syncing */ - onSyncEnabled: (syncable: Syncable) => Promise; - /** Called when a syncable resource is disabled */ - onSyncDisabled: (syncable: Syncable) => Promise; + /** Returns available channels for the authorized actor. Must not use Plot tool. */ + getChannels: (auth: Authorization, token: AuthToken) => Promise; + /** Called when a channel resource is enabled for syncing */ + onChannelEnabled: (channel: Channel) => Promise; + /** Called when a channel resource is disabled */ + onChannelDisabled: (channel: Channel) => Promise; + /** + * Called when a thread created by this source is updated by the user. + * Used for write-back to external services (e.g., marking an issue as done). + */ + onThreadUpdated?: (thread: Thread) => Promise; + /** + * Called when a note is created on a thread owned by this source. + * Used for write-back to external services (e.g., adding a comment to an issue). + */ + onNoteCreated?: (note: Note, meta: ThreadMeta) => Promise; + + // Deprecated aliases + /** @deprecated Use getChannels instead */ + getSyncables?: (auth: Authorization, token: AuthToken) => Promise; + /** @deprecated Use onChannelEnabled instead */ + onSyncEnabled?: (channel: Channel) => Promise; + /** @deprecated Use onChannelDisabled instead */ + onSyncDisabled?: (channel: Channel) => Promise; }; /** @@ -40,20 +72,21 @@ export type IntegrationOptions = { }; /** - * Built-in tool for managing OAuth authentication and syncable resources. + * Built-in tool for managing OAuth authentication and channel resources. * - * The redesigned Integrations tool: + * The Integrations tool: * 1. Declares providers/scopes in build options with lifecycle callbacks - * 2. Manages syncable resources (calendars, projects, etc.) per actor - * 3. Returns tokens for the user who enabled sync on a syncable + * 2. Manages channel resources (calendars, projects, etc.) per actor + * 3. Returns tokens for the user who enabled sync on a channel * 4. Supports per-actor auth via actAs() for write-back operations + * 5. Provides saveThread/saveContacts/archiveThreads for Sources to save data directly * - * Auth and syncable management is handled in the twist edit modal in Flutter, - * removing the need for tools to create auth activities or selection UIs. + * Auth and channel management is handled in the twist edit modal in Flutter, + * removing the need for sources to create auth activities or selection UIs. * * @example * ```typescript - * class CalendarTool extends Tool { + * class CalendarSource extends Source { * static readonly PROVIDER = AuthProvider.Google; * static readonly SCOPES = ["https://www.googleapis.com/auth/calendar"]; * @@ -62,16 +95,16 @@ export type IntegrationOptions = { * integrations: build(Integrations, { * providers: [{ * provider: AuthProvider.Google, - * scopes: CalendarTool.SCOPES, - * getSyncables: this.getSyncables, - * onSyncEnabled: this.onSyncEnabled, - * onSyncDisabled: this.onSyncDisabled, + * scopes: CalendarSource.SCOPES, + * getChannels: this.getChannels, + * onChannelEnabled: this.onChannelEnabled, + * onChannelDisabled: this.onChannelDisabled, * }] * }), * }; * } * - * async getSyncables(auth: Authorization, token: AuthToken): Promise { + * async getChannels(auth: Authorization, token: AuthToken): Promise { * const calendars = await this.listCalendars(token); * return calendars.map(c => ({ id: c.id, title: c.name })); * } @@ -90,17 +123,17 @@ export abstract class Integrations extends ITool { } /** - * Retrieves an access token for a syncable resource. + * Retrieves an access token for a channel resource. * - * Returns the token of the user who enabled sync on the given syncable. - * If the syncable is not enabled or the token is expired/invalid, returns null. + * Returns the token of the user who enabled sync on the given channel. + * If the channel is not enabled or the token is expired/invalid, returns null. * * @param provider - The OAuth provider - * @param syncableId - The syncable resource ID (e.g., calendar ID) + * @param channelId - The channel resource ID (e.g., calendar ID) * @returns Promise resolving to the access token or null */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract get(provider: AuthProvider, syncableId: string): Promise; + abstract get(provider: AuthProvider, channelId: string): Promise; /** * Execute a callback as a specific actor, requesting auth if needed. @@ -127,6 +160,38 @@ export abstract class Integrations extends ITool { ...extraArgs: TArgs ): Promise; + /** + * Saves a thread with notes to the source's priority. + * + * This method is available only to Sources (not regular Twists). + * It replaces the old pattern of passing threads via callbacks to a Twist + * which then called plot.createThread(). + * + * @param thread - The thread with notes to save + * @returns Promise resolving to the saved thread's UUID + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract saveThread(thread: NewThreadWithNotes): Promise; + + /** + * Saves contacts to the source's priority. + * + * @param contacts - Array of contacts to save + * @returns Promise resolving to the saved actors + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract saveContacts(contacts: NewContact[]): Promise; + + /** + * Archives threads matching a filter. + * + * Useful for bulk archiving when a channel is disabled. + * + * @param filter - Filter to match threads to archive + * @returns Promise that resolves when archiving is complete + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract archiveThreads(filter: ThreadFilter): Promise; } /** diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index 94b26a3..7be5dd1 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -1,6 +1,5 @@ import { type Thread, - type ThreadOccurrence, type ThreadUpdate, type Actor, type ActorId, @@ -17,6 +16,11 @@ import { type Tag, Uuid, } from ".."; +import { + type Schedule, + type NewSchedule, + type ScheduleUpdate, +} from "../schedule"; export enum ThreadAccess { /** @@ -168,10 +172,6 @@ export abstract class Plot extends ITool { changes: { tagsAdded: Record; tagsRemoved: Record; - /** - * If present, this update is for a specific occurrence of a recurring thread. - */ - occurrence?: ThreadOccurrence; } ) => Promise; }; @@ -263,8 +263,7 @@ export abstract class Plot extends ITool { * When updating the parent, the thread's path will be automatically recalculated to * maintain the correct hierarchical structure. * - * When updating scheduling fields (start, end, recurrence*), the database will - * automatically recalculate duration and range values to maintain consistency. + * Scheduling is handled separately via `createSchedule()` / `updateSchedule()`. * * @param thread - The thread update containing the ID or source and fields to change * @returns Promise that resolves when the update is complete @@ -278,13 +277,6 @@ export abstract class Plot extends ITool { * done: new Date() * }); * - * // Reschedule an event - * await this.plot.updateThread({ - * id: "event-456", - * start: new Date("2024-03-15T10:00:00Z"), - * end: new Date("2024-03-15T11:00:00Z") - * }); - * * // Add and remove tags * await this.plot.updateThread({ * id: "thread-789", @@ -293,13 +285,6 @@ export abstract class Plot extends ITool { * 2: false // Remove tag with ID 2 * } * }); - * - * // Update a recurring event exception - * await this.plot.updateThread({ - * id: "exception-123", - * occurrence: new Date("2024-03-20T09:00:00Z"), - * title: "Rescheduled meeting" - * }); * ``` */ // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -504,4 +489,61 @@ export abstract class Plot extends ITool { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract getActors(ids: ActorId[]): Promise; + + /** + * Creates a new schedule for a thread. + * + * Schedules define when a thread occurs in time. A thread can have + * multiple schedules (shared and per-user). + * + * @param schedule - The schedule data to create + * @returns Promise resolving to the created schedule + * + * @example + * ```typescript + * // Schedule a timed event + * const threadId = await this.plot.createThread({ + * type: ThreadType.Event, + * title: "Team standup" + * }); + * await this.plot.createSchedule({ + * threadId, + * start: new Date("2025-01-15T10:00:00Z"), + * end: new Date("2025-01-15T10:30:00Z"), + * recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" + * }); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract createSchedule(schedule: NewSchedule): Promise; + + /** + * Updates an existing schedule. + * + * Only the fields provided in the update object will be modified. + * + * @param schedule - The schedule update containing the ID and fields to change + * @returns Promise resolving to the updated schedule + * + * @example + * ```typescript + * // Reschedule + * await this.plot.updateSchedule({ + * id: scheduleId, + * start: new Date("2025-03-20T10:00:00Z"), + * end: new Date("2025-03-20T11:00:00Z") + * }); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract updateSchedule(schedule: ScheduleUpdate): Promise; + + /** + * Retrieves all schedules for a thread. + * + * @param threadId - The thread whose schedules to retrieve + * @returns Promise resolving to array of schedules for the thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract getSchedules(threadId: Uuid): Promise; } From a70a1202dcd424adc571dde0b8478f021746582d Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 24 Feb 2026 23:30:30 -0500 Subject: [PATCH 03/25] =?UTF-8?q?Rename=20tools=20=E2=86=92=20sources:=20S?= =?UTF-8?q?ources=20save=20directly=20via=20integrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources are a specialization of Twist that save threads directly via integrations.saveThread() instead of passing data through callback relay to a parent twist. This eliminates the Tool→Twist pass-through layer. - Add Source class extending Twist with isSource marker - Rename Syncable → Channel, getSyncables → getChannels, etc. - Add saveThread/saveContacts/archiveThreads to Integrations SDK - Rename CalendarTool → CalendarSource (and all common interfaces) - Move tools/ → sources/, rename @plotday/tool-* → @plotday/source-* - Convert all 11 tools to extend Source, save directly - Remove pass-through twists (calendar-sync, project-sync, etc.) - Simplify Schedule type: remove id/threadId, add to Thread Co-Authored-By: Claude Opus 4.6 --- .changeset/full-ends-rescue.md | 4 +- AGENTS.md | 35 ++- pnpm-lock.yaml | 68 +++--- pnpm-workspace.yaml | 2 +- {tools => sources}/AGENTS.md | 0 {tools => sources}/CLAUDE.md | 0 {tools => sources}/asana/CHANGELOG.md | 0 {tools => sources}/asana/package.json | 2 +- {tools => sources}/asana/src/asana.ts | 169 ++++----------- {tools => sources}/asana/src/index.ts | 0 {tools => sources}/asana/tsconfig.json | 0 {tools => sources}/github-issues/package.json | 2 +- .../github-issues/src/github-issues.ts | 179 +++++---------- {tools => sources}/github-issues/src/index.ts | 0 .../github-issues/tsconfig.json | 0 {tools => sources}/github/package.json | 2 +- {tools => sources}/github/src/github.ts | 162 ++++---------- {tools => sources}/github/src/index.ts | 0 {tools => sources}/github/tsconfig.json | 0 {tools => sources}/gmail/CHANGELOG.md | 0 {tools => sources}/gmail/package.json | 2 +- {tools => sources}/gmail/src/gmail-api.ts | 0 {tools => sources}/gmail/src/gmail.ts | 162 +++----------- {tools => sources}/gmail/src/index.ts | 0 {tools => sources}/gmail/tsconfig.json | 0 .../google-calendar/CHANGELOG.md | 0 {tools => sources}/google-calendar/LICENSE | 0 {tools => sources}/google-calendar/README.md | 0 .../google-calendar/package.json | 4 +- .../google-calendar/src/google-api.ts | 0 .../google-calendar/src/google-calendar.ts | 152 ++++--------- .../google-calendar/src/index.ts | 0 .../google-calendar/tsconfig.json | 0 .../google-contacts/CHANGELOG.md | 0 {tools => sources}/google-contacts/LICENSE | 0 {tools => sources}/google-contacts/README.md | 0 .../google-contacts/package.json | 2 +- .../google-contacts/src/google-contacts.ts | 75 ++----- .../google-contacts/src/index.ts | 0 sources/google-contacts/src/types.ts | 9 + .../google-contacts/tsconfig.json | 0 {tools => sources}/google-drive/CHANGELOG.md | 0 {tools => sources}/google-drive/LICENSE | 0 {tools => sources}/google-drive/README.md | 0 {tools => sources}/google-drive/package.json | 4 +- .../google-drive/src/google-api.ts | 0 .../google-drive/src/google-drive.ts | 163 ++++---------- {tools => sources}/google-drive/src/index.ts | 0 {tools => sources}/google-drive/tsconfig.json | 0 {tools => sources}/jira/CHANGELOG.md | 0 {tools => sources}/jira/package.json | 2 +- {tools => sources}/jira/src/index.ts | 0 {tools => sources}/jira/src/jira.ts | 163 ++++---------- {tools => sources}/jira/tsconfig.json | 0 {tools => sources}/linear/CHANGELOG.md | 0 {tools => sources}/linear/LICENSE | 0 {tools => sources}/linear/README.md | 0 {tools => sources}/linear/package.json | 2 +- {tools => sources}/linear/src/index.ts | 0 {tools => sources}/linear/src/linear.ts | 181 ++++------------ {tools => sources}/linear/tsconfig.json | 0 .../outlook-calendar/CHANGELOG.md | 0 {tools => sources}/outlook-calendar/LICENSE | 0 {tools => sources}/outlook-calendar/README.md | 0 .../outlook-calendar/package.json | 2 +- .../outlook-calendar/src/graph-api.ts | 0 .../outlook-calendar/src/index.ts | 0 .../outlook-calendar/src/outlook-calendar.ts | 160 ++++---------- .../outlook-calendar/tsconfig.json | 0 {tools => sources}/slack/CHANGELOG.md | 0 {tools => sources}/slack/package.json | 2 +- {tools => sources}/slack/src/index.ts | 0 {tools => sources}/slack/src/slack-api.ts | 0 {tools => sources}/slack/src/slack.ts | 172 +++------------ {tools => sources}/slack/tsconfig.json | 0 tools/google-contacts/src/types.ts | 18 -- twister/src/plot.ts | 4 +- twister/src/schedule.ts | 50 +---- twister/src/tools/plot.ts | 22 -- twists/calendar-sync/package.json | 24 -- twists/calendar-sync/src/index.ts | 44 ---- twists/calendar-sync/tsconfig.json | 5 - twists/code-review/package.json | 22 -- twists/code-review/src/index.ts | 175 --------------- twists/code-review/tsconfig.json | 5 - twists/document-actions/package.json | 22 -- twists/document-actions/src/index.ts | 167 -------------- twists/document-actions/tsconfig.json | 5 - twists/message-tasks/package.json | 4 +- twists/message-tasks/src/index.ts | 4 +- twists/project-sync/package.json | 25 --- twists/project-sync/src/index.ts | 205 ------------------ twists/project-sync/tsconfig.json | 5 - 93 files changed, 502 insertions(+), 2186 deletions(-) rename {tools => sources}/AGENTS.md (100%) rename {tools => sources}/CLAUDE.md (100%) rename {tools => sources}/asana/CHANGELOG.md (100%) rename {tools => sources}/asana/package.json (96%) rename {tools => sources}/asana/src/asana.ts (79%) rename {tools => sources}/asana/src/index.ts (100%) rename {tools => sources}/asana/tsconfig.json (100%) rename {tools => sources}/github-issues/package.json (96%) rename {tools => sources}/github-issues/src/github-issues.ts (79%) rename {tools => sources}/github-issues/src/index.ts (100%) rename {tools => sources}/github-issues/tsconfig.json (100%) rename {tools => sources}/github/package.json (96%) rename {tools => sources}/github/src/github.ts (83%) rename {tools => sources}/github/src/index.ts (100%) rename {tools => sources}/github/tsconfig.json (100%) rename {tools => sources}/gmail/CHANGELOG.md (100%) rename {tools => sources}/gmail/package.json (96%) rename {tools => sources}/gmail/src/gmail-api.ts (100%) rename {tools => sources}/gmail/src/gmail.ts (66%) rename {tools => sources}/gmail/src/index.ts (100%) rename {tools => sources}/gmail/tsconfig.json (100%) rename {tools => sources}/google-calendar/CHANGELOG.md (100%) rename {tools => sources}/google-calendar/LICENSE (100%) rename {tools => sources}/google-calendar/README.md (100%) rename {tools => sources}/google-calendar/package.json (91%) rename {tools => sources}/google-calendar/src/google-api.ts (100%) rename {tools => sources}/google-calendar/src/google-calendar.ts (90%) rename {tools => sources}/google-calendar/src/index.ts (100%) rename {tools => sources}/google-calendar/tsconfig.json (100%) rename {tools => sources}/google-contacts/CHANGELOG.md (100%) rename {tools => sources}/google-contacts/LICENSE (100%) rename {tools => sources}/google-contacts/README.md (100%) rename {tools => sources}/google-contacts/package.json (95%) rename {tools => sources}/google-contacts/src/google-contacts.ts (81%) rename {tools => sources}/google-contacts/src/index.ts (100%) create mode 100644 sources/google-contacts/src/types.ts rename {tools => sources}/google-contacts/tsconfig.json (100%) rename {tools => sources}/google-drive/CHANGELOG.md (100%) rename {tools => sources}/google-drive/LICENSE (100%) rename {tools => sources}/google-drive/README.md (100%) rename {tools => sources}/google-drive/package.json (91%) rename {tools => sources}/google-drive/src/google-api.ts (100%) rename {tools => sources}/google-drive/src/google-drive.ts (81%) rename {tools => sources}/google-drive/src/index.ts (100%) rename {tools => sources}/google-drive/tsconfig.json (100%) rename {tools => sources}/jira/CHANGELOG.md (100%) rename {tools => sources}/jira/package.json (96%) rename {tools => sources}/jira/src/index.ts (100%) rename {tools => sources}/jira/src/jira.ts (81%) rename {tools => sources}/jira/tsconfig.json (100%) rename {tools => sources}/linear/CHANGELOG.md (100%) rename {tools => sources}/linear/LICENSE (100%) rename {tools => sources}/linear/README.md (100%) rename {tools => sources}/linear/package.json (96%) rename {tools => sources}/linear/src/index.ts (100%) rename {tools => sources}/linear/src/linear.ts (78%) rename {tools => sources}/linear/tsconfig.json (100%) rename {tools => sources}/outlook-calendar/CHANGELOG.md (100%) rename {tools => sources}/outlook-calendar/LICENSE (100%) rename {tools => sources}/outlook-calendar/README.md (100%) rename {tools => sources}/outlook-calendar/package.json (95%) rename {tools => sources}/outlook-calendar/src/graph-api.ts (100%) rename {tools => sources}/outlook-calendar/src/index.ts (100%) rename {tools => sources}/outlook-calendar/src/outlook-calendar.ts (88%) rename {tools => sources}/outlook-calendar/tsconfig.json (100%) rename {tools => sources}/slack/CHANGELOG.md (100%) rename {tools => sources}/slack/package.json (96%) rename {tools => sources}/slack/src/index.ts (100%) rename {tools => sources}/slack/src/slack-api.ts (100%) rename {tools => sources}/slack/src/slack.ts (65%) rename {tools => sources}/slack/tsconfig.json (100%) delete mode 100644 tools/google-contacts/src/types.ts delete mode 100644 twists/calendar-sync/package.json delete mode 100644 twists/calendar-sync/src/index.ts delete mode 100644 twists/calendar-sync/tsconfig.json delete mode 100644 twists/code-review/package.json delete mode 100644 twists/code-review/src/index.ts delete mode 100644 twists/code-review/tsconfig.json delete mode 100644 twists/document-actions/package.json delete mode 100644 twists/document-actions/src/index.ts delete mode 100644 twists/document-actions/tsconfig.json delete mode 100644 twists/project-sync/package.json delete mode 100644 twists/project-sync/src/index.ts delete mode 100644 twists/project-sync/tsconfig.json diff --git a/.changeset/full-ends-rescue.md b/.changeset/full-ends-rescue.md index c87ee28..f47eccb 100644 --- a/.changeset/full-ends-rescue.md +++ b/.changeset/full-ends-rescue.md @@ -1,6 +1,6 @@ --- -"@plotday/tool-outlook-calendar": minor -"@plotday/tool-google-calendar": minor +"@plotday/source-outlook-calendar": minor +"@plotday/source-google-calendar": minor "@plotday/tool-github-issues": minor "@plotday/tool-google-drive": minor "@plotday/tool-github": minor diff --git a/AGENTS.md b/AGENTS.md index 0109a93..882e9b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,27 @@ # Plot Development Guide for AI Assistants -This guide helps AI assistants build Plot tools and twists correctly. +This guide helps AI assistants build Plot sources and twists correctly. ## What Are You Building? -### Building a Tool (service integration) +### Building a Source (service integration) -Tools are reusable packages that connect to external services (Linear, Slack, Google Calendar, etc.). They implement a standard interface and are consumed by twists. +Sources are packages that connect to external services (Linear, Slack, Google Calendar, etc.). They extend Source and save data directly via `integrations.saveThread()`. -**Start here:** `tools/AGENTS.md` — Complete tool development guide with scaffold, patterns, and checklist. +**Start here:** `sources/AGENTS.md` — Complete source development guide with scaffold, patterns, and checklist. **Choose your interface:** | Interface | For | Import | |-----------|-----|--------| -| `CalendarTool` | Calendar/scheduling | `@plotday/twister/common/calendar` | -| `ProjectTool` | Project/task management | `@plotday/twister/common/projects` | -| `MessagingTool` | Email and chat | `@plotday/twister/common/messaging` | -| `DocumentTool` | Document/file storage | `@plotday/twister/common/documents` | +| `CalendarSource` | Calendar/scheduling | `@plotday/twister/common/calendar` | +| `ProjectSource` | Project/task management | `@plotday/twister/common/projects` | +| `MessagingSource` | Email and chat | `@plotday/twister/common/messaging` | +| `DocumentSource` | Document/file storage | `@plotday/twister/common/documents` | ### Building a Twist (orchestrator) -Twists are the entry point that users install. They declare which tools to use and implement domain logic (filtering, enrichment, two-way sync). +Twists are the entry point that users install. They declare which tools to use and implement domain logic. **Start here:** `twister/cli/templates/AGENTS.template.md` — Twist implementation guide. @@ -29,6 +29,7 @@ Twists are the entry point that users install. They declare which tools to use a All types in `twister/src/` with full JSDoc: +- **Source base**: `twister/src/source.ts` - **Tool base**: `twister/src/tool.ts` - **Twist base**: `twister/src/twist.ts` - **Built-in tools**: `twister/src/tools/*.ts` @@ -40,24 +41,22 @@ All types in `twister/src/` with full JSDoc: ## Additional Resources - **Full Documentation**: -- **Building Tools Guide**: `twister/docs/BUILDING_TOOLS.md` +- **Building Sources Guide**: `twister/docs/BUILDING_TOOLS.md` - **Runtime Environment**: `twister/docs/RUNTIME.md` - **Tools Guide**: `twister/docs/TOOLS_GUIDE.md` - **Multi-User Auth**: `twister/docs/MULTI_USER_AUTH.md` - **Sync Strategies**: `twister/docs/SYNC_STRATEGIES.md` -- **Working Tool Examples**: `tools/linear/`, `tools/google-calendar/`, `tools/slack/`, `tools/jira/` -- **Working Twist Examples**: `twists/calendar-sync/`, `twists/project-sync/` +- **Working Source Examples**: `sources/linear/`, `sources/google-calendar/`, `sources/slack/`, `sources/jira/` ## Common Pitfalls 1. **❌ Using instance variables for state** — Use `this.set()`/`this.get()` (state doesn't persist between executions) 2. **❌ Long-running operations without batching** — Break into chunks with `runTask()` (~1000 requests per execution) -3. **❌ Passing functions to `this.callback()`** — See `tools/AGENTS.md` for callback serialization pattern -4. **❌ Calling `plot.createActivity()` from a tool** — Tools build data, twists save it -5. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `syncableId` into `activity.meta` -6. **❌ Not handling initial vs incremental sync** — `unread: false` for initial, omit for incremental -7. **❌ Missing localhost guard in webhooks** — Skip webhook registration when URL contains "localhost" +3. **❌ Passing functions to `this.callback()`** — See `sources/AGENTS.md` for callback serialization pattern +4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `thread.meta` +5. **❌ Not handling initial vs incremental sync** — `unread: false` for initial, omit for incremental +6. **❌ Missing localhost guard in webhooks** — Skip webhook registration when URL contains "localhost" --- -**Remember**: When in doubt, check the type definitions in `twister/src/` and study the working examples in `tools/`. +**Remember**: When in doubt, check the type definitions in `twister/src/` and study the working examples in `sources/`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89423d..5472934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/asana: + sources/asana: dependencies: '@plotday/twister': specifier: workspace:^ @@ -43,7 +43,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/github: + sources/github: dependencies: '@plotday/twister': specifier: workspace:^ @@ -53,7 +53,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/github-issues: + sources/github-issues: dependencies: '@octokit/rest': specifier: ^21.1.1 @@ -66,7 +66,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/gmail: + sources/gmail: dependencies: '@plotday/twister': specifier: workspace:^ @@ -76,9 +76,9 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/google-calendar: + sources/google-calendar: dependencies: - '@plotday/tool-google-contacts': + '@plotday/source-google-contacts': specifier: workspace:^ version: link:../google-contacts '@plotday/twister': @@ -89,7 +89,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/google-contacts: + sources/google-contacts: dependencies: '@plotday/twister': specifier: workspace:^ @@ -99,9 +99,9 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/google-drive: + sources/google-drive: dependencies: - '@plotday/tool-google-contacts': + '@plotday/source-google-contacts': specifier: workspace:^ version: link:../google-contacts '@plotday/twister': @@ -112,7 +112,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/jira: + sources/jira: dependencies: '@plotday/twister': specifier: workspace:^ @@ -125,7 +125,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/linear: + sources/linear: dependencies: '@linear/sdk': specifier: ^72.0.0 @@ -138,7 +138,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/outlook-calendar: + sources/outlook-calendar: dependencies: '@plotday/twister': specifier: workspace:^ @@ -148,7 +148,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - tools/slack: + sources/slack: dependencies: '@plotday/twister': specifier: workspace:^ @@ -209,12 +209,12 @@ importers: twists/calendar-sync: dependencies: - '@plotday/tool-google-calendar': + '@plotday/source-google-calendar': specifier: workspace:^ - version: link:../../tools/google-calendar - '@plotday/tool-outlook-calendar': + version: link:../../sources/google-calendar + '@plotday/source-outlook-calendar': specifier: workspace:^ - version: link:../../tools/outlook-calendar + version: link:../../sources/outlook-calendar '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -238,9 +238,9 @@ importers: twists/code-review: dependencies: - '@plotday/tool-github': + '@plotday/source-github': specifier: workspace:^ - version: link:../../tools/github + version: link:../../sources/github '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -251,9 +251,9 @@ importers: twists/document-actions: dependencies: - '@plotday/tool-google-drive': + '@plotday/source-google-drive': specifier: workspace:^ - version: link:../../tools/google-drive + version: link:../../sources/google-drive '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -264,12 +264,12 @@ importers: twists/message-tasks: dependencies: - '@plotday/tool-gmail': + '@plotday/source-gmail': specifier: workspace:^ - version: link:../../tools/gmail - '@plotday/tool-slack': + version: link:../../sources/gmail + '@plotday/source-slack': specifier: workspace:^ - version: link:../../tools/slack + version: link:../../sources/slack '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -283,18 +283,18 @@ importers: twists/project-sync: dependencies: - '@plotday/tool-asana': + '@plotday/source-asana': specifier: workspace:^ - version: link:../../tools/asana - '@plotday/tool-github-issues': + version: link:../../sources/asana + '@plotday/source-jira': specifier: workspace:^ - version: link:../../tools/github-issues - '@plotday/tool-jira': + version: link:../../sources/jira + '@plotday/source-linear': specifier: workspace:^ - version: link:../../tools/jira - '@plotday/tool-linear': + version: link:../../sources/linear + '@plotday/tool-github-issues': specifier: workspace:^ - version: link:../../tools/linear + version: link:../../sources/github-issues '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -1208,7 +1208,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8ef7d4d..e222aee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - twister - - tools/* + - sources/* - twists/* onlyBuiltDependencies: diff --git a/tools/AGENTS.md b/sources/AGENTS.md similarity index 100% rename from tools/AGENTS.md rename to sources/AGENTS.md diff --git a/tools/CLAUDE.md b/sources/CLAUDE.md similarity index 100% rename from tools/CLAUDE.md rename to sources/CLAUDE.md diff --git a/tools/asana/CHANGELOG.md b/sources/asana/CHANGELOG.md similarity index 100% rename from tools/asana/CHANGELOG.md rename to sources/asana/CHANGELOG.md diff --git a/tools/asana/package.json b/sources/asana/package.json similarity index 96% rename from tools/asana/package.json rename to sources/asana/package.json index 650e9d1..1080673 100644 --- a/tools/asana/package.json +++ b/sources/asana/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-asana", + "name": "@plotday/source-asana", "displayName": "Asana", "description": "Sync with Asana project management", "author": "Plot (https://plot.day)", diff --git a/tools/asana/src/asana.ts b/sources/asana/src/asana.ts similarity index 79% rename from tools/asana/src/asana.ts rename to sources/asana/src/asana.ts index 623d940..918ffb9 100644 --- a/tools/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -2,34 +2,29 @@ import * as asana from "asana"; import { type Thread, - type ThreadFilter, type Action, ActionType, ThreadMeta, ThreadType, - type NewThread, type NewThreadWithNotes, type NewNote, - type Serializable, - type SyncToolOptions, } from "@plotday/twister"; import type { Project, ProjectSyncOptions, - ProjectTool, + ProjectSource, } from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; +import { Source } from "@plotday/twister/source"; +import type { ToolBuilder } from "@plotday/twister/tool"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; type SyncState = { @@ -40,16 +35,14 @@ type SyncState = { }; /** - * Asana project management tool + * Asana project management source * - * Implements the ProjectTool interface for syncing Asana projects and tasks - * with Plot activities. + * Implements the ProjectSource interface for syncing Asana projects and tasks + * with Plot threads. */ -export class Asana extends Tool implements ProjectTool { +export class Asana extends Source implements ProjectSource { static readonly PROVIDER = AuthProvider.Asana; static readonly SCOPES = ["default"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -57,15 +50,13 @@ export class Asana extends Tool implements ProjectTool { providers: [{ provider: Asana.PROVIDER, scopes: Asana.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }], }), network: build(Network, { urls: ["https://app.asana.com/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } @@ -81,78 +72,46 @@ export class Asana extends Tool implements ProjectTool { } /** - * Returns available Asana projects as syncable resources. + * Returns available Asana projects as channel resources. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_auth: Authorization, token: AuthToken): Promise { const client = asana.Client.create().useAccessToken(token.token); const workspaces = await client.workspaces.getWorkspaces(); - const allProjects: Syncable[] = []; + const allChannels: Channel[] = []; for (const workspace of workspaces.data) { const projects = await client.projects.findByWorkspace(workspace.gid, { limit: 100 }); for (const project of projects.data) { - allProjects.push({ id: project.gid, title: project.name }); + allChannels.push({ id: project.gid, title: project.name }); } } - return allProjects; + return allChannels; } /** - * Called when a syncable project is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel is enabled for syncing. + * Sets up webhook and auto-starts sync. */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const filter: ThreadFilter = { - meta: { syncProvider: "asana", syncableId: syncable.id }, - }; - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - filter - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and begin batch sync - await this.setupAsanaWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupAsanaWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Called when a syncable project is disabled. - * Stops sync, runs disable callback, and cleans up stored tokens. + * Called when a channel is disabled. + * Stops sync and archives all threads from this channel. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.deleteCallback(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } - - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "asana", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } /** @@ -188,28 +147,16 @@ export class Asana extends Tool implements ProjectTool { /** * Start syncing tasks from an Asana project */ - async startSync< - TArgs extends Serializable[], - TCallback extends (task: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & ProjectSyncOptions ): Promise { const { projectId, timeMin } = options; // Setup webhook for real-time updates await this.setupAsanaWebhook(projectId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); } @@ -294,12 +241,6 @@ export class Asana extends Tool implements ProjectTool { throw new Error(`Sync state not found for project ${projectId}`); } - // Retrieve callback token from storage - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - throw new Error(`Callback token not found for project ${projectId}`); - } - const client = await this.getClient(projectId); // Build request params @@ -352,8 +293,7 @@ export class Asana extends Tool implements ProjectTool { if (state.initialSync) { threadWithNotes.archived = false; } - // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, threadWithNotes); + await this.tools.integrations.saveThread(threadWithNotes); } // Check if more pages by checking if we got a full batch @@ -601,13 +541,6 @@ export class Asana extends Tool implements ProjectTool { } } - // Get callback token (needed by both handlers) - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - console.warn("No callback token found for project:", projectId); - return; - } - // Process events if (payload.events && Array.isArray(payload.events)) { for (const event of payload.events) { @@ -617,18 +550,10 @@ export class Asana extends Tool implements ProjectTool { if (changedField === "stories") { // Story/comment event - handle separately - await this.handleStoryWebhook( - event, - projectId, - callbackToken - ); + await this.handleStoryWebhook(event, projectId); } else { // Task property changed - update metadata only - await this.handleTaskWebhook( - event, - projectId, - callbackToken - ); + await this.handleTaskWebhook(event, projectId); } } } @@ -640,8 +565,7 @@ export class Asana extends Tool implements ProjectTool { */ private async handleTaskWebhook( event: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const client = await this.getClient(projectId); @@ -698,8 +622,8 @@ export class Asana extends Tool implements ProjectTool { description = task.notes; } - // Create partial thread update (no notes = doesn't touch existing notes) - const thread: NewThread = { + // Create partial thread update (empty notes = doesn't touch existing notes) + const thread: NewThreadWithNotes = { source: threadSource, type: ThreadType.Action, title: task.name, @@ -717,9 +641,10 @@ export class Asana extends Tool implements ProjectTool { ? new Date(task.completed_at) : null, preview: description || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } catch (error) { console.warn("Failed to process Asana task webhook:", error); } @@ -730,8 +655,7 @@ export class Asana extends Tool implements ProjectTool { */ private async handleStoryWebhook( event: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const client = await this.getClient(projectId); @@ -797,7 +721,7 @@ export class Asana extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } catch (error) { console.warn("Failed to process Asana story webhook:", error); } @@ -819,13 +743,6 @@ export class Asana extends Tool implements ProjectTool { await this.clear(`webhook_id_${projectId}`); } - // Cleanup callback - const callbackToken = await this.get(`item_callback_${projectId}`); - if (callbackToken) { - await this.deleteCallback(callbackToken); - await this.clear(`item_callback_${projectId}`); - } - // Cleanup sync state await this.clear(`sync_state_${projectId}`); } diff --git a/tools/asana/src/index.ts b/sources/asana/src/index.ts similarity index 100% rename from tools/asana/src/index.ts rename to sources/asana/src/index.ts diff --git a/tools/asana/tsconfig.json b/sources/asana/tsconfig.json similarity index 100% rename from tools/asana/tsconfig.json rename to sources/asana/tsconfig.json diff --git a/tools/github-issues/package.json b/sources/github-issues/package.json similarity index 96% rename from tools/github-issues/package.json rename to sources/github-issues/package.json index 7c2d9b5..405ccf8 100644 --- a/tools/github-issues/package.json +++ b/sources/github-issues/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-github-issues", + "name": "@plotday/source-github-issues", "displayName": "GitHub Issues", "description": "Sync with GitHub Issues", "author": "Plot (https://plot.day)", diff --git a/tools/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts similarity index 79% rename from tools/github-issues/src/github-issues.ts rename to sources/github-issues/src/github-issues.ts index 9a2ab2e..59ec3c0 100644 --- a/tools/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -5,29 +5,25 @@ import { ActionType, type ThreadMeta, ThreadType, - type NewThread, type NewThreadWithNotes, type NewNote, - type Serializable, - type SyncToolOptions, } from "@plotday/twister"; import type { Project, ProjectSyncOptions, - ProjectTool, + ProjectSource, } from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; +import { Source } from "@plotday/twister/source"; +import type { ToolBuilder } from "@plotday/twister/tool"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; type SyncState = { @@ -45,16 +41,14 @@ type RepoInfo = { }; /** - * GitHub Issues tool + * GitHub Issues source * - * Implements the ProjectTool interface for syncing GitHub Issues - * with Plot activities. Explicitly filters out pull requests. + * Implements the ProjectSource interface for syncing GitHub Issues + * with Plot threads. Explicitly filters out pull requests. */ -export class GitHubIssues extends Tool implements ProjectTool { +export class GitHubIssues extends Source implements ProjectSource { static readonly PROVIDER = AuthProvider.GitHub; static readonly SCOPES = ["repo"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -63,26 +57,24 @@ export class GitHubIssues extends Tool implements ProjectTool { { provider: GitHubIssues.PROVIDER, scopes: GitHubIssues.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }, ], }), network: build(Network, { urls: ["https://api.github.com/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } /** - * Create GitHub API client using syncable-based auth + * Create GitHub API client using channel-based auth */ - private async getClient(syncableId: string): Promise { + private async getClient(channelId: string): Promise { const token = await this.tools.integrations.get( GitHubIssues.PROVIDER, - syncableId + channelId ); if (!token) { throw new Error("No GitHub authentication token available"); @@ -102,12 +94,12 @@ export class GitHubIssues extends Tool implements ProjectTool { } /** - * Returns available GitHub repos as syncable resources. + * Returns available GitHub repos as channel resources. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const octokit = new Octokit({ auth: token.token }); const repos = await octokit.rest.repos.listForAuthenticatedUser({ sort: "updated", @@ -120,69 +112,40 @@ export class GitHubIssues extends Tool implements ProjectTool { } /** - * Called when a syncable resource is enabled for syncing. + * Called when a channel resource is enabled for syncing. */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Store repo info (owner/repo) for API calls - // syncable.title is "owner/repo" (full_name) - const [owner, repo] = (syncable.title ?? "").split("/"); + // channel.title is "owner/repo" (full_name) + const [owner, repo] = (channel.title ?? "").split("/"); if (owner && repo) { - await this.set(`repo_info_${syncable.id}`, { + await this.set(`repo_info_${channel.id}`, { owner, repo, - fullName: syncable.title ?? "", + fullName: channel.title ?? "", }); } - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "github-issues", syncableId: syncable.id } } - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } - // Auto-start sync: setup webhook and begin batch sync - await this.setupGitHubWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupGitHubWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Called when a syncable resource is disabled. + * Called when a channel resource is disabled. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); - - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.tools.callbacks.delete(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.tools.callbacks.delete(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "github-issues", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); - await this.clear(`repo_info_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); + await this.clear(`repo_info_${channel.id}`); } /** @@ -206,31 +169,16 @@ export class GitHubIssues extends Tool implements ProjectTool { /** * Start syncing issues from a GitHub repo */ - async startSync< - TArgs extends Serializable[], - TCallback extends ( - issue: NewThreadWithNotes, - ...args: TArgs - ) => any, - >( + async startSync( options: { projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & ProjectSyncOptions ): Promise { const { projectId } = options; // Setup webhook for real-time updates await this.setupGitHubWebhook(projectId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(projectId, options); } @@ -318,13 +266,6 @@ export class GitHubIssues extends Tool implements ProjectTool { throw new Error(`Sync state not found for repo ${repoId}`); } - const callbackToken = await this.get( - `item_callback_${repoId}` - ); - if (!callbackToken) { - throw new Error(`Callback token not found for repo ${repoId}`); - } - const octokit = await this.getClient(repoId); const { owner, repo, fullName } = await this.getRepoInfo(repoId); @@ -373,7 +314,7 @@ export class GitHubIssues extends Tool implements ProjectTool { syncProvider: "github-issues", syncableId: repoId, }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); processedInBatch++; } } @@ -580,13 +521,10 @@ export class GitHubIssues extends Tool implements ProjectTool { updateFields.state = "open"; } - // Handle assignee + // Handle assignee - use actor name as GitHub login if (thread.assignee) { - const actors = await this.tools.plot.getActors([thread.assignee.id]); - const actor = actors[0]; - if (actor?.name) { - // GitHub assignees use login names - updateFields.assignees = [actor.name]; + if (thread.assignee.name) { + updateFields.assignees = [thread.assignee.name]; } } else { updateFields.assignees = []; @@ -689,22 +627,13 @@ export class GitHubIssues extends Tool implements ProjectTool { return; } - // Get callback token - const callbackToken = await this.get( - `item_callback_${repoId}` - ); - if (!callbackToken) { - console.warn("No callback token found for repo:", repoId); - return; - } - const event = request.headers["x-github-event"]; const payload = request.body as any; if (event === "issues") { - await this.handleIssueWebhook(payload, repoId, callbackToken); + await this.handleIssueWebhook(payload, repoId); } else if (event === "issue_comment") { - await this.handleCommentWebhook(payload, repoId, callbackToken); + await this.handleCommentWebhook(payload, repoId); } } @@ -713,8 +642,7 @@ export class GitHubIssues extends Tool implements ProjectTool { */ private async handleIssueWebhook( payload: any, - repoId: string, - callbackToken: Callback + repoId: string ): Promise { const issue = payload.issue; if (!issue) return; @@ -748,7 +676,7 @@ export class GitHubIssues extends Tool implements ProjectTool { }; } - const thread: NewThread = { + const thread: NewThreadWithNotes = { source: `github:issue:${repoId}:${issue.number}`, type: ThreadType.Action, title: issue.title, @@ -765,9 +693,10 @@ export class GitHubIssues extends Tool implements ProjectTool { syncableId: repoId, }, preview: issue.body || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } /** @@ -775,8 +704,7 @@ export class GitHubIssues extends Tool implements ProjectTool { */ private async handleCommentWebhook( payload: any, - repoId: string, - callbackToken: Callback + repoId: string ): Promise { const comment = payload.comment; const issue = payload.issue; @@ -822,7 +750,7 @@ export class GitHubIssues extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } /** @@ -849,15 +777,6 @@ export class GitHubIssues extends Tool implements ProjectTool { // Cleanup webhook secret await this.clear(`webhook_secret_${projectId}`); - // Cleanup item callback - const itemCallbackToken = await this.get( - `item_callback_${projectId}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${projectId}`); - } - // Cleanup sync state await this.clear(`sync_state_${projectId}`); } diff --git a/tools/github-issues/src/index.ts b/sources/github-issues/src/index.ts similarity index 100% rename from tools/github-issues/src/index.ts rename to sources/github-issues/src/index.ts diff --git a/tools/github-issues/tsconfig.json b/sources/github-issues/tsconfig.json similarity index 100% rename from tools/github-issues/tsconfig.json rename to sources/github-issues/tsconfig.json diff --git a/tools/github/package.json b/sources/github/package.json similarity index 96% rename from tools/github/package.json rename to sources/github/package.json index 636b854..297a452 100644 --- a/tools/github/package.json +++ b/sources/github/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-github", + "name": "@plotday/source-github", "displayName": "GitHub", "description": "Sync with GitHub pull requests and code reviews", "author": "Plot (https://plot.day)", diff --git a/tools/github/src/github.ts b/sources/github/src/github.ts similarity index 83% rename from tools/github/src/github.ts rename to sources/github/src/github.ts index d518a25..a109be2 100644 --- a/tools/github/src/github.ts +++ b/sources/github/src/github.ts @@ -4,28 +4,24 @@ import { ActionType, type ThreadMeta, ThreadType, - type NewThread, type NewThreadWithNotes, - type Serializable, - type SyncToolOptions, + Source, + type ToolBuilder, } from "@plotday/twister"; import type { Repository, SourceControlSyncOptions, - SourceControlTool, + SourceControlSource, } from "@plotday/twister/common/source-control"; import type { NewContact } from "@plotday/twister/plot"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; type SyncState = { @@ -90,16 +86,14 @@ type GitHubRepo = { }; /** - * GitHub source control tool + * GitHub source control source * - * Implements the SourceControlTool interface for syncing GitHub repositories - * and pull requests with Plot activities. + * Implements the SourceControlSource interface for syncing GitHub repositories + * and pull requests with Plot threads. */ -export class GitHub extends Tool implements SourceControlTool { +export class GitHub extends Source implements SourceControlSource { static readonly PROVIDER = AuthProvider.GitHub; static readonly SCOPES = ["repo"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; /** Days of recently closed/merged PRs to include in sync */ private static readonly RECENT_DAYS = 30; @@ -113,16 +107,14 @@ export class GitHub extends Tool implements SourceControlTool { { provider: GitHub.PROVIDER, scopes: GitHub.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }, ], }), network: build(Network, { urls: ["https://api.github.com/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } @@ -146,12 +138,12 @@ export class GitHub extends Tool implements SourceControlTool { } /** - * Get an authenticated token for a syncable repository + * Get an authenticated token for a channel repository */ - private async getToken(syncableId: string): Promise { + private async getToken(channelId: string): Promise { const authToken = await this.tools.integrations.get( GitHub.PROVIDER, - syncableId, + channelId, ); if (!authToken) { throw new Error("No GitHub authentication token available"); @@ -160,12 +152,12 @@ export class GitHub extends Tool implements SourceControlTool { } /** - * Returns available GitHub repositories as syncable resources. + * Returns available GitHub repositories as channel resources. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken, - ): Promise { + ): Promise { const repos: GitHubRepo[] = []; let page = 1; @@ -199,57 +191,28 @@ export class GitHub extends Tool implements SourceControlTool { } /** - * Called when a syncable repository is enabled for syncing. + * Called when a channel repository is enabled for syncing. */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem, - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "github", syncableId: syncable.id } }, - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Setup webhook and start initial sync - await this.setupWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Called when a syncable repository is disabled. + * Called when a channel repository is disabled. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}`, - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.tools.callbacks.delete(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } - - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}`, - ); - if (itemCallbackToken) { - await this.tools.callbacks.delete(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "github", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } /** @@ -291,28 +254,16 @@ export class GitHub extends Tool implements SourceControlTool { /** * Start syncing pull requests from a repository */ - async startSync< - TArgs extends Serializable[], - TCallback extends (pr: NewThreadWithNotes, ...args: TArgs) => any, - >( + async startSync( options: { repositoryId: string; } & SourceControlSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { repositoryId } = options; // Setup webhook for real-time updates await this.setupWebhook(repositoryId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs, - ); - await this.set(`item_callback_${repositoryId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(repositoryId); } @@ -341,15 +292,6 @@ export class GitHub extends Tool implements SourceControlTool { // Cleanup webhook secret await this.clear(`webhook_secret_${repositoryId}`); - // Cleanup item callback - const itemCallbackToken = await this.get( - `item_callback_${repositoryId}`, - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${repositoryId}`); - } - // Cleanup sync state await this.clear(`sync_state_${repositoryId}`); } @@ -500,15 +442,6 @@ export class GitHub extends Tool implements SourceControlTool { return; } - // Get callback token - const callbackToken = await this.get( - `item_callback_${repositoryId}`, - ); - if (!callbackToken) { - console.warn("No callback token found for repository:", repositoryId); - return; - } - const event = request.headers["x-github-event"]; const payload = typeof request.body === "string" @@ -516,13 +449,13 @@ export class GitHub extends Tool implements SourceControlTool { : request.body; if (event === "pull_request") { - await this.handlePRWebhook(payload, repositoryId, callbackToken); + await this.handlePRWebhook(payload, repositoryId); } else if (event === "pull_request_review") { - await this.handleReviewWebhook(payload, repositoryId, callbackToken); + await this.handleReviewWebhook(payload, repositoryId); } else if (event === "issue_comment") { // Only handle comments on PRs (issue_comment fires for both issues and PRs) if (payload.issue?.pull_request) { - await this.handleCommentWebhook(payload, repositoryId, callbackToken); + await this.handleCommentWebhook(payload, repositoryId); } } } @@ -533,7 +466,6 @@ export class GitHub extends Tool implements SourceControlTool { private async handlePRWebhook( payload: any, repositoryId: string, - callbackToken: Callback, ): Promise { const pr: GitHubPullRequest = payload.pull_request; if (!pr) return; @@ -545,7 +477,7 @@ export class GitHub extends Tool implements SourceControlTool { ? this.userToContact(pr.assignee) : null; - const thread: NewThread = { + const thread: NewThreadWithNotes = { source: `github:pr:${owner}/${repo}/${pr.number}`, type: ThreadType.Action, title: pr.title, @@ -564,9 +496,10 @@ export class GitHub extends Tool implements SourceControlTool { syncableId: repositoryId, }, preview: pr.body || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } /** @@ -575,7 +508,6 @@ export class GitHub extends Tool implements SourceControlTool { private async handleReviewWebhook( payload: any, repositoryId: string, - callbackToken: Callback, ): Promise { const review: GitHubReview = payload.review; const pr: GitHubPullRequest = payload.pull_request; @@ -614,7 +546,7 @@ export class GitHub extends Tool implements SourceControlTool { }, }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } /** @@ -623,7 +555,6 @@ export class GitHub extends Tool implements SourceControlTool { private async handleCommentWebhook( payload: any, repositoryId: string, - callbackToken: Callback, ): Promise { const comment: GitHubIssueComment = payload.comment; const issue = payload.issue; @@ -654,7 +585,7 @@ export class GitHub extends Tool implements SourceControlTool { }, }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } // ---------- Batch sync ---------- @@ -671,7 +602,7 @@ export class GitHub extends Tool implements SourceControlTool { }); const batchCallback = await this.callback(this.syncBatch, repositoryId); - await this.tools.tasks.runTask(batchCallback); + await this.runTask(batchCallback); } /** @@ -683,15 +614,6 @@ export class GitHub extends Tool implements SourceControlTool { throw new Error(`Sync state not found for repository ${repositoryId}`); } - const callbackToken = await this.get( - `item_callback_${repositoryId}`, - ); - if (!callbackToken) { - throw new Error( - `Callback token not found for repository ${repositoryId}`, - ); - } - const token = await this.getToken(repositoryId); const [owner, repo] = repositoryId.split("/"); @@ -747,7 +669,7 @@ export class GitHub extends Tool implements SourceControlTool { syncProvider: "github", syncableId: repositoryId, }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } } @@ -761,7 +683,7 @@ export class GitHub extends Tool implements SourceControlTool { }); const nextBatch = await this.callback(this.syncBatch, repositoryId); - await this.tools.tasks.runTask(nextBatch); + await this.runTask(nextBatch); } else { // Sync complete await this.clear(`sync_state_${repositoryId}`); diff --git a/tools/github/src/index.ts b/sources/github/src/index.ts similarity index 100% rename from tools/github/src/index.ts rename to sources/github/src/index.ts diff --git a/tools/github/tsconfig.json b/sources/github/tsconfig.json similarity index 100% rename from tools/github/tsconfig.json rename to sources/github/tsconfig.json diff --git a/tools/gmail/CHANGELOG.md b/sources/gmail/CHANGELOG.md similarity index 100% rename from tools/gmail/CHANGELOG.md rename to sources/gmail/CHANGELOG.md diff --git a/tools/gmail/package.json b/sources/gmail/package.json similarity index 96% rename from tools/gmail/package.json rename to sources/gmail/package.json index 3b4982d..be958a6 100644 --- a/tools/gmail/package.json +++ b/sources/gmail/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-gmail", + "name": "@plotday/source-gmail", "displayName": "Gmail", "description": "Sync with Gmail inbox and messages", "author": "Plot (https://plot.day)", diff --git a/tools/gmail/src/gmail-api.ts b/sources/gmail/src/gmail-api.ts similarity index 100% rename from tools/gmail/src/gmail-api.ts rename to sources/gmail/src/gmail-api.ts diff --git a/tools/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts similarity index 66% rename from tools/gmail/src/gmail.ts rename to sources/gmail/src/gmail.ts index 4e07169..6a40963 100644 --- a/tools/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -1,30 +1,21 @@ import { - type ThreadFilter, type NewThreadWithNotes, - Serializable, - type SyncToolOptions, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; import { type MessageChannel, type MessageSyncOptions, - type MessagingTool, + type MessagingSource, } from "@plotday/twister/common/messaging"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ThreadAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; import { GmailApi, @@ -35,7 +26,7 @@ import { } from "./gmail-api"; /** - * Gmail integration tool implementing the MessagingTool interface. + * Gmail integration source implementing the MessagingSource interface. * * Supports inbox, labels, and search filters as channels. * Auth is managed declaratively via provider config in build() and @@ -44,46 +35,9 @@ import { * **Required OAuth Scopes:** * - `https://www.googleapis.com/auth/gmail.readonly` - Read emails * - `https://www.googleapis.com/auth/gmail.modify` - Modify labels - * - * @example - * ```typescript - * class MessagesTwist extends Twist { - * private gmail: Gmail; - * - * constructor(id: string, tools: Tools) { - * super(); - * this.gmail = tools.get(Gmail); - * } - * - * // Auth is handled via the twist edit modal. - * // When sync is enabled on a channel, onSyncEnabled fires and - * // the twist can start syncing: - * - * async onGmailSyncEnabled(channelId: string) { - * await this.gmail.startSync( - * { channelId, timeMin: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, - * this.onGmailThread - * ); - * } - * - * async onGmailThread(thread: NewThreadWithNotes) { - * // Process Gmail email thread - * // Each thread is a Thread 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(", ")}`); - * } - * } - * } - * ``` */ -export class Gmail extends Tool implements MessagingTool { +export class Gmail extends Source implements MessagingSource { static readonly PROVIDER = AuthProvider.Google; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; static readonly SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify", @@ -96,30 +50,22 @@ export class Gmail extends Tool implements MessagingTool { { provider: Gmail.PROVIDER, scopes: Gmail.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.listSyncChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }, ], }), network: build(Network, { urls: ["https://gmail.googleapis.com/gmail/v1/*"], }), - plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - thread: { - access: ThreadAccess.Create, - }, - }), }; } - async getSyncables( + async listSyncChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new GmailApi(token.token); const labels = await api.getLabels(); return labels @@ -131,68 +77,36 @@ export class Gmail extends Tool implements MessagingTool { .map((l: any) => ({ id: l.id, title: l.name })); } - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const filter: ThreadFilter = { - meta: { syncProvider: "google", syncableId: syncable.id }, - }; - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - filter - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and queue first batch - await this.setupChannelWebhook(syncable.id); + await this.setupChannelWebhook(channel.id); const initialState: SyncState = { - channelId: syncable.id, + channelId: channel.id, }; - await this.set(`sync_state_${syncable.id}`, initialState); + await this.set(`sync_state_${channel.id}`, initialState); const syncCallback = await this.callback( this.syncBatch, 1, "full", - syncable.id + channel.id ); await this.run(syncCallback); } - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); - - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.deleteCallback(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "google", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } private async getApi(channelId: string): Promise { @@ -240,25 +154,13 @@ export class Gmail extends Tool implements MessagingTool { return channels; } - async startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { channelId: string; } & MessageSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { channelId, timeMin } = options; - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${channelId}`, callbackToken); - // Setup webhook for this channel (Gmail Push Notifications) await this.setupChannelWebhook(channelId); @@ -297,9 +199,6 @@ export class Gmail extends Tool implements MessagingTool { // Clear sync state await this.clear(`sync_state_${channelId}`); - - // Clear callback token - await this.clear(`item_callback_${channelId}`); } private async setupChannelWebhook(channelId: string): Promise { @@ -380,15 +279,6 @@ export class Gmail extends Tool implements MessagingTool { threads: GmailThread[], channelId: string ): Promise { - const callbackToken = await this.get( - `item_callback_${channelId}` - ); - - if (!callbackToken) { - console.error("No callback token found for channel", channelId); - return; - } - for (const thread of threads) { try { // Transform Gmail thread to NewThreadWithNotes @@ -403,8 +293,8 @@ export class Gmail extends Tool implements MessagingTool { syncableId: channelId, }; - // Call parent callback with the thread (contacts will be created by the API) - await this.run(callbackToken, activityThread); + // Save thread directly via integrations + await this.tools.integrations.saveThread(activityThread); } catch (error) { console.error(`Failed to process Gmail thread ${thread.id}:`, error); // Continue processing other threads diff --git a/tools/gmail/src/index.ts b/sources/gmail/src/index.ts similarity index 100% rename from tools/gmail/src/index.ts rename to sources/gmail/src/index.ts diff --git a/tools/gmail/tsconfig.json b/sources/gmail/tsconfig.json similarity index 100% rename from tools/gmail/tsconfig.json rename to sources/gmail/tsconfig.json diff --git a/tools/google-calendar/CHANGELOG.md b/sources/google-calendar/CHANGELOG.md similarity index 100% rename from tools/google-calendar/CHANGELOG.md rename to sources/google-calendar/CHANGELOG.md diff --git a/tools/google-calendar/LICENSE b/sources/google-calendar/LICENSE similarity index 100% rename from tools/google-calendar/LICENSE rename to sources/google-calendar/LICENSE diff --git a/tools/google-calendar/README.md b/sources/google-calendar/README.md similarity index 100% rename from tools/google-calendar/README.md rename to sources/google-calendar/README.md diff --git a/tools/google-calendar/package.json b/sources/google-calendar/package.json similarity index 91% rename from tools/google-calendar/package.json rename to sources/google-calendar/package.json index 473faae..a732789 100644 --- a/tools/google-calendar/package.json +++ b/sources/google-calendar/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-google-calendar", + "name": "@plotday/source-google-calendar", "displayName": "Google Calendar", "description": "Sync with Google Calendar", "author": "Plot (https://plot.day)", @@ -25,7 +25,7 @@ "clean": "rm -rf dist" }, "dependencies": { - "@plotday/tool-google-contacts": "workspace:^", + "@plotday/source-google-contacts": "workspace:^", "@plotday/twister": "workspace:^" }, "devDependencies": { diff --git a/tools/google-calendar/src/google-api.ts b/sources/google-calendar/src/google-api.ts similarity index 100% rename from tools/google-calendar/src/google-api.ts rename to sources/google-calendar/src/google-api.ts diff --git a/tools/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts similarity index 90% rename from tools/google-calendar/src/google-calendar.ts rename to sources/google-calendar/src/google-calendar.ts index ff20bfc..ea38e66 100644 --- a/tools/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -1,4 +1,4 @@ -import GoogleContacts from "@plotday/tool-google-contacts"; +import GoogleContacts from "@plotday/source-google-contacts"; import { type Thread, ActionType, @@ -10,32 +10,24 @@ import { type NewActor, type NewContact, type NewNote, - Serializable, - type SyncToolOptions, Tag, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; import type { NewScheduleOccurrence } from "@plotday/twister/schedule"; import { type Calendar, - type CalendarTool, + type CalendarSource, type SyncOptions, } from "@plotday/twister/common/calendar"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ThreadAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; import { GoogleApi, @@ -116,16 +108,14 @@ import { * ``` */ export class GoogleCalendar - extends Tool - implements CalendarTool + extends Source + implements CalendarSource { static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = [ "https://www.googleapis.com/auth/calendar.calendarlist.readonly", "https://www.googleapis.com/auth/calendar.events", ]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -137,66 +127,43 @@ export class GoogleCalendar GoogleCalendar.SCOPES, GoogleContacts.SCOPES ), - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, + onThreadUpdated: this.onThreadUpdated, }, ], }), network: build(Network, { urls: ["https://www.googleapis.com/calendar/*"], }), - plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - thread: { - access: ThreadAccess.Create, - updated: this.onThreadUpdated, - }, - }), googleContacts: build(GoogleContacts), }; } - async preUpgrade(): Promise { - const keys = await this.list("sync_lock_"); + async upgrade(): Promise { + const keys = await this.tools.store.list("sync_lock_"); for (const key of keys) { await this.clear(key); } } /** - * Returns available calendars as syncable resources after authorization. + * Returns available calendars as channel resources after authorization. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_auth: Authorization, token: AuthToken): Promise { const api = new GoogleApi(token.token); const calendars = await this.listCalendarsWithApi(api); return calendars.map((c) => ({ id: c.id, title: c.name })); } /** - * Called when a syncable calendar is enabled for syncing. - * Creates callback tokens and auto-starts sync for the calendar. + * Called when a channel calendar is enabled for syncing. + * Auto-starts sync for the calendar. */ - async onSyncEnabled(syncable: Syncable): Promise { - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback token if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "google", syncableId: syncable.id } } - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } - + async onChannelEnabled(channel: Channel): Promise { // Resolve "primary" to actual calendar ID for consistent storage keys - const resolvedCalendarId = await this.resolveCalendarId(syncable.id); + const resolvedCalendarId = await this.resolveCalendarId(channel.id); // Check if sync is already in progress const syncInProgress = await this.get( @@ -237,30 +204,16 @@ export class GoogleCalendar } /** - * Called when a syncable calendar is disabled. - * Stops sync, runs the disable callback, and cleans up stored tokens. + * Called when a channel calendar is disabled. + * Stops sync and archives threads from this channel. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Run and clean up the disable callback if it exists - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.tools.callbacks.delete(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } - - // Clean up the item callback token - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.tools.callbacks.delete(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "google", syncableId: channel.id }, + }); } private async getApi(calendarId: string): Promise { @@ -366,15 +319,10 @@ export class GoogleCalendar })); } - async startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { calendarId: string; } & SyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { calendarId, timeMin, timeMax } = options; @@ -392,13 +340,6 @@ export class GoogleCalendar // Set sync lock await this.set(`sync_lock_${resolvedCalendarId}`, true); - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set("event_callback_token", callbackToken); - // Setup webhook for this calendar await this.setupCalendarWatch(resolvedCalendarId); @@ -700,20 +641,6 @@ export class GoogleCalendar calendarId: string, initialSync: boolean ): Promise { - // Hoist callback token retrieval outside loop - saves N-1 subrequests - // Try per-syncable key first, fall back to legacy key for backward compatibility - let callbackToken = await this.get( - `item_callback_${calendarId}` - ); - if (!callbackToken) { - callbackToken = await this.get("event_callback_token"); - } - if (!callbackToken) { - console.warn("No callback token found, skipping event processing"); - return; - } - - // Get user email for RSVP tagging for (const event of events) { try { // Extract contacts from organizer and attendees @@ -741,8 +668,7 @@ export class GoogleCalendar await this.processEventInstance( event, calendarId, - initialSync, - callbackToken + initialSync ); } else { // Regular or master recurring event @@ -783,7 +709,7 @@ export class GoogleCalendar thread.meta = { ...thread.meta, syncProvider: "google", syncableId: calendarId }; // Send thread - database handles upsert automatically - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); continue; } @@ -920,7 +846,7 @@ export class GoogleCalendar thread.meta = { ...thread.meta, syncProvider: "google", syncableId: calendarId }; // Send thread - database handles upsert automatically - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } } catch (error) { console.error(`Failed to process event ${event.id}:`, error); @@ -936,8 +862,7 @@ export class GoogleCalendar private async processEventInstance( event: GoogleEvent, calendarId: string, - initialSync: boolean, - callbackToken: Callback + initialSync: boolean ): Promise { const originalStartTime = event.originalStartTime?.dateTime || event.originalStartTime?.date; @@ -980,14 +905,15 @@ export class GoogleCalendar archived: true, }; - const occurrenceUpdate = { + const occurrenceUpdate: NewThreadWithNotes = { type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "google", syncableId: calendarId }, scheduleOccurrences: [cancelledOccurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveThread(occurrenceUpdate); return; } @@ -1043,19 +969,17 @@ export class GoogleCalendar occurrence.end = instanceSchedule.end; } - // Send occurrence data to the twist via callback - // The twist will decide whether to create or update the master thread - // Build a minimal NewThread with source and scheduleOccurrences - // The twist's createThread will upsert the master thread - const occurrenceUpdate = { + // The source saves directly via integrations.saveThread + const occurrenceUpdate: NewThreadWithNotes = { type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "google", syncableId: calendarId }, scheduleOccurrences: [occurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveThread(occurrenceUpdate); } async onCalendarWebhook( diff --git a/tools/google-calendar/src/index.ts b/sources/google-calendar/src/index.ts similarity index 100% rename from tools/google-calendar/src/index.ts rename to sources/google-calendar/src/index.ts diff --git a/tools/google-calendar/tsconfig.json b/sources/google-calendar/tsconfig.json similarity index 100% rename from tools/google-calendar/tsconfig.json rename to sources/google-calendar/tsconfig.json diff --git a/tools/google-contacts/CHANGELOG.md b/sources/google-contacts/CHANGELOG.md similarity index 100% rename from tools/google-contacts/CHANGELOG.md rename to sources/google-contacts/CHANGELOG.md diff --git a/tools/google-contacts/LICENSE b/sources/google-contacts/LICENSE similarity index 100% rename from tools/google-contacts/LICENSE rename to sources/google-contacts/LICENSE diff --git a/tools/google-contacts/README.md b/sources/google-contacts/README.md similarity index 100% rename from tools/google-contacts/README.md rename to sources/google-contacts/README.md diff --git a/tools/google-contacts/package.json b/sources/google-contacts/package.json similarity index 95% rename from tools/google-contacts/package.json rename to sources/google-contacts/package.json index a92c232..ffc5b26 100644 --- a/tools/google-contacts/package.json +++ b/sources/google-contacts/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-google-contacts", + "name": "@plotday/source-google-contacts", "displayName": "Google Contacts", "description": "Sync with Google Contacts", "author": "Plot (https://plot.day)", diff --git a/tools/google-contacts/src/google-contacts.ts b/sources/google-contacts/src/google-contacts.ts similarity index 81% rename from tools/google-contacts/src/google-contacts.ts rename to sources/google-contacts/src/google-contacts.ts index b7531a0..7501754 100644 --- a/tools/google-contacts/src/google-contacts.ts +++ b/sources/google-contacts/src/google-contacts.ts @@ -1,21 +1,17 @@ import { type NewContact, - Serializable, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network } from "@plotday/twister/tools/network"; -import type { GoogleContacts as IGoogleContacts, GoogleContactsOptions } from "./types"; - type ContactTokens = { connections?: { nextPageToken?: string; @@ -251,14 +247,11 @@ async function getGoogleContacts( } export default class GoogleContacts - extends Tool - implements IGoogleContacts + extends Source { static readonly id = "google-contacts"; static readonly PROVIDER = AuthProvider.Google; - static readonly Options: GoogleContactsOptions; - declare readonly Options: GoogleContactsOptions; static readonly SCOPES = [ "https://www.googleapis.com/auth/contacts.readonly", @@ -271,9 +264,9 @@ export default class GoogleContacts providers: [{ provider: GoogleContacts.PROVIDER, scopes: GoogleContacts.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }], }), network: build(Network, { @@ -282,21 +275,14 @@ export default class GoogleContacts }; } - async getSyncables(_auth: Authorization, _token: AuthToken): Promise { + async getChannels(_auth: Authorization, _token: AuthToken): Promise { return [{ id: "contacts", title: "Contacts" }]; } - async onSyncEnabled(syncable: Syncable): Promise { - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Auto-start sync + async onChannelEnabled(channel: Channel): Promise { const token = await this.tools.integrations.get( GoogleContacts.PROVIDER, - syncable.id + channel.id ); if (!token) { throw new Error("No Google authentication token available"); @@ -306,23 +292,14 @@ export default class GoogleContacts more: false, }; - await this.set(`sync_state:${syncable.id}`, initialState); + await this.set(`sync_state:${channel.id}`, initialState); - const syncCallback = await this.callback(this.syncBatch, 1, syncable.id); + const syncCallback = await this.callback(this.syncBatch, 1, channel.id); await this.run(syncCallback); } - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); - - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); } async getContacts(syncableId: string): Promise { @@ -344,10 +321,7 @@ export default class GoogleContacts return result.contacts; } - async startSync< - TArgs extends Serializable[], - TCallback extends (contacts: NewContact[], ...args: TArgs) => any - >(syncableId: string, callback: TCallback, ...extraArgs: TArgs): Promise { + async startSync(syncableId: string): Promise { const token = await this.tools.integrations.get( GoogleContacts.PROVIDER, syncableId @@ -358,29 +332,18 @@ export default class GoogleContacts ); } - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${syncableId}`, callbackToken); - - // Start initial sync const initialState: ContactSyncState = { more: false, }; await this.set(`sync_state:${syncableId}`, initialState); - // Start sync batch using run tool for long-running operation const syncCallback = await this.callback(this.syncBatch, 1, syncableId); await this.run(syncCallback); } async stopSync(syncableId: string): Promise { - // Clear sync state for this specific syncable await this.clear(`sync_state:${syncableId}`); - await this.clear(`item_callback_${syncableId}`); } async syncBatch(batchNumber: number, syncableId: string): Promise { @@ -408,7 +371,7 @@ export default class GoogleContacts ); if (result.contacts.length > 0) { - await this.processContacts(result.contacts, syncableId); + await this.processContacts(result.contacts); } await this.set(`sync_state:${syncableId}`, result.state); @@ -432,13 +395,7 @@ export default class GoogleContacts private async processContacts( contacts: NewContact[], - syncableId: string ): Promise { - const callbackToken = await this.get( - `item_callback_${syncableId}` - ); - if (callbackToken) { - await this.run(callbackToken, contacts); - } + await this.tools.integrations.saveContacts(contacts); } } diff --git a/tools/google-contacts/src/index.ts b/sources/google-contacts/src/index.ts similarity index 100% rename from tools/google-contacts/src/index.ts rename to sources/google-contacts/src/index.ts diff --git a/sources/google-contacts/src/types.ts b/sources/google-contacts/src/types.ts new file mode 100644 index 0000000..cb19b21 --- /dev/null +++ b/sources/google-contacts/src/types.ts @@ -0,0 +1,9 @@ +import type { NewContact } from "@plotday/twister"; + +export interface GoogleContacts { + getContacts(channelId: string): Promise; + + startSync(channelId: string): Promise; + + stopSync(channelId: string): Promise; +} diff --git a/tools/google-contacts/tsconfig.json b/sources/google-contacts/tsconfig.json similarity index 100% rename from tools/google-contacts/tsconfig.json rename to sources/google-contacts/tsconfig.json diff --git a/tools/google-drive/CHANGELOG.md b/sources/google-drive/CHANGELOG.md similarity index 100% rename from tools/google-drive/CHANGELOG.md rename to sources/google-drive/CHANGELOG.md diff --git a/tools/google-drive/LICENSE b/sources/google-drive/LICENSE similarity index 100% rename from tools/google-drive/LICENSE rename to sources/google-drive/LICENSE diff --git a/tools/google-drive/README.md b/sources/google-drive/README.md similarity index 100% rename from tools/google-drive/README.md rename to sources/google-drive/README.md diff --git a/tools/google-drive/package.json b/sources/google-drive/package.json similarity index 91% rename from tools/google-drive/package.json rename to sources/google-drive/package.json index 2df882d..801e3c6 100644 --- a/tools/google-drive/package.json +++ b/sources/google-drive/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-google-drive", + "name": "@plotday/source-google-drive", "displayName": "Google Drive", "description": "Sync documents comments from Google Drive", "author": "Plot (https://plot.day)", @@ -25,7 +25,7 @@ "clean": "rm -rf dist" }, "dependencies": { - "@plotday/tool-google-contacts": "workspace:^", + "@plotday/source-google-contacts": "workspace:^", "@plotday/twister": "workspace:^" }, "devDependencies": { diff --git a/tools/google-drive/src/google-api.ts b/sources/google-drive/src/google-api.ts similarity index 100% rename from tools/google-drive/src/google-api.ts rename to sources/google-drive/src/google-api.ts diff --git a/tools/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts similarity index 81% rename from tools/google-drive/src/google-drive.ts rename to sources/google-drive/src/google-drive.ts index 8775c28..c5f6473 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -1,4 +1,4 @@ -import GoogleContacts from "@plotday/tool-google-contacts"; +import GoogleContacts from "@plotday/source-google-contacts"; import { type ThreadFilter, ThreadKind, @@ -8,27 +8,23 @@ import { type NewThreadWithNotes, type NewContact, type NewNote, - Serializable, - type SyncToolOptions, - Tag, - Tool, + Source, type ToolBuilder, + Tag, } from "@plotday/twister"; import { type DocumentFolder, type DocumentSyncOptions, - type DocumentTool, + type DocumentSource, } from "@plotday/twister/common/documents"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { GoogleApi, @@ -46,7 +42,7 @@ import { } from "./google-api"; /** - * Google Drive integration tool. + * Google Drive integration source. * * Provides integration with Google Drive, supporting document * synchronization, comment syncing, and real-time updates via webhooks. @@ -62,10 +58,8 @@ import { * **Required OAuth Scopes:** * - `https://www.googleapis.com/auth/drive` - Read/write files, folders, comments */ -export class GoogleDrive extends Tool implements DocumentTool { +export class GoogleDrive extends Source implements DocumentSource { static readonly PROVIDER = AuthProvider.Google; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; static readonly SCOPES = ["https://www.googleapis.com/auth/drive"]; build(build: ToolBuilder) { @@ -78,33 +72,28 @@ export class GoogleDrive extends Tool implements DocumentTool { GoogleDrive.SCOPES, GoogleContacts.SCOPES ), - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }, ], }), network: build(Network, { urls: ["https://www.googleapis.com/drive/*"], }), - plot: build(Plot, { - contact: { - access: ContactAccess.Write, - }, - }), googleContacts: build(GoogleContacts), }; } /** - * Returns available Google Drive folders as a syncable tree. + * Returns available Google Drive folders as a channel tree. * Shared drives and root-level My Drive folders appear at the top level, * with subfolders nested under their parents. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new GoogleApi(token.token); const [folders, sharedDrives] = await Promise.all([ listFolders(api), @@ -112,14 +101,14 @@ export class GoogleDrive extends Tool implements DocumentTool { ]); // Build node map for all folders - type SyncableNode = { id: string; title: string; children: SyncableNode[] }; - const nodeMap = new Map(); + type ChannelNode = { id: string; title: string; children: ChannelNode[] }; + const nodeMap = new Map(); for (const f of folders) { nodeMap.set(f.id, { id: f.id, title: f.name, children: [] }); } // Build shared drive node map - const sharedDriveMap = new Map(); + const sharedDriveMap = new Map(); for (const drive of sharedDrives) { sharedDriveMap.set(drive.id, { id: drive.id, @@ -129,7 +118,7 @@ export class GoogleDrive extends Tool implements DocumentTool { } // Link children to parents - const roots: SyncableNode[] = []; + const roots: ChannelNode[] = []; for (const f of folders) { const node = nodeMap.get(f.id)!; const parentId = f.parents?.[0]; @@ -145,7 +134,7 @@ export class GoogleDrive extends Tool implements DocumentTool { continue; } } - // No known parent in our set → root folder (My Drive) + // No known parent in our set -> root folder (My Drive) roots.push(node); } @@ -153,7 +142,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const allRoots = [...sharedDriveMap.values(), ...roots]; // Strip empty children arrays for clean output - const clean = (nodes: SyncableNode[]): Syncable[] => { + const clean = (nodes: ChannelNode[]): Channel[] => { return nodes.map((n) => { if (n.children.length > 0) { return { id: n.id, title: n.title, children: clean(n.children) }; @@ -166,85 +155,51 @@ export class GoogleDrive extends Tool implements DocumentTool { } /** - * Called when a syncable folder is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel folder is enabled for syncing. */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const filter: ThreadFilter = { - meta: { syncProvider: "google", syncableId: syncable.id }, - }; - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - filter - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup watch and queue first batch - await this.set(`sync_lock_${syncable.id}`, true); + await this.set(`sync_lock_${channel.id}`, true); - const api = await this.getApi(syncable.id); + const api = await this.getApi(channel.id); const changesToken = await getChangesStartToken(api); const initialState: SyncState = { - folderId: syncable.id, + folderId: channel.id, changesToken, sequence: 1, }; - await this.set(`sync_state_${syncable.id}`, initialState); - await this.setupDriveWatch(syncable.id); + await this.set(`sync_state_${channel.id}`, initialState); + await this.setupDriveWatch(channel.id); const syncCallback = await this.callback( this.syncBatch, 1, - syncable.id, + channel.id, true // initialSync ); await this.runTask(syncCallback); } /** - * Called when a syncable folder is disabled. - * Stops sync, runs disable callback, and cleans up stored tokens. + * Called when a channel folder is disabled. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.deleteCallback(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } - - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "google", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } private async getApi(folderId: string): Promise { - // Get token for the syncable (folder) from integrations + // Get token for the channel (folder) from integrations const token = await this.tools.integrations.get( GoogleDrive.PROVIDER, folderId @@ -277,15 +232,10 @@ export class GoogleDrive extends Tool implements DocumentTool { })); } - async startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { folderId: string; } & DocumentSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { folderId } = options; @@ -298,13 +248,6 @@ export class GoogleDrive extends Tool implements DocumentTool { // Set sync lock await this.set(`sync_lock_${folderId}`, true); - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${folderId}`, callbackToken); - // Get changes start token for future incremental syncs const api = await this.getApi(folderId); const changesToken = await getChangesStartToken(api); @@ -348,7 +291,6 @@ export class GoogleDrive extends Tool implements DocumentTool { await this.clear(`drive_watch_${folderId}`); await this.clear(`sync_state_${folderId}`); await this.clear(`sync_lock_${folderId}`); - await this.clear(`item_callback_${folderId}`); } async addDocumentComment( @@ -418,8 +360,6 @@ export class GoogleDrive extends Tool implements DocumentTool { )) as { expiration: string; resourceId: string }; const expiry = new Date(parseInt(watchData.expiration)); - const hoursUntilExpiry = - (expiry.getTime() - Date.now()) / (1000 * 60 * 60); await this.set(`drive_watch_${folderId}`, { watchId, @@ -577,15 +517,6 @@ export class GoogleDrive extends Tool implements DocumentTool { const api = await this.getApi(folderId); const result = await listFilesInFolder(api, folderId, state.pageToken); - // Process files in this batch - const callbackToken = await this.get( - `item_callback_${folderId}` - ); - if (!callbackToken) { - console.warn("No callback token found, skipping file processing"); - return; - } - for (const file of result.files) { try { const thread = await this.buildThreadFromFile( @@ -594,7 +525,7 @@ export class GoogleDrive extends Tool implements DocumentTool { folderId, initialSync ); - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } catch (error) { console.error(`Failed to process file ${file.id}:`, error); } @@ -639,17 +570,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const api = await this.getApi(folderId); const result = await listChanges(api, changesToken); - const callbackToken = await this.get( - `item_callback_${folderId}` - ); - if (!callbackToken) { - console.warn("No callback token found, skipping incremental sync"); - await this.clear(`sync_lock_${folderId}`); - return; - } - // Filter changes to files in our synced folder - let processedCount = 0; for (const change of result.changes) { if (change.removed || !change.file) continue; @@ -660,8 +581,6 @@ export class GoogleDrive extends Tool implements DocumentTool { if (change.file.mimeType === "application/vnd.google-apps.folder") continue; - processedCount++; - try { const thread = await this.buildThreadFromFile( api, @@ -669,7 +588,7 @@ export class GoogleDrive extends Tool implements DocumentTool { folderId, false // incremental sync ); - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } catch (error) { console.error( `Failed to process changed file ${change.fileId}:`, @@ -727,7 +646,7 @@ export class GoogleDrive extends Tool implements DocumentTool { } } - // Build displayName → email lookup from file permissions + // Build displayName -> email lookup from file permissions // (Drive API doesn't return emailAddress on comment authors) const emailByName = new Map(); if (file.permissions) { diff --git a/tools/google-drive/src/index.ts b/sources/google-drive/src/index.ts similarity index 100% rename from tools/google-drive/src/index.ts rename to sources/google-drive/src/index.ts diff --git a/tools/google-drive/tsconfig.json b/sources/google-drive/tsconfig.json similarity index 100% rename from tools/google-drive/tsconfig.json rename to sources/google-drive/tsconfig.json diff --git a/tools/jira/CHANGELOG.md b/sources/jira/CHANGELOG.md similarity index 100% rename from tools/jira/CHANGELOG.md rename to sources/jira/CHANGELOG.md diff --git a/tools/jira/package.json b/sources/jira/package.json similarity index 96% rename from tools/jira/package.json rename to sources/jira/package.json index 7618209..e294776 100644 --- a/tools/jira/package.json +++ b/sources/jira/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-jira", + "name": "@plotday/source-jira", "displayName": "Jira", "description": "Sync with Jira project management", "author": "Plot (https://plot.day)", diff --git a/tools/jira/src/index.ts b/sources/jira/src/index.ts similarity index 100% rename from tools/jira/src/index.ts rename to sources/jira/src/index.ts diff --git a/tools/jira/src/jira.ts b/sources/jira/src/jira.ts similarity index 81% rename from tools/jira/src/jira.ts rename to sources/jira/src/jira.ts index b09c1f4..afc6966 100644 --- a/tools/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -5,28 +5,24 @@ import { type Action, ActionType, ThreadType, - type NewThread, type NewThreadWithNotes, NewContact, - Serializable, - type SyncToolOptions, } from "@plotday/twister"; import type { Project, ProjectSyncOptions, - ProjectTool, + ProjectSource, } from "@plotday/twister/common/projects"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; +import { Source } from "@plotday/twister/source"; +import type { ToolBuilder } from "@plotday/twister/tool"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; type SyncState = { @@ -37,16 +33,14 @@ type SyncState = { }; /** - * Jira project management tool + * Jira project management source * - * Implements the ProjectTool interface for syncing Jira projects and issues - * with Plot activities. + * Implements the ProjectSource interface for syncing Jira projects and issues + * with Plot threads. */ -export class Jira extends Tool implements ProjectTool { +export class Jira extends Source implements ProjectSource { static readonly PROVIDER = AuthProvider.Atlassian; static readonly SCOPES = ["read:jira-work", "write:jira-work", "read:jira-user"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -54,20 +48,18 @@ export class Jira extends Tool implements ProjectTool { providers: [{ provider: Jira.PROVIDER, scopes: Jira.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }], }), network: build(Network, { urls: ["https://*.atlassian.net/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } /** - * Create Jira API client using syncable-based auth + * Create Jira API client using channel-based auth */ private async getClient(projectId: string): Promise { const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); @@ -89,9 +81,9 @@ export class Jira extends Tool implements ProjectTool { } /** - * Returns available Jira projects as syncable resources. + * Returns available Jira projects as channel resources. */ - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_auth: Authorization, token: AuthToken): Promise { const cloudId = token.provider?.cloud_id; if (!cloudId) { throw new Error("No Jira cloud ID in authorization"); @@ -108,55 +100,30 @@ export class Jira extends Tool implements ProjectTool { } /** - * Handle syncable resource being enabled. - * Creates callback tokens for the sync lifecycle and auto-starts sync. + * Called when a channel is enabled for syncing. + * Sets up webhook and auto-starts sync. */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem handler - const itemCallback = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallback); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const disableCallback = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "atlassian", syncableId: syncable.id } } - ); - await this.set(`disable_callback_${syncable.id}`, disableCallback); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and queue first batch - await this.setupJiraWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupJiraWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Handle syncable resource being disabled. - * Stops sync, runs disable callback, and cleans up all stored tokens. + * Called when a channel is disabled. + * Stops sync and archives all threads from this channel. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); - - // Run and clean up disable callback - const disableCallback = await this.get(`disable_callback_${syncable.id}`); - if (disableCallback) { - await this.tools.callbacks.run(disableCallback); - await this.deleteCallback(disableCallback); - await this.clear(`disable_callback_${syncable.id}`); - } + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Clean up item callback - const itemCallback = await this.get(`item_callback_${syncable.id}`); - if (itemCallback) { - await this.deleteCallback(itemCallback); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "atlassian", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } /** @@ -181,28 +148,16 @@ export class Jira extends Tool implements ProjectTool { /** * Start syncing issues from a Jira project */ - async startSync< - TArgs extends Serializable[], - TCallback extends (issue: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & ProjectSyncOptions ): Promise { const { projectId, timeMin } = options; // Setup webhook for real-time updates await this.setupJiraWebhook(projectId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); } @@ -233,10 +188,6 @@ export class Jira extends Tool implements ProjectTool { // Store webhook URL for reference await this.set(`webhook_url_${projectId}`, webhookUrl); - - // TODO: Implement programmatic webhook creation when Jira API access is available - // The jira.js library doesn't expose webhook creation methods - // Manual configuration is required for now } catch (error) { console.error("Failed to create webhook URL:", error); } @@ -280,12 +231,6 @@ export class Jira extends Tool implements ProjectTool { throw new Error(`Sync state not found for project ${projectId}`); } - // Retrieve callback token from storage - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - throw new Error(`Callback token not found for project ${projectId}`); - } - const client = await this.getClient(projectId); // Build JQL query @@ -325,8 +270,7 @@ export class Jira extends Tool implements ProjectTool { threadWithNotes.unread = !state.initialSync; // Inject sync metadata for filtering on disable threadWithNotes.meta = { ...threadWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; - // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, threadWithNotes); + await this.tools.integrations.saveThread(threadWithNotes); } // Check if more pages @@ -356,7 +300,7 @@ export class Jira extends Tool implements ProjectTool { } /** - * Get the cloud ID using syncable-based auth + * Get the cloud ID using channel-based auth */ private async getCloudId(projectId: string): Promise { const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); @@ -671,26 +615,11 @@ export class Jira extends Tool implements ProjectTool { ): Promise { const payload = request.body as any; - // Get callback token (needed by both handlers) - const callbackToken = await this.get(`item_callback_${projectId}`); - if (!callbackToken) { - console.warn("No callback token found for project:", projectId); - return; - } - // Split handling by webhook event type for efficiency if (payload.webhookEvent?.startsWith("jira:issue_")) { - await this.handleIssueWebhook( - payload, - projectId, - callbackToken - ); + await this.handleIssueWebhook(payload, projectId); } else if (payload.webhookEvent?.startsWith("comment_")) { - await this.handleCommentWebhook( - payload, - projectId, - callbackToken - ); + await this.handleCommentWebhook(payload, projectId); } else { console.log("Ignoring webhook event:", payload.webhookEvent); } @@ -701,8 +630,7 @@ export class Jira extends Tool implements ProjectTool { */ private async handleIssueWebhook( payload: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const issue = payload.issue; if (!issue) { @@ -760,8 +688,8 @@ export class Jira extends Tool implements ProjectTool { } } - // Create partial thread update (no notes = doesn't touch existing notes) - const thread: NewThread = { + // Create partial thread update (empty notes = doesn't touch existing notes) + const thread: NewThreadWithNotes = { ...(source ? { source } : {}), type: ThreadType.Action, title: fields.summary || issue.key, @@ -774,9 +702,10 @@ export class Jira extends Tool implements ProjectTool { assignee: assigneeContact ?? null, done: fields.resolutiondate ? new Date(fields.resolutiondate) : null, preview: description || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } /** @@ -784,8 +713,7 @@ export class Jira extends Tool implements ProjectTool { */ private async handleCommentWebhook( payload: any, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const comment = payload.comment; const issue = payload.issue; @@ -853,7 +781,7 @@ export class Jira extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } /** @@ -864,13 +792,6 @@ export class Jira extends Tool implements ProjectTool { await this.clear(`webhook_url_${projectId}`); await this.clear(`webhook_id_${projectId}`); - // Cleanup callback - const callbackToken = await this.get(`item_callback_${projectId}`); - if (callbackToken) { - await this.deleteCallback(callbackToken); - await this.clear(`item_callback_${projectId}`); - } - // Cleanup sync state await this.clear(`sync_state_${projectId}`); } diff --git a/tools/jira/tsconfig.json b/sources/jira/tsconfig.json similarity index 100% rename from tools/jira/tsconfig.json rename to sources/jira/tsconfig.json diff --git a/tools/linear/CHANGELOG.md b/sources/linear/CHANGELOG.md similarity index 100% rename from tools/linear/CHANGELOG.md rename to sources/linear/CHANGELOG.md diff --git a/tools/linear/LICENSE b/sources/linear/LICENSE similarity index 100% rename from tools/linear/LICENSE rename to sources/linear/LICENSE diff --git a/tools/linear/README.md b/sources/linear/README.md similarity index 100% rename from tools/linear/README.md rename to sources/linear/README.md diff --git a/tools/linear/package.json b/sources/linear/package.json similarity index 96% rename from tools/linear/package.json rename to sources/linear/package.json index 7abc19a..85b7d1a 100644 --- a/tools/linear/package.json +++ b/sources/linear/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-linear", + "name": "@plotday/source-linear", "displayName": "Linear", "description": "Sync with Linear project management", "author": "Plot (https://plot.day)", diff --git a/tools/linear/src/index.ts b/sources/linear/src/index.ts similarity index 100% rename from tools/linear/src/index.ts rename to sources/linear/src/index.ts diff --git a/tools/linear/src/linear.ts b/sources/linear/src/linear.ts similarity index 78% rename from tools/linear/src/linear.ts rename to sources/linear/src/linear.ts index ef2abea..f702e91 100644 --- a/tools/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -11,29 +11,25 @@ import { ActionType, ThreadMeta, ThreadType, - type NewThread, type NewThreadWithNotes, type NewNote, - Serializable, - type SyncToolOptions, } from "@plotday/twister"; import type { Project, ProjectSyncOptions, - ProjectTool, + ProjectSource, } from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; -import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; +import { Source } from "@plotday/twister/source"; +import type { ToolBuilder } from "@plotday/twister/tool"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; // Cloudflare Workers provides Buffer global @@ -52,16 +48,14 @@ type SyncState = { }; /** - * Linear project management tool + * Linear project management source * - * Implements the ProjectTool interface for syncing Linear teams and issues - * with Plot activities. + * Implements the ProjectSource interface for syncing Linear teams and issues + * with Plot threads. */ -export class Linear extends Tool implements ProjectTool { +export class Linear extends Source implements ProjectSource { static readonly PROVIDER = AuthProvider.Linear; static readonly SCOPES = ["read", "write", "admin"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -70,21 +64,19 @@ export class Linear extends Tool implements ProjectTool { { provider: Linear.PROVIDER, scopes: Linear.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }, ], }), network: build(Network, { urls: ["https://api.linear.app/*"] }), - callbacks: build(Callbacks), tasks: build(Tasks), - plot: build(Plot, { contact: { access: ContactAccess.Write } }), }; } /** - * Create Linear API client using syncable-based auth + * Create Linear API client using channel-based auth */ private async getClient(projectId: string): Promise { const token = await this.tools.integrations.get(Linear.PROVIDER, projectId); @@ -95,12 +87,12 @@ export class Linear extends Tool implements ProjectTool { } /** - * Returns available Linear teams as syncable resources. + * Returns available Linear teams as channel resources. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const client = new LinearClient({ accessToken: token.token }); const teams = await client.teams(); return teams.nodes.map((team) => ({ @@ -110,59 +102,30 @@ export class Linear extends Tool implements ProjectTool { } /** - * Called when a syncable resource is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel resource is enabled for syncing. + * Sets up webhook and auto-starts sync. */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "linear", syncableId: syncable.id } } - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and begin batch sync - await this.setupLinearWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupLinearWebhook(channel.id); + await this.startBatchSync(channel.id); } /** - * Called when a syncable resource is disabled. - * Runs the disable callback, then cleans up all stored state. + * Called when a channel resource is disabled. + * Stops sync and archives all threads from this channel. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.tools.callbacks.delete(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } - - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.tools.callbacks.delete(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "linear", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } /** @@ -183,28 +146,16 @@ export class Linear extends Tool implements ProjectTool { /** * Start syncing issues from a Linear team */ - async startSync< - TArgs extends Serializable[], - TCallback extends (issue: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { projectId: string; - } & ProjectSyncOptions, - callback: TCallback, - ...extraArgs: TArgs + } & ProjectSyncOptions ): Promise { const { projectId, timeMin } = options; // Setup webhook for real-time updates await this.setupLinearWebhook(projectId); - // Store callback for webhook processing - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${projectId}`, callbackToken); - // Start initial batch sync await this.startBatchSync(projectId, { timeMin }); } @@ -291,14 +242,6 @@ export class Linear extends Tool implements ProjectTool { throw new Error(`Sync state not found for project ${projectId}`); } - // Retrieve callback token from storage - const callbackToken = await this.get( - `item_callback_${projectId}` - ); - if (!callbackToken) { - throw new Error(`Callback token not found for project ${projectId}`); - } - const client = await this.getClient(projectId); const team = await client.team(projectId); @@ -330,8 +273,7 @@ export class Linear extends Tool implements ProjectTool { syncProvider: "linear", syncableId: projectId, }; - // Execute the callback using the callback token - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); } } @@ -525,15 +467,10 @@ export class Linear extends Tool implements ProjectTool { } // Handle assignee - map Plot actor to Linear user via email lookup - const currentAssigneeActorId = thread.assignee?.id || null; - - if (!currentAssigneeActorId) { + if (!thread.assignee) { updateFields.assigneeId = null; } else { - const actors = await this.tools.plot.getActors([currentAssigneeActorId]); - const actor = actors[0]; - const email = actor?.email; - + const email = thread.assignee.email; if (email) { // Check cache first let linearUserId = await this.get(`linear_user:${email}`); @@ -560,7 +497,7 @@ export class Linear extends Tool implements ProjectTool { } } else { console.warn( - `No email found for actor ${currentAssigneeActorId}, skipping assignee update` + `No email found for assignee actor, skipping assignee update` ); } } @@ -680,27 +617,16 @@ export class Linear extends Tool implements ProjectTool { return; } - // Get callback token - const callbackToken = await this.get( - `item_callback_${projectId}` - ); - if (!callbackToken) { - console.warn("No callback token found for project:", projectId); - return; - } - // Route by webhook type if (payload.type === "Issue") { await this.handleIssueWebhook( payload as EntityWebhookPayloadWithIssueData, - projectId, - callbackToken + projectId ); } else if (payload.type === "Comment") { await this.handleCommentWebhook( payload as EntityWebhookPayloadWithCommentData, - projectId, - callbackToken + projectId ); } } @@ -710,8 +636,7 @@ export class Linear extends Tool implements ProjectTool { */ private async handleIssueWebhook( payload: EntityWebhookPayloadWithIssueData, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const issue = payload.data; const issueId = issue.id; @@ -746,9 +671,9 @@ export class Linear extends Tool implements ProjectTool { }; } - // Create partial thread update (no notes = doesn't touch existing notes) + // Create partial thread update (empty notes = doesn't touch existing notes) // Note: webhook payload dates are JSON strings, must convert to Date - const newThread: NewThread = { + const newThread: NewThreadWithNotes = { source: `linear:issue:${issue.id}`, type: ThreadType.Action, title: issue.title, @@ -768,9 +693,10 @@ export class Linear extends Tool implements ProjectTool { syncableId: projectId, }, preview: issue.description || null, + notes: [], }; - await this.tools.callbacks.run(callbackToken, newThread); + await this.tools.integrations.saveThread(newThread); } /** @@ -778,8 +704,7 @@ export class Linear extends Tool implements ProjectTool { */ private async handleCommentWebhook( payload: EntityWebhookPayloadWithCommentData, - projectId: string, - callbackToken: Callback + projectId: string ): Promise { const comment = payload.data; const commentId = comment.id; @@ -828,7 +753,7 @@ export class Linear extends Tool implements ProjectTool { }, }; - await this.tools.callbacks.run(callbackToken, newThread); + await this.tools.integrations.saveThread(newThread); } /** @@ -850,22 +775,6 @@ export class Linear extends Tool implements ProjectTool { // Cleanup webhook secret await this.clear(`webhook_secret_${projectId}`); - // Cleanup callback (legacy key for backward compatibility) - const callbackToken = await this.get(`callback_${projectId}`); - if (callbackToken) { - await this.deleteCallback(callbackToken); - await this.clear(`callback_${projectId}`); - } - - // Cleanup item callback (new key) - const itemCallbackToken = await this.get( - `item_callback_${projectId}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${projectId}`); - } - // Cleanup sync state await this.clear(`sync_state_${projectId}`); } diff --git a/tools/linear/tsconfig.json b/sources/linear/tsconfig.json similarity index 100% rename from tools/linear/tsconfig.json rename to sources/linear/tsconfig.json diff --git a/tools/outlook-calendar/CHANGELOG.md b/sources/outlook-calendar/CHANGELOG.md similarity index 100% rename from tools/outlook-calendar/CHANGELOG.md rename to sources/outlook-calendar/CHANGELOG.md diff --git a/tools/outlook-calendar/LICENSE b/sources/outlook-calendar/LICENSE similarity index 100% rename from tools/outlook-calendar/LICENSE rename to sources/outlook-calendar/LICENSE diff --git a/tools/outlook-calendar/README.md b/sources/outlook-calendar/README.md similarity index 100% rename from tools/outlook-calendar/README.md rename to sources/outlook-calendar/README.md diff --git a/tools/outlook-calendar/package.json b/sources/outlook-calendar/package.json similarity index 95% rename from tools/outlook-calendar/package.json rename to sources/outlook-calendar/package.json index 347016a..a0739ff 100644 --- a/tools/outlook-calendar/package.json +++ b/sources/outlook-calendar/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-outlook-calendar", + "name": "@plotday/source-outlook-calendar", "displayName": "Outlook Calendar", "description": "Sync with Microsoft Outlook Calendar", "author": "Plot (https://plot.day)", diff --git a/tools/outlook-calendar/src/graph-api.ts b/sources/outlook-calendar/src/graph-api.ts similarity index 100% rename from tools/outlook-calendar/src/graph-api.ts rename to sources/outlook-calendar/src/graph-api.ts diff --git a/tools/outlook-calendar/src/index.ts b/sources/outlook-calendar/src/index.ts similarity index 100% rename from tools/outlook-calendar/src/index.ts rename to sources/outlook-calendar/src/index.ts diff --git a/tools/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts similarity index 88% rename from tools/outlook-calendar/src/outlook-calendar.ts rename to sources/outlook-calendar/src/outlook-calendar.ts index 85b8a3d..5dc7ceb 100644 --- a/tools/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -10,32 +10,24 @@ import { type NewActor, type NewContact, type NewNote, - Serializable, - type SyncToolOptions, Tag, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; import type { NewScheduleOccurrence } from "@plotday/twister/schedule"; import type { Calendar, - CalendarTool, + CalendarSource, SyncOptions, } from "@plotday/twister/common/calendar"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ThreadAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; import { GraphApi, @@ -110,13 +102,11 @@ type WatchState = { * ``` */ export class OutlookCalendar - extends Tool - implements CalendarTool + extends Source + implements CalendarSource { static readonly PROVIDER = AuthProvider.Microsoft; static readonly SCOPES = ["https://graph.microsoft.com/calendars.readwrite"]; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; build(build: ToolBuilder) { return { @@ -125,73 +115,52 @@ export class OutlookCalendar { provider: OutlookCalendar.PROVIDER, scopes: OutlookCalendar.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, + onThreadUpdated: this.onThreadUpdated, }, ], }), network: build(Network, { urls: ["https://graph.microsoft.com/*"] }), - plot: build(Plot, { - contact: { access: ContactAccess.Write }, - thread: { - access: ThreadAccess.Create, - updated: this.onThreadUpdated, - }, - }), }; } /** - * Returns available Outlook calendars as syncable resources. + * Returns available Outlook calendars as channel resources. */ - async getSyncables( + async getChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new GraphApi(token.token); const calendars = await api.getCalendars(); return calendars.map((c) => ({ id: c.id, title: c.name })); } /** - * Called when a syncable calendar is enabled for syncing. - * Creates callback tokens from options and auto-starts sync. + * Called when a channel calendar is enabled for syncing. + * Auto-starts sync for the calendar. */ - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem option - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if the parent provided one - if (this.options.onSyncableDisabled) { - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "microsoft", syncableId: syncable.id } } - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup watch and queue first batch - await this.setupOutlookWatch(syncable.id); + await this.setupOutlookWatch(channel.id); // Determine default sync range (2 years into the past) const now = new Date(); const min = new Date(now.getFullYear() - 2, 0, 1); - await this.set(`outlook_sync_state_${syncable.id}`, { - calendarId: syncable.id, + await this.set(`outlook_sync_state_${channel.id}`, { + calendarId: channel.id, min, sequence: 1, } as SyncState); const syncCallback = await this.callback( this.syncOutlookBatch, - syncable.id, + channel.id, true, // initialSync 1 // batchNumber ); @@ -199,31 +168,17 @@ export class OutlookCalendar } /** - * Called when a syncable calendar is disabled. - * Cleans up callback tokens and stops sync. + * Called when a channel calendar is disabled. + * Stops sync and archives threads from this channel. */ - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); - await this.clear(`sync_enabled_${syncable.id}`); - - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.deleteCallback(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } - - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); + await this.clear(`sync_enabled_${channel.id}`); + + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "microsoft", syncableId: channel.id }, + }); } private async getApi(calendarId: string): Promise { @@ -267,23 +222,12 @@ export class OutlookCalendar return await api.getCalendars(); } - async startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { calendarId: string; } & SyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { calendarId, timeMin, timeMax } = options; - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${calendarId}`, callbackToken); // Setup webhook for this calendar await this.setupOutlookWatch(calendarId); @@ -401,15 +345,6 @@ export class OutlookCalendar await this.ensureUserIdentity(calendarId); } - // Hoist callback token retrieval outside event loop - saves N-1 subrequests - const callbackToken = await this.get( - `item_callback_${calendarId}` - ); - if (!callbackToken) { - console.warn("No callback token found, skipping event processing"); - return; - } - // Load existing sync state const savedState = await this.get( `outlook_sync_state_${calendarId}` @@ -424,12 +359,11 @@ export class OutlookCalendar // Process ONE batch (single API page) instead of while loop const result = await syncOutlookCalendar(api, calendarId, syncState); - // Process events with hoisted callback token + // Process events await this.processOutlookEvents( result.events, calendarId, - initialSync, - callbackToken + initialSync ); console.log( @@ -465,13 +399,11 @@ export class OutlookCalendar /** * Process Outlook events from a sync batch. - * Extracted to receive hoisted callback token and reduce subrequests. */ private async processOutlookEvents( events: import("./graph-api").OutlookEvent[], calendarId: string, - initialSync: boolean, - callbackToken: Callback + initialSync: boolean ): Promise { for (const outlookEvent of events) { try { @@ -510,7 +442,7 @@ export class OutlookCalendar }; // Send thread update - await this.tools.callbacks.run(callbackToken, thread); + await this.tools.integrations.saveThread(thread); continue; } @@ -544,8 +476,7 @@ export class OutlookCalendar await this.processEventInstance( outlookEvent, calendarId, - initialSync, - callbackToken + initialSync ); continue; } @@ -669,7 +600,7 @@ export class OutlookCalendar }; // Call the event callback using hoisted token - await this.tools.callbacks.run(callbackToken, threadWithNotes); + await this.tools.integrations.saveThread(threadWithNotes); } catch (error) { console.error(`Error processing event ${outlookEvent.id}:`, error); // Continue processing other events @@ -684,8 +615,7 @@ export class OutlookCalendar private async processEventInstance( event: import("./graph-api").OutlookEvent, calendarId: string, - initialSync: boolean, - callbackToken: Callback + initialSync: boolean ): Promise { const originalStart = event.originalStart; if (!originalStart) { @@ -717,14 +647,15 @@ export class OutlookCalendar archived: true, }; - const occurrenceUpdate = { + const occurrenceUpdate: NewThreadWithNotes = { type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, scheduleOccurrences: [cancelledOccurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveThread(occurrenceUpdate); return; } @@ -785,18 +716,17 @@ export class OutlookCalendar } // Send occurrence data to the twist via callback - // The twist will decide whether to create or update the master thread - // Build a minimal NewThread with source and scheduleOccurrences - // The twist's createThread will upsert the master thread - const occurrenceUpdate = { + // The source saves directly via integrations.saveThread + const occurrenceUpdate: NewThreadWithNotes = { type: ThreadType.Event, source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, scheduleOccurrences: [occurrence], + notes: [], }; - await this.tools.callbacks.run(callbackToken, occurrenceUpdate); + await this.tools.integrations.saveThread(occurrenceUpdate); } async onOutlookWebhook( diff --git a/tools/outlook-calendar/tsconfig.json b/sources/outlook-calendar/tsconfig.json similarity index 100% rename from tools/outlook-calendar/tsconfig.json rename to sources/outlook-calendar/tsconfig.json diff --git a/tools/slack/CHANGELOG.md b/sources/slack/CHANGELOG.md similarity index 100% rename from tools/slack/CHANGELOG.md rename to sources/slack/CHANGELOG.md diff --git a/tools/slack/package.json b/sources/slack/package.json similarity index 96% rename from tools/slack/package.json rename to sources/slack/package.json index af990c3..2ec5653 100644 --- a/tools/slack/package.json +++ b/sources/slack/package.json @@ -1,5 +1,5 @@ { - "name": "@plotday/tool-slack", + "name": "@plotday/source-slack", "displayName": "Slack", "description": "Sync with Slack channels and messages", "author": "Plot (https://plot.day)", diff --git a/tools/slack/src/index.ts b/sources/slack/src/index.ts similarity index 100% rename from tools/slack/src/index.ts rename to sources/slack/src/index.ts diff --git a/tools/slack/src/slack-api.ts b/sources/slack/src/slack-api.ts similarity index 100% rename from tools/slack/src/slack-api.ts rename to sources/slack/src/slack-api.ts diff --git a/tools/slack/src/slack.ts b/sources/slack/src/slack.ts similarity index 65% rename from tools/slack/src/slack.ts rename to sources/slack/src/slack.ts index eca9039..7512425 100644 --- a/tools/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -1,30 +1,21 @@ import { - type ThreadFilter, type NewThreadWithNotes, - Serializable, - type SyncToolOptions, - Tool, + Source, type ToolBuilder, } from "@plotday/twister"; import { type MessageChannel, type MessageSyncOptions, - type MessagingTool, + type MessagingSource, } from "@plotday/twister/common/messaging"; -import { type Callback } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { - ThreadAccess, - ContactAccess, - Plot, -} from "@plotday/twister/tools/plot"; import { SlackApi, @@ -36,7 +27,7 @@ import { } from "./slack-api"; /** - * Slack integration tool. + * Slack integration source. * * Provides seamless integration with Slack, supporting message * synchronization, real-time updates via webhooks, and thread handling. @@ -61,57 +52,9 @@ import { * - `chat:write` - Send messages as the bot * - `im:history` - Read direct messages with the bot * - `mpim:history` - Read group direct messages - * - * @example - * ```typescript - * class MessagesTwist extends Twist { - * private slack: Slack; - * - * constructor(id: string, tools: Tools) { - * super(); - * this.slack = tools.get(Slack); - * } - * - * async activate() { - * const authLink = await this.slack.requestAuth(this.onSlackAuth); - * - * await this.plot.createThread({ - * type: ThreadType.Action, - * title: "Connect Slack", - * actions: [authLink] - * }); - * } - * - * async onSlackAuth(auth: MessagingAuth) { - * const channels = await this.slack.getChannels(auth.authToken); - * - * // Start syncing a channel - * const general = channels.find(c => c.name === "general"); - * if (general) { - * await this.slack.startSync( - * auth.authToken, - * general.id, - * this.onSlackThread, - * { - * timeMin: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // Last 7 days - * } - * ); - * } - * } - * - * async onSlackThread(thread: NewThreadWithNotes) { - * // Process Slack message thread - * // thread contains the Thread with thread.notes containing each message - * console.log(`Thread: ${thread.title}`); - * console.log(`${thread.notes.length} messages`); - * } - * } - * ``` */ -export class Slack extends Tool implements MessagingTool { +export class Slack extends Source implements MessagingSource { static readonly PROVIDER = AuthProvider.Slack; - static readonly Options: SyncToolOptions; - declare readonly Options: SyncToolOptions; static readonly SCOPES = [ "channels:history", "channels:read", @@ -131,24 +74,20 @@ export class Slack extends Tool implements MessagingTool { { provider: Slack.PROVIDER, scopes: Slack.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.listSyncChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }, ], }), network: build(Network, { urls: ["https://slack.com/api/*"] }), - plot: build(Plot, { - contact: { access: ContactAccess.Write }, - thread: { access: ThreadAccess.Create }, - }), }; } - async getSyncables( + async listSyncChannels( _auth: Authorization, token: AuthToken - ): Promise { + ): Promise { const api = new SlackApi(token.token); const channels = await api.getChannels(); return channels @@ -156,74 +95,41 @@ export class Slack extends Tool implements MessagingTool { .map((c: SlackChannel) => ({ id: c.id, title: c.name })); } - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); - - // Create item callback token from parent's onItem handler - const itemCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onItem - ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); - - // Create disable callback if parent provided onSyncableDisabled - if (this.options.onSyncableDisabled) { - const filter: ThreadFilter = { - meta: { syncProvider: "slack", syncableId: syncable.id }, - }; - const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - filter - ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); - } + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Auto-start sync: setup webhook and queue first batch - await this.setupChannelWebhook(syncable.id); + await this.setupChannelWebhook(channel.id); - let oldest: string | undefined; // Default to 30 days of history const timeMin = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - oldest = (timeMin.getTime() / 1000).toString(); + const oldest = (timeMin.getTime() / 1000).toString(); const initialState: SyncState = { - channelId: syncable.id, + channelId: channel.id, oldest, }; - await this.set(`sync_state_${syncable.id}`, initialState); + await this.set(`sync_state_${channel.id}`, initialState); const syncCallback = await this.callback( this.syncBatch, 1, "full", - syncable.id + channel.id ); await this.run(syncCallback); } - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - // Run and clean up disable callback - const disableCallbackToken = await this.get( - `disable_callback_${syncable.id}` - ); - if (disableCallbackToken) { - await this.tools.callbacks.run(disableCallbackToken); - await this.deleteCallback(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); - } - - // Clean up item callback - const itemCallbackToken = await this.get( - `item_callback_${syncable.id}` - ); - if (itemCallbackToken) { - await this.deleteCallback(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); - } + // Archive all threads from this channel + await this.tools.integrations.archiveThreads({ + meta: { syncProvider: "slack", syncableId: channel.id }, + }); - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } private async getApi(channelId: string): Promise { @@ -250,25 +156,13 @@ export class Slack extends Tool implements MessagingTool { })); } - async startSync< - TArgs extends Serializable[], - TCallback extends (thread: NewThreadWithNotes, ...args: TArgs) => any - >( + async startSync( options: { channelId: string; } & MessageSyncOptions, - callback: TCallback, - ...extraArgs: TArgs ): Promise { const { channelId } = options; - // Create callback token for parent - const callbackToken = await this.tools.callbacks.createFromParent( - callback, - ...extraArgs - ); - await this.set(`item_callback_${channelId}`, callbackToken); - // Setup webhook for this channel (Slack Events API) await this.setupChannelWebhook(channelId); @@ -305,9 +199,6 @@ export class Slack extends Tool implements MessagingTool { // Clear sync state await this.clear(`sync_state_${channelId}`); - - // Clear callback token - await this.clear(`item_callback_${channelId}`); } private async setupChannelWebhook(channelId: string): Promise { @@ -379,15 +270,6 @@ export class Slack extends Tool implements MessagingTool { threads: SlackMessage[][], channelId: string ): Promise { - const callbackToken = await this.get( - `item_callback_${channelId}` - ); - - if (!callbackToken) { - console.error("No callback token found for channel", channelId); - return; - } - for (const thread of threads) { try { // Transform Slack thread to NewThreadWithNotes @@ -402,8 +284,8 @@ export class Slack extends Tool implements MessagingTool { syncableId: channelId, }; - // Call parent callback with the thread (contacts will be created by the API) - await this.run(callbackToken, activityThread); + // Save thread directly via integrations + await this.tools.integrations.saveThread(activityThread); } catch (error) { console.error(`Failed to process thread:`, error); // Continue processing other threads diff --git a/tools/slack/tsconfig.json b/sources/slack/tsconfig.json similarity index 100% rename from tools/slack/tsconfig.json rename to sources/slack/tsconfig.json diff --git a/tools/google-contacts/src/types.ts b/tools/google-contacts/src/types.ts deleted file mode 100644 index c6178a5..0000000 --- a/tools/google-contacts/src/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ITool, NewContact } from "@plotday/twister"; - -export type GoogleContactsOptions = { - /** Callback invoked for each batch of synced contacts. */ - onItem: (contacts: NewContact[]) => Promise; -}; - -export interface GoogleContacts extends ITool { - getContacts(syncableId: string): Promise; - - startSync any>( - syncableId: string, - callback: TCallback, - ...extraArgs: any[] - ): Promise; - - stopSync(syncableId: string): Promise; -} diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 6038646..a8a39f4 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -1,4 +1,4 @@ -import type { NewSchedule, NewScheduleOccurrence } from "./schedule"; +import type { NewSchedule, NewScheduleOccurrence, Schedule } from "./schedule"; import { type Tag } from "./tag"; import { type Callback } from "./tools/callbacks"; import { type AuthProvider } from "./tools/integrations"; @@ -496,6 +496,8 @@ type ThreadFields = ThreadCommon & { order: number; /** Array of interactive actions attached to the thread (external, conferencing, callback) */ actions: Array | null; + /** The schedule associated with this thread, if any */ + schedule?: Schedule; }; export type Thread = ThreadFields & diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts index 96dbf73..7d5d38f 100644 --- a/twister/src/schedule.ts +++ b/twister/src/schedule.ts @@ -15,14 +15,10 @@ export { Uuid } from "./utils/uuid"; * defining the pattern. */ export type Schedule = { - /** Unique identifier for the schedule */ - id: Uuid; /** When this schedule was created */ - createdAt: Date; - /** When this schedule was last updated */ - updatedAt: Date; + created: Date; /** Whether this schedule has been archived */ - archivedAt: Date | null; + archived: boolean; /** If set, this is a per-user schedule visible only to this user */ userId: ActorId | null; /** Per-user ordering within a day (only set for per-user schedules) */ @@ -48,8 +44,6 @@ export type Schedule = { * Format: Date object or "YYYY-MM-DD" for all-day events. */ occurrence: Date | string | null; - /** The thread this schedule belongs to */ - threadId: Uuid; }; /** @@ -117,44 +111,10 @@ export type NewSchedule = { order?: number | null; /** Whether to archive this schedule */ archived?: boolean; -} & ( - | { - /** - * Unique identifier for the schedule, generated by Uuid.Generate(). - * Specifying an ID allows tools to track and upsert schedules. - */ - id: Uuid; - } - | { - /* No id required. An id will be generated and returned. */ - } -); +}; -/** - * Type for updating existing schedules. - * - * Must provide `id` to identify the schedule. All other fields are optional - * and only provided fields will be updated. - * - * @example - * ```typescript - * // Reschedule - * await plot.updateSchedule({ - * id: scheduleId, - * start: new Date("2025-03-20T10:00:00Z"), - * end: new Date("2025-03-20T11:00:00Z") - * }); - * - * // Archive a schedule - * await plot.updateSchedule({ - * id: scheduleId, - * archived: true - * }); - * ``` - */ -export type ScheduleUpdate = { - id: Uuid; -} & Partial>; +/** @deprecated Schedules are updated via Thread. Use NewSchedule instead. */ +export type ScheduleUpdate = Partial>; /** * Represents a specific instance of a recurring schedule. diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index 7be5dd1..0a3bfbb 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -19,7 +19,6 @@ import { import { type Schedule, type NewSchedule, - type ScheduleUpdate, } from "../schedule"; export enum ThreadAccess { @@ -517,27 +516,6 @@ export abstract class Plot extends ITool { // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract createSchedule(schedule: NewSchedule): Promise; - /** - * Updates an existing schedule. - * - * Only the fields provided in the update object will be modified. - * - * @param schedule - The schedule update containing the ID and fields to change - * @returns Promise resolving to the updated schedule - * - * @example - * ```typescript - * // Reschedule - * await this.plot.updateSchedule({ - * id: scheduleId, - * start: new Date("2025-03-20T10:00:00Z"), - * end: new Date("2025-03-20T11:00:00Z") - * }); - * ``` - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract updateSchedule(schedule: ScheduleUpdate): Promise; - /** * Retrieves all schedules for a thread. * diff --git a/twists/calendar-sync/package.json b/twists/calendar-sync/package.json deleted file mode 100644 index 32ce7f2..0000000 --- a/twists/calendar-sync/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@plotday/twist-calendar-sync", - "plotTwistId": "0199b6f4-85b8-7b21-aeb2-ac4169e351af", - "displayName": "Calendar Sync", - "description": "Sync calendar events", - "main": "src/index.ts", - "types": "src/index.ts", - "sideEffects": false, - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-calendar": "workspace:^", - "@plotday/tool-outlook-calendar": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/calendar-sync/src/index.ts b/twists/calendar-sync/src/index.ts deleted file mode 100644 index 71c32c4..0000000 --- a/twists/calendar-sync/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { GoogleCalendar } from "@plotday/tool-google-calendar"; -import { OutlookCalendar } from "@plotday/tool-outlook-calendar"; -import { - type ThreadFilter, - type NewThreadWithNotes, - type Priority, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; - -export default class CalendarSyncTwist extends Twist { - build(build: ToolBuilder) { - return { - googleCalendar: build(GoogleCalendar, { - onItem: this.handleEvent, - onSyncableDisabled: this.handleSyncableDisabled, - }), - outlookCalendar: build(OutlookCalendar, { - onItem: this.handleEvent, - onSyncableDisabled: this.handleSyncableDisabled, - }), - plot: build(Plot, { - thread: { - access: ThreadAccess.Create, - }, - }), - }; - } - - async activate(_priority: Pick) { - // Auth and calendar selection are now handled in the twist edit modal. - } - - async handleSyncableDisabled(filter: ThreadFilter): Promise { - await this.tools.plot.updateThread({ match: filter, archived: true }); - } - - async handleEvent(thread: NewThreadWithNotes): Promise { - // Just create/upsert - database handles everything automatically - // Note: The unread field is already set by the tool based on sync type - await this.tools.plot.createThread(thread); - } -} diff --git a/twists/calendar-sync/tsconfig.json b/twists/calendar-sync/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/calendar-sync/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -} diff --git a/twists/code-review/package.json b/twists/code-review/package.json deleted file mode 100644 index 808d286..0000000 --- a/twists/code-review/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@plotday/twist-code-review", - "plotTwistId": "d4c81f01-3f43-5304-bdb1-81c77c1c713c", - "displayName": "Code Review", - "description": "Sync GitHub pull requests and code reviews", - "main": "src/index.ts", - "types": "src/index.ts", - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-github": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/code-review/src/index.ts b/twists/code-review/src/index.ts deleted file mode 100644 index 95269f1..0000000 --- a/twists/code-review/src/index.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { GitHub } from "@plotday/tool-github"; -import { - type Thread, - type ThreadFilter, - ActorType, - type NewThreadWithNotes, - type Note, - type Priority, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import type { SourceControlTool } from "@plotday/twister/common/source-control"; -import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; - -type SourceControlProvider = "github"; - -/** - * Code Review Twist - * - * Syncs source control tools (GitHub) with Plot. - * Converts pull requests into Plot activities with notes for comments - * and review summaries. - */ -export default class CodeReview extends Twist { - build(build: ToolBuilder) { - return { - github: build(GitHub, { - onItem: this.onGitHubItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { - thread: { - access: ThreadAccess.Create, - updated: this.onThreadUpdated, - }, - note: { - created: this.onNoteCreated, - }, - }), - }; - } - - /** - * Get the tool for a specific source control provider - */ - private getProviderTool(provider: SourceControlProvider): SourceControlTool { - switch (provider) { - case "github": - return this.tools.github; - default: - throw new Error(`Unknown provider: ${provider}`); - } - } - - async activate(_priority: Pick) { - // Auth and repository selection are handled in the twist edit modal. - } - - async onGitHubItem(item: NewThreadWithNotes) { - return this.onPullRequest(item, "github"); - } - - async onSyncableDisabled(filter: ThreadFilter): Promise { - await this.tools.plot.updateThread({ match: filter, archived: true }); - } - - /** - * Check if a note is fully empty (no content, no links, no mentions) - */ - private isNoteEmpty(note: { - content?: string | null; - links?: any[] | null; - mentions?: any[] | null; - }): boolean { - return ( - (!note.content || note.content.trim() === "") && - (!note.links || note.links.length === 0) && - (!note.mentions || note.mentions.length === 0) - ); - } - - /** - * Called for each PR synced from any provider. - * Creates or updates Plot activities based on PR state. - */ - async onPullRequest( - pr: NewThreadWithNotes, - provider: SourceControlProvider, - ) { - // Add provider to meta for routing updates back to the correct tool - pr.meta = { ...pr.meta, provider }; - - // Filter out empty notes to avoid warnings in Plot tool - pr.notes = pr.notes?.filter((note) => !this.isNoteEmpty(note)); - - // Create/upsert - database handles everything automatically - await this.tools.plot.createThread(pr); - } - - /** - * Called when a thread created by this twist is updated. - * Syncs changes back to the external service. - */ - private async onThreadUpdated( - thread: Thread, - _changes: { - tagsAdded: Record; - tagsRemoved: Record; - }, - ): Promise { - const provider = thread.meta?.provider as - | SourceControlProvider - | undefined; - if (!provider) return; - - const tool = this.getProviderTool(provider); - - try { - if (tool.updatePRStatus) { - await tool.updatePRStatus(thread); - } - } catch (error) { - console.error( - `Failed to sync thread update to ${provider}:`, - error, - ); - } - } - - /** - * Called when a note is created on a thread created by this twist. - * Syncs the note as a comment to the external service. - */ - private async onNoteCreated(note: Note): Promise { - const thread = note.thread; - - // Filter out notes created by twists to prevent loops - if (note.author.type === ActorType.Twist) { - return; - } - - // Only sync if note has content - if (!note.content) { - return; - } - - const provider = thread.meta?.provider as - | SourceControlProvider - | undefined; - if (!provider || !thread.meta) { - return; - } - - const tool = this.getProviderTool(provider); - if (!tool.addPRComment) { - console.warn( - `Provider ${provider} does not support adding PR comments`, - ); - return; - } - - try { - const commentKey = await tool.addPRComment( - thread.meta, - note.content, - note.id, - ); - if (commentKey) { - await this.tools.plot.updateNote({ id: note.id, key: commentKey }); - } - } catch (error) { - console.error(`Failed to sync note to ${provider}:`, error); - } - } -} diff --git a/twists/code-review/tsconfig.json b/twists/code-review/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/code-review/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -} diff --git a/twists/document-actions/package.json b/twists/document-actions/package.json deleted file mode 100644 index e36ab8d..0000000 --- a/twists/document-actions/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@plotday/twist-document-actions", - "plotTwistId": "a7e1c4d2-5f38-4b91-9d06-8c2e3a1f7b54", - "displayName": "Document Actions", - "description": "Sync documents, comments, and action items from Google Drive", - "main": "src/index.ts", - "types": "src/index.ts", - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-drive": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/document-actions/src/index.ts b/twists/document-actions/src/index.ts deleted file mode 100644 index 3b4a1ae..0000000 --- a/twists/document-actions/src/index.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { GoogleDrive } from "@plotday/tool-google-drive"; -import { - type ThreadFilter, - type NewThreadWithNotes, - type Note, - type Priority, - ActorType, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import type { DocumentTool } from "@plotday/twister/common/documents"; -import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; - -/** - * Document Actions Twist - * - * Syncs documents, comments, and action items from Google Drive with Plot. - * Converts documents into Plot threads with notes for comments, - * syncs Plot notes back as comments on the documents, - * and tags action items with Tag.Now for assigned users. - */ -export default class DocumentActions extends Twist { - build(build: ToolBuilder) { - return { - googleDrive: build(GoogleDrive, { - onItem: this.onDocument, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { - thread: { - access: ThreadAccess.Create, - }, - note: { - created: this.onNoteCreated, - }, - }), - }; - } - - /** - * Get the document tool for a provider. - * Currently only Google Drive is supported. - */ - private getProviderTool(_provider: string): DocumentTool { - return this.tools.googleDrive; - } - - async activate(_priority: Pick) { - // Auth and folder selection are now handled in the twist edit modal. - } - - async onSyncableDisabled(filter: ThreadFilter): Promise { - await this.tools.plot.updateThread({ match: filter, archived: true }); - } - - /** - * Called for each document synced from Google Drive. - */ - async onDocument(doc: NewThreadWithNotes) { - // Add provider to meta for routing updates back - doc.meta = { ...doc.meta, provider: "google-drive" }; - - await this.tools.plot.createThread(doc); - } - - /** - * Called when a note is created on a thread created by this twist. - * Syncs the note as a comment or reply to Google Drive. - */ - private async onNoteCreated(note: Note): Promise { - const thread = note.thread; - - // Filter out twist-authored notes to prevent loops - if (note.author.type === ActorType.Twist) { - return; - } - - // Only sync if note has content - if (!note.content) { - return; - } - - // Get provider from meta - const provider = thread.meta?.provider as string | undefined; - if (!provider || !thread.meta) { - return; - } - - const tool = this.getProviderTool(provider); - - // Determine if this is a reply and find the Google Drive comment ID - let commentId: string | null = null; - if (note.reNote?.id && tool.addDocumentReply) { - commentId = await this.resolveCommentId(note); - } - - try { - // Tool resolves auth token internally via integrations - let commentKey: string | void; - if (commentId && tool.addDocumentReply) { - // Reply to existing comment thread - commentKey = await tool.addDocumentReply( - thread.meta, - commentId, - note.content, - note.id - ); - } else if (tool.addDocumentComment) { - // Top-level comment - commentKey = await tool.addDocumentComment( - thread.meta, - note.content, - note.id - ); - } else { - return; - } - if (commentKey) { - await this.tools.plot.updateNote({ id: note.id, key: commentKey }); - } - } catch (error) { - console.error("Failed to sync note to provider:", error); - } - } - - /** - * Walks the reNote chain to find the root Google Drive comment ID. - * Returns the commentId extracted from a key like "comment-{commentId}", - * or null if the chain doesn't lead to a synced comment. - */ - private async resolveCommentId(note: Note): Promise { - // Fetch all notes for the thread to build the lookup map - const notes = await this.tools.plot.getNotes(note.thread); - const noteMap = new Map(notes.map((n) => [n.id, n])); - - // Walk up the reNote chain - let currentId = note.reNote?.id; - const visited = new Set(); - - while (currentId) { - if (visited.has(currentId)) break; // Prevent infinite loops - visited.add(currentId); - - const parent = noteMap.get(currentId); - if (!parent) break; - - // Check if this note's key is a comment key - if (parent.key?.startsWith("comment-")) { - return parent.key.slice("comment-".length); - } - - // Check if this note's key is a reply key (extract commentId) - if (parent.key?.startsWith("reply-")) { - const parts = parent.key.split("-"); - // key format: "reply-{commentId}-{replyId}" - if (parts.length >= 3) { - return parts[1]; - } - } - - // Continue up the chain - currentId = parent.reNote?.id; - } - - return null; - } -} diff --git a/twists/document-actions/tsconfig.json b/twists/document-actions/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/document-actions/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -} diff --git a/twists/message-tasks/package.json b/twists/message-tasks/package.json index 3a71112..71cfa6a 100644 --- a/twists/message-tasks/package.json +++ b/twists/message-tasks/package.json @@ -15,8 +15,8 @@ }, "dependencies": { "@plotday/twister": "workspace:^", - "@plotday/tool-slack": "workspace:^", - "@plotday/tool-gmail": "workspace:^", + "@plotday/source-slack": "workspace:^", + "@plotday/source-gmail": "workspace:^", "typebox": "^1.0.35" }, "devDependencies": { diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index fda9deb..edcca40 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; -import { Gmail } from "@plotday/tool-gmail"; -import { Slack } from "@plotday/tool-slack"; +import { Gmail } from "@plotday/source-gmail"; +import { Slack } from "@plotday/source-slack"; import { type ThreadFilter, ThreadType, diff --git a/twists/project-sync/package.json b/twists/project-sync/package.json deleted file mode 100644 index bedde08..0000000 --- a/twists/project-sync/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@plotday/twist-project-sync", - "plotTwistId": "c3b70e90-2e32-4293-adc0-70b66b0b602b", - "displayName": "Project Sync", - "description": "Sync project management tools like Linear, Jira, Asana, and GitHub Issues", - "main": "src/index.ts", - "types": "src/index.ts", - "version": "0.1.0", - "private": true, - "scripts": { - "deploy": "plot deploy", - "lint": "plot lint", - "logs": "plot logs" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-linear": "workspace:^", - "@plotday/tool-jira": "workspace:^", - "@plotday/tool-asana": "workspace:^", - "@plotday/tool-github-issues": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } -} diff --git a/twists/project-sync/src/index.ts b/twists/project-sync/src/index.ts deleted file mode 100644 index 7aa1536..0000000 --- a/twists/project-sync/src/index.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { Asana } from "@plotday/tool-asana"; -import { GitHubIssues } from "@plotday/tool-github-issues"; -import { Jira } from "@plotday/tool-jira"; -import { Linear } from "@plotday/tool-linear"; -import { - type Thread, - type ThreadFilter, - ActorType, - type NewThreadWithNotes, - type Note, - type Priority, - type ToolBuilder, - Twist, -} from "@plotday/twister"; -import type { ProjectTool } from "@plotday/twister/common/projects"; -import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; - -type ProjectProvider = "linear" | "jira" | "asana" | "github-issues"; - -/** - * Project Sync Twist - * - * Syncs project management tools (Linear, Jira, Asana) with Plot. - * Converts issues and tasks into Plot activities with notes for comments. - */ -export default class ProjectSync extends Twist { - build(build: ToolBuilder) { - return { - linear: build(Linear, { - onItem: this.onLinearItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - jira: build(Jira, { - onItem: this.onJiraItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - asana: build(Asana, { - onItem: this.onAsanaItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - githubIssues: build(GitHubIssues, { - onItem: this.onGitHubIssuesItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { - thread: { - access: ThreadAccess.Create, - updated: this.onThreadUpdated, - }, - note: { - created: this.onNoteCreated, - }, - }), - }; - } - - /** - * Get the tool for a specific project provider - */ - private getProviderTool(provider: ProjectProvider): ProjectTool { - switch (provider) { - case "linear": - return this.tools.linear; - case "jira": - return this.tools.jira; - case "asana": - return this.tools.asana; - case "github-issues": - return this.tools.githubIssues; - default: - throw new Error(`Unknown provider: ${provider}`); - } - } - - async activate(_priority: Pick) { - // Auth and project selection are now handled in the twist edit modal. - } - - async onLinearItem(item: NewThreadWithNotes) { - return this.onIssue(item, "linear"); - } - - async onJiraItem(item: NewThreadWithNotes) { - return this.onIssue(item, "jira"); - } - - async onAsanaItem(item: NewThreadWithNotes) { - return this.onIssue(item, "asana"); - } - - async onGitHubIssuesItem(item: NewThreadWithNotes) { - return this.onIssue(item, "github-issues"); - } - - async onSyncableDisabled(filter: ThreadFilter): Promise { - await this.tools.plot.updateThread({ match: filter, archived: true }); - } - - /** - * Check if a note is fully empty (no content, no links, no mentions) - */ - private isNoteEmpty(note: { - content?: string | null; - links?: any[] | null; - mentions?: any[] | null; - }): boolean { - return ( - (!note.content || note.content.trim() === "") && - (!note.links || note.links.length === 0) && - (!note.mentions || note.mentions.length === 0) - ); - } - - /** - * Called for each issue synced from any provider. - * Creates or updates Plot activities based on issue state. - */ - async onIssue( - issue: NewThreadWithNotes, - provider: ProjectProvider - ) { - // Add provider to meta for routing updates back to the correct tool - issue.meta = { ...issue.meta, provider }; - - // Filter out empty notes to avoid warnings in Plot tool - issue.notes = issue.notes?.filter((note) => !this.isNoteEmpty(note)); - - // Just create/upsert - database handles everything automatically - // Note: The unread field is already set by the tool based on sync type - await this.tools.plot.createThread(issue); - } - - /** - * Called when a thread created by this twist is updated. - * Syncs changes back to the external service. - */ - private async onThreadUpdated( - thread: Thread, - _changes: { - tagsAdded: Record; - tagsRemoved: Record; - } - ): Promise { - // Get provider from meta (set by this twist when creating the thread) - const provider = thread.meta?.provider as ProjectProvider | undefined; - if (!provider) return; - - const tool = this.getProviderTool(provider); - - try { - // Sync all changes using the generic updateIssue method - // Tool reads its own IDs from thread.meta (e.g., linearId, taskGid, issueKey) - // Tool resolves auth token internally via integrations - if (tool.updateIssue) { - await tool.updateIssue(thread); - } - } catch (error) { - console.error(`Failed to sync thread update to ${provider}:`, error); - } - } - - /** - * Called when a note is created on a thread created by this twist. - * Syncs the note as a comment to the external service. - */ - private async onNoteCreated(note: Note): Promise { - const thread = note.thread; - - // Filter out notes created by twists to prevent loops - if (note.author.type === ActorType.Twist) { - return; - } - - // Only sync if note has content - if (!note.content) { - return; - } - - // Get provider from meta (set by this twist when creating the thread) - const provider = thread.meta?.provider as ProjectProvider | undefined; - if (!provider || !thread.meta) { - return; - } - - const tool = this.getProviderTool(provider); - if (!tool.addIssueComment) { - console.warn(`Provider ${provider} does not support adding comments`); - return; - } - - try { - // Tool resolves auth token internally via integrations - const commentKey = await tool.addIssueComment( - thread.meta, - note.content, - note.id - ); - if (commentKey) { - await this.tools.plot.updateNote({ id: note.id, key: commentKey }); - } - } catch (error) { - console.error(`Failed to sync note to ${provider}:`, error); - } - } -} diff --git a/twists/project-sync/tsconfig.json b/twists/project-sync/tsconfig.json deleted file mode 100644 index 280de0f..0000000 --- a/twists/project-sync/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "include": ["src/**/*.ts"] -} From 118505b7b96c6d536cd754a195827db1d89684e7 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 25 Feb 2026 00:16:57 -0500 Subject: [PATCH 04/25] =?UTF-8?q?Rename=20syncable=20=E2=86=92=20channel?= =?UTF-8?q?=20in=20sources/AGENTS.md=20and=20message-tasks=20twist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- sources/AGENTS.md | 221 +++++++++++++++--------------- twists/message-tasks/src/index.ts | 11 +- 2 files changed, 116 insertions(+), 116 deletions(-) diff --git a/sources/AGENTS.md b/sources/AGENTS.md index 83050f5..d8c8b6c 100644 --- a/sources/AGENTS.md +++ b/sources/AGENTS.md @@ -1,20 +1,20 @@ -# Tool Development Guide +# Source Development Guide -This guide covers everything needed to build a Plot tool correctly. +This guide covers everything needed to build a Plot source correctly. **For twist development**: See `../twister/cli/templates/AGENTS.template.md` **For general navigation**: See `../AGENTS.md` **For type definitions**: See `../twister/src/tools/*.ts` (comprehensive JSDoc) -## Quick Start: Complete Tool Scaffold +## Quick Start: Complete Source Scaffold -Every tool follows this structure: +Every source follows this structure: ``` -tools// +sources// src/ index.ts # Re-exports: export { default, ClassName } from "./class-file" - .ts # Main Tool class + .ts # Main Source class .ts # (optional) Separate API client + transform functions package.json tsconfig.json @@ -26,7 +26,7 @@ tools// ```json { - "name": "@plotday/tool-", + "name": "@plotday/source-", "displayName": "Human Name", "description": "One-line purpose statement", "author": "Plot (https://plot.day)", @@ -56,10 +56,10 @@ tools// "repository": { "type": "git", "url": "https://github.com/plotday/plot.git", - "directory": "tools/" + "directory": "sources/" }, "homepage": "https://plot.day", - "keywords": ["plot", "tool", ""], + "keywords": ["plot", "source", ""], "publishConfig": { "access": "public" } } ``` @@ -67,7 +67,7 @@ tools// **Notes:** - `"@plotday/source"` export condition resolves to TypeScript source during workspace development - Add third-party SDKs to `dependencies` (e.g., `"@linear/sdk": "^72.0.0"`) -- Add `@plotday/tool-google-contacts` as `"workspace:^"` if your tool syncs contacts (Google tools only) +- Add `@plotday/source-google-contacts` as `"workspace:^"` if your source syncs contacts (Google sources only) ### tsconfig.json @@ -85,10 +85,10 @@ tools// ### src/index.ts ```typescript -export { default, ToolName } from "./tool-name"; +export { default, SourceName } from "./source-name"; ``` -## Tool Class Template +## Source Class Template ```typescript import { @@ -98,26 +98,27 @@ import { type NewActivityWithNotes, type NewNote, type SyncToolOptions, + Source, + type SourceBuilder, } from "@plotday/twister"; import type { NewContact } from "@plotday/twister/plot"; -import { Tool, type ToolBuilder } from "@plotday/twister/tool"; import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks"; import { AuthProvider, type AuthToken, type Authorization, Integrations, - type Syncable, + type Channel, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; -// Choose the correct common interface for your tool category: -// import type { CalendarTool, SyncOptions } from "@plotday/twister/common/calendar"; -// import type { ProjectTool, ProjectSyncOptions } from "@plotday/twister/common/projects"; -// import type { MessagingTool, MessageSyncOptions } from "@plotday/twister/common/messaging"; -// import type { DocumentTool, DocumentSyncOptions } from "@plotday/twister/common/documents"; +// Choose the correct common interface for your source category: +// import type { CalendarSource, SyncOptions } from "@plotday/twister/common/calendar"; +// import type { ProjectSource, ProjectSyncOptions } from "@plotday/twister/common/projects"; +// import type { MessagingSource, MessageSyncOptions } from "@plotday/twister/common/messaging"; +// import type { DocumentSource, DocumentSyncOptions } from "@plotday/twister/common/documents"; type SyncState = { cursor: string | null; @@ -126,7 +127,7 @@ type SyncState = { initialSync: boolean; }; -export class MyTool extends Tool implements ProjectTool { +export class MySource extends Source implements ProjectSource { // 1. Static constants static readonly PROVIDER = AuthProvider.Linear; // Use appropriate provider static readonly SCOPES = ["read", "write"]; @@ -134,15 +135,15 @@ export class MyTool extends Tool implements ProjectTool { declare readonly Options: SyncToolOptions; // 2. Declare dependencies - build(build: ToolBuilder) { + build(build: SourceBuilder) { return { integrations: build(Integrations, { providers: [{ - provider: MyTool.PROVIDER, - scopes: MyTool.SCOPES, - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + provider: MySource.PROVIDER, + scopes: MySource.SCOPES, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }], }), network: build(Network, { urls: ["https://api.example.com/*"] }), @@ -152,61 +153,61 @@ export class MyTool extends Tool implements ProjectTool { }; } - // 3. Create API client using syncable-based auth - private async getClient(syncableId: string): Promise { - const token = await this.tools.integrations.get(MyTool.PROVIDER, syncableId); + // 3. Create API client using channel-based auth + private async getClient(channelId: string): Promise { + const token = await this.tools.integrations.get(MySource.PROVIDER, channelId); if (!token) throw new Error("No authentication token available"); return new SomeApiClient({ accessToken: token.token }); } // 4. Return available resources for the user to select - async getSyncables(_auth: Authorization, token: AuthToken): Promise { + async getChannels(_auth: Authorization, token: AuthToken): Promise { const client = new SomeApiClient({ accessToken: token.token }); const resources = await client.listResources(); return resources.map(r => ({ id: r.id, title: r.name })); } // 5. Called when user enables a resource - async onSyncEnabled(syncable: Syncable): Promise { - await this.set(`sync_enabled_${syncable.id}`, true); + async onChannelEnabled(channel: Channel): Promise { + await this.set(`sync_enabled_${channel.id}`, true); // Store parent callback tokens const itemCallbackToken = await this.tools.callbacks.createFromParent( this.options.onItem ); - await this.set(`item_callback_${syncable.id}`, itemCallbackToken); + await this.set(`item_callback_${channel.id}`, itemCallbackToken); - if (this.options.onSyncableDisabled) { + if (this.options.onChannelDisabled) { const disableCallbackToken = await this.tools.callbacks.createFromParent( - this.options.onSyncableDisabled, - { meta: { syncProvider: "myprovider", syncableId: syncable.id } } + this.options.onChannelDisabled, + { meta: { syncProvider: "myprovider", channelId: channel.id } } ); - await this.set(`disable_callback_${syncable.id}`, disableCallbackToken); + await this.set(`disable_callback_${channel.id}`, disableCallbackToken); } // Setup webhook and start initial sync - await this.setupWebhook(syncable.id); - await this.startBatchSync(syncable.id); + await this.setupWebhook(channel.id); + await this.startBatchSync(channel.id); } // 6. Called when user disables a resource - async onSyncDisabled(syncable: Syncable): Promise { - await this.stopSync(syncable.id); + async onChannelDisabled(channel: Channel): Promise { + await this.stopSync(channel.id); - const disableCallbackToken = await this.get(`disable_callback_${syncable.id}`); + const disableCallbackToken = await this.get(`disable_callback_${channel.id}`); if (disableCallbackToken) { await this.tools.callbacks.run(disableCallbackToken); await this.tools.callbacks.delete(disableCallbackToken); - await this.clear(`disable_callback_${syncable.id}`); + await this.clear(`disable_callback_${channel.id}`); } - const itemCallbackToken = await this.get(`item_callback_${syncable.id}`); + const itemCallbackToken = await this.get(`item_callback_${channel.id}`); if (itemCallbackToken) { await this.tools.callbacks.delete(itemCallbackToken); - await this.clear(`item_callback_${syncable.id}`); + await this.clear(`item_callback_${channel.id}`); } - await this.clear(`sync_enabled_${syncable.id}`); + await this.clear(`sync_enabled_${channel.id}`); } // 7. Public interface methods (from common interface) @@ -308,7 +309,7 @@ export class MyTool extends Tool implements ProjectTool { activity.meta = { ...activity.meta, syncProvider: "myprovider", - syncableId: resourceId, + channelId: resourceId, }; await this.tools.callbacks.run(callbackToken, activity); } @@ -369,41 +370,41 @@ export class MyTool extends Tool implements ProjectTool { activity.meta = { ...activity.meta, syncProvider: "myprovider", - syncableId: resourceId, + channelId: resourceId, }; await this.tools.callbacks.run(callbackToken, activity); } } -export default MyTool; +export default MySource; ``` -## Common Tool Interfaces +## Common Source Interfaces Choose the correct interface based on what your service provides. Import from `@plotday/twister/common/*`. | Interface | For | Examples | Key resource | |-----------|-----|----------|-------------| -| `CalendarTool` | Calendar/scheduling services | Google Calendar, Outlook, Apple Calendar | Calendars with events | -| `ProjectTool` | Project/task management | Linear, Jira, Asana, GitHub Issues, Todoist, ClickUp, Trello, Monday | Projects with issues/tasks | -| `MessagingTool` | Email and chat services | Gmail, Slack, Discord, Microsoft Teams, Intercom | Channels/inboxes with threads | -| `DocumentTool` | Document/file services | Google Drive, Notion, Dropbox, OneDrive, Confluence | Folders with documents | +| `CalendarSource` | Calendar/scheduling services | Google Calendar, Outlook, Apple Calendar | Calendars with events | +| `ProjectSource` | Project/task management | Linear, Jira, Asana, GitHub Issues, Todoist, ClickUp, Trello, Monday | Projects with issues/tasks | +| `MessagingSource` | Email and chat services | Gmail, Slack, Discord, Microsoft Teams, Intercom | Channels/inboxes with threads | +| `DocumentSource` | Document/file services | Google Drive, Notion, Dropbox, OneDrive, Confluence | Folders with documents | | None | Services that don't fit above | CRM, analytics, monitoring | Define your own interface | Each interface requires these methods: `get[Resources]()`, `startSync()`, `stopSync()`. Some have optional methods for bidirectional sync (`updateIssue`, `addIssueComment`, `addDocumentComment`, etc.). -## The Integrations Pattern (Auth + Syncables) +## The Integrations Pattern (Auth + Channels) -**This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Tools declare their provider config in `build()`. +**This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Sources declare their provider config in `build()`. ### How It Works -1. Tool declares providers in `build()` with `getSyncables`, `onSyncEnabled`, `onSyncDisabled` callbacks +1. Source declares providers in `build()` with `getChannels`, `onChannelEnabled`, `onChannelDisabled` callbacks 2. User clicks "Connect" in the twist edit modal → OAuth flow happens automatically -3. After auth, the runtime calls your `getSyncables()` to list available resources -4. User enables resources in the modal → `onSyncEnabled()` fires -5. User disables resources → `onSyncDisabled()` fires -6. Get tokens via `this.tools.integrations.get(PROVIDER, syncableId)` +3. After auth, the runtime calls your `getChannels()` to list available resources +4. User enables resources in the modal → `onChannelEnabled()` fires +5. User disables resources → `onChannelDisabled()` fires +6. Get tokens via `this.tools.integrations.get(PROVIDER, channelId)` ### Available Providers @@ -415,7 +416,7 @@ For bidirectional sync where actions should be attributed to the acting user: ```typescript await this.tools.integrations.actAs( - MyTool.PROVIDER, + MySource.PROVIDER, actorId, // The user who performed the action activityId, // Activity to create auth prompt in (if user hasn't connected) this.performWriteBack, @@ -429,25 +430,25 @@ async performWriteBack(token: AuthToken, ...extraArgs: any[]): Promise { } ``` -### Cross-Tool Auth Sharing (Google Tools) +### Cross-Source Auth Sharing (Google Sources) -When building a Google tool that should also sync contacts, merge scopes: +When building a Google source that should also sync contacts, merge scopes: ```typescript -import GoogleContacts from "@plotday/tool-google-contacts"; +import GoogleContacts from "@plotday/source-google-contacts"; -build(build: ToolBuilder) { +build(build: SourceBuilder) { return { integrations: build(Integrations, { providers: [{ provider: AuthProvider.Google, scopes: Integrations.MergeScopes( - MyGoogleTool.SCOPES, + MyGoogleSource.SCOPES, GoogleContacts.SCOPES ), - getSyncables: this.getSyncables, - onSyncEnabled: this.onSyncEnabled, - onSyncDisabled: this.onSyncDisabled, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, }], }), googleContacts: build(GoogleContacts), @@ -456,18 +457,18 @@ build(build: ToolBuilder) { } ``` -## Architecture: Tools Build, Twists Save +## Architecture: Sources Save Directly -**Tools NEVER call `plot.createActivity()` directly.** Tools build `NewActivityWithNotes` objects and deliver them to the parent twist via `this.tools.callbacks.run(callbackToken, activity)`. The parent twist decides what to save. +**Sources save data directly** via `integrations.saveThread()`. Sources build `NewActivityWithNotes` objects and save them, rather than passing them through a parent twist. This means: -- Tools request `Plot` with `ContactAccess.Write` (for contacts on activities), not `ActivityAccess.Create` -- Tools declare `static readonly Options: SyncToolOptions` to receive the `onItem` callback from the parent -- The parent twist's `onItem` callback calls `this.tools.plot.createActivity(activity)` +- Sources request `Plot` with `ContactAccess.Write` (for contacts on activities) +- Sources declare `static readonly Options: SyncToolOptions` to receive configuration from the parent +- Sources call save methods directly to persist synced data ## Critical: Callback Serialization Pattern -**The #1 mistake when building tools is passing function references as callback arguments.** Functions cannot be serialized across worker boundaries. +**The #1 mistake when building sources is passing function references as callback arguments.** Functions cannot be serialized across worker boundaries. ### ❌ WRONG - Passing Function as Callback Argument @@ -518,7 +519,7 @@ async syncBatch(resourceId: string): Promise { ## Callback Backward Compatibility -**All callbacks automatically upgrade to new tool versions on deployment.** You MUST maintain backward compatibility. +**All callbacks automatically upgrade to new source versions on deployment.** You MUST maintain backward compatibility. - ❌ Don't change function signatures (remove/reorder params, change types) - ✅ Do add optional parameters at the end @@ -551,12 +552,12 @@ async preUpgrade(): Promise { ## Storage Key Conventions -All tools use consistent key prefixes: +All sources use consistent key prefixes: | Key Pattern | Purpose | |------------|---------| | `item_callback_` | Serialized callback to parent's `onItem` | -| `disable_callback_` | Serialized callback to parent's `onSyncableDisabled` | +| `disable_callback_` | Serialized callback to parent's `onChannelDisabled` | | `sync_state_` | Current batch pagination state | | `sync_enabled_` | Boolean tracking enabled state | | `webhook_id_` | External webhook registration ID | @@ -572,7 +573,7 @@ The `activity.source` field is the idempotency key for automatic upserts. Use a :: — When provider has multiple entity types ``` -Examples from existing tools: +Examples from existing sources: ``` linear:issue: asana:task: @@ -607,15 +608,15 @@ https://slack.com/app_redirect?channel=&message_ts= — Slack uses full activity.meta = { ...activity.meta, syncProvider: "myprovider", // Provider identifier - syncableId: resourceId, // Resource being synced + channelId: resourceId, // Resource being synced }; ``` -This metadata is used by the twist's `onSyncableDisabled` callback to match and archive activities: +This metadata is used by the twist's `onChannelDisabled` callback to match and archive activities: ```typescript // In the twist: -async onSyncableDisabled(filter: ActivityFilter): Promise { +async onChannelDisabled(filter: ActivityFilter): Promise { await this.tools.plot.updateActivity({ match: filter, archived: true }); } ``` @@ -639,7 +640,7 @@ const activity = { ### Localhost Guard (REQUIRED) -All tools MUST skip webhook registration in local development: +All sources MUST skip webhook registration in local development: ```typescript const webhookUrl = await this.tools.network.createWebhook({}, this.onWebhook, resourceId); @@ -678,7 +679,7 @@ private async scheduleWatchRenewal(resourceId: string): Promise { ## Bidirectional Sync -For tools that support write-backs (updating external items from Plot): +For sources that support write-backs (updating external items from Plot): ### Issue/Task Updates (`updateIssue`) @@ -713,7 +714,7 @@ async addIssueComment(meta: ActivityMeta, body: string, noteId?: string): Promis The parent twist prevents infinite loops by checking note authorship: ```typescript -// In the twist (not the tool): +// In the twist (not the source): async onNoteCreated(note: Note): Promise { if (note.author.type === ActorType.Twist) return; // Prevent loops // ... sync note to external service @@ -722,7 +723,7 @@ async onNoteCreated(note: Note): Promise { ## Contacts Pattern -Tools that sync user data should create contacts for authors and assignees: +Sources that sync user data should create contacts for authors and assignees: ```typescript import type { NewContact } from "@plotday/twister/plot"; @@ -765,25 +766,25 @@ declare const Buffer: { ## Building and Testing ```bash -# Build the tool -cd public/tools/ && pnpm build +# Build the source +cd public/sources/ && pnpm build # Type-check without building -cd public/tools/ && pnpm exec tsc --noEmit +cd public/sources/ && pnpm exec tsc --noEmit # Install dependencies (from repo root) pnpm install ``` -After creating a new tool, add it to `pnpm-workspace.yaml` if not already covered by the glob pattern. +After creating a new source, add it to `pnpm-workspace.yaml` if not already covered by the glob pattern. -## Tool Development Checklist +## Source Development Checklist -- [ ] Extend `Tool` and implement the correct common interface +- [ ] Extend `Source` and implement the correct common interface - [ ] Declare `static readonly PROVIDER`, `static readonly SCOPES` - [ ] Declare `static readonly Options: SyncToolOptions` and `declare readonly Options: SyncToolOptions` - [ ] Declare all dependencies in `build()`: Integrations, Network, Callbacks, Tasks, Plot -- [ ] Implement `getSyncables()`, `onSyncEnabled()`, `onSyncDisabled()` +- [ ] Implement `getChannels()`, `onChannelEnabled()`, `onChannelDisabled()` - [ ] Convert parent callbacks to tokens with `createFromParent()` — **never pass functions to `this.callback()`** - [ ] Store callback tokens with `this.set()`, retrieve with `this.get()` - [ ] Pass only serializable values (no functions, no undefined) to `this.callback()` @@ -792,23 +793,23 @@ After creating a new tool, add it to `pnpm-workspace.yaml` if not already covere - [ ] Verify webhook signatures - [ ] Use canonical `source` URLs for activity upserts (immutable IDs) - [ ] Use `note.key` for note-level upserts -- [ ] Inject `syncProvider` and `syncableId` into `activity.meta` +- [ ] Inject `syncProvider` and `channelId` into `activity.meta` - [ ] Handle `initialSync` flag: `unread: false` and `archived: false` for initial, omit both for incremental - [ ] Create contacts for authors/assignees with `NewContact` -- [ ] Clean up all stored state and callbacks in `stopSync()` and `onSyncDisabled()` +- [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()` - [ ] Add `package.json` with correct structure, `tsconfig.json`, and `src/index.ts` re-export -- [ ] Verify the tool builds: `pnpm build` +- [ ] Verify the source builds: `pnpm build` ## Common Pitfalls 1. **❌ Passing functions to `this.callback()`** — Convert to tokens first with `createFromParent()` 2. **❌ Storing functions with `this.set()`** — Convert to tokens first 3. **❌ Not validating callback token exists** — Always check before `callbacks.run()` -4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `syncableId` into `activity.meta` +4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `activity.meta` 5. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key) 6. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit 7. **❌ Missing localhost guard** — Webhook registration fails silently on localhost -8. **❌ Calling `plot.createActivity()` from a tool** — Tools build data, twists save it +8. **❌ Calling `plot.createActivity()` from a source** — Sources save data directly via `integrations.saveThread()` 9. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only 10. **❌ Passing `undefined` in serializable values** — Use `null` instead 11. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state @@ -816,14 +817,14 @@ After creating a new tool, add it to `pnpm-workspace.yaml` if not already covere ## Study These Examples -| Tool | Category | Key Patterns | -|------|----------|-------------| -| `linear/` | ProjectTool | Clean reference implementation, webhook handling, bidirectional sync | -| `google-calendar/` | CalendarTool | Recurring events, RSVP write-back, watch renewal, cross-tool auth sharing | -| `slack/` | MessagingTool | Team-sharded webhooks, thread model, Slack-specific auth | -| `gmail/` | MessagingTool | PubSub webhooks, email thread transformation | -| `google-drive/` | DocumentTool | Document comments, reply threading, file watching | -| `jira/` | ProjectTool | Immutable vs mutable IDs, comment metadata for dedup | -| `asana/` | ProjectTool | HMAC webhook verification, section-based projects | -| `outlook-calendar/` | CalendarTool | Microsoft Graph API, subscription management | -| `google-contacts/` | (Supporting) | Contact sync, cross-tool `syncWithAuth()` pattern | +| Source | Category | Key Patterns | +|--------|----------|-------------| +| `linear/` | ProjectSource | Clean reference implementation, webhook handling, bidirectional sync | +| `google-calendar/` | CalendarSource | Recurring events, RSVP write-back, watch renewal, cross-source auth sharing | +| `slack/` | MessagingSource | Team-sharded webhooks, thread model, Slack-specific auth | +| `gmail/` | MessagingSource | PubSub webhooks, email thread transformation | +| `google-drive/` | DocumentSource | Document comments, reply threading, file watching | +| `jira/` | ProjectSource | Immutable vs mutable IDs, comment metadata for dedup | +| `asana/` | ProjectSource | HMAC webhook verification, section-based projects | +| `outlook-calendar/` | CalendarSource | Microsoft Graph API, subscription management | +| `google-contacts/` | (Supporting) | Contact sync, cross-source `syncWithAuth()` pattern | diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index edcca40..7cf30c1 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -38,11 +38,11 @@ export default class MessageTasksTwist extends Twist { return { slack: build(Slack, { onItem: this.onSlackThread, - onSyncableDisabled: this.onSyncableDisabled, + onChannelDisabled: this.onChannelDisabled, }), gmail: build(Gmail, { onItem: this.onGmailThread, - onSyncableDisabled: this.onSyncableDisabled, + onChannelDisabled: this.onChannelDisabled, }), ai: build(AI), plot: build(Plot, { @@ -92,16 +92,16 @@ export default class MessageTasksTwist extends Twist { } async onSlackThread(thread: NewThreadWithNotes): Promise { - const channelId = thread.meta?.syncableId as string; + const channelId = thread.meta?.channelId as string; return this.onMessageThread(thread, "slack", channelId); } async onGmailThread(thread: NewThreadWithNotes): Promise { - const channelId = thread.meta?.syncableId as string; + const channelId = thread.meta?.channelId as string; return this.onMessageThread(thread, "gmail", channelId); } - async onSyncableDisabled(filter: ThreadFilter): Promise { + async onChannelDisabled(filter: ThreadFilter): Promise { await this.tools.plot.updateThread({ match: filter, archived: true }); } @@ -493,7 +493,6 @@ If a task is needed, create a clear, actionable title that describes what the us source: `message-tasks:${threadId}`, type: ThreadType.Action, title: analysis.taskTitle || thread.title || "Action needed from message", - start: new Date(), notes: analysis.taskNote ? [ { From 8d9cb2647303f85f8d11876a8bad3ea43c953ee7 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 25 Feb 2026 07:42:03 -0500 Subject: [PATCH 05/25] Add ScheduleContact types to SDK Co-Authored-By: Claude Opus 4.6 --- twister/src/schedule.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts index 7d5d38f..45b5c9e 100644 --- a/twister/src/schedule.ts +++ b/twister/src/schedule.ts @@ -1,5 +1,5 @@ import { type Tag } from "./tag"; -import { type ActorId, type NewTags, type Tags } from "./plot"; +import { type ActorId, type NewActor, type NewTags, type Tags } from "./plot"; import { Uuid } from "./utils/uuid"; export { Uuid } from "./utils/uuid"; @@ -44,6 +44,25 @@ export type Schedule = { * Format: Date object or "YYYY-MM-DD" for all-day events. */ occurrence: Date | string | null; + /** Contacts invited to this schedule (attendees/participants) */ + contacts: ScheduleContact[]; +}; + +export type ScheduleContactStatus = "attend" | "skip"; +export type ScheduleContactRole = "organizer" | "required" | "optional"; + +export type ScheduleContact = { + contact: ActorId; + status: ScheduleContactStatus | null; + role: ScheduleContactRole | null; + archived: boolean; +}; + +export type NewScheduleContact = { + contact: NewActor; + status?: ScheduleContactStatus | null; + role?: ScheduleContactRole | null; + archived?: boolean; }; /** @@ -111,6 +130,8 @@ export type NewSchedule = { order?: number | null; /** Whether to archive this schedule */ archived?: boolean; + /** Contacts to upsert on this schedule. Upserted by contact identity. */ + contacts?: NewScheduleContact[]; }; /** @deprecated Schedules are updated via Thread. Use NewSchedule instead. */ @@ -161,6 +182,9 @@ export type NewScheduleOccurrence = Pick< /** Whether this occurrence should be marked as unread */ unread?: boolean; + + /** Contacts to upsert on this occurrence's schedule */ + contacts?: NewScheduleContact[]; }; /** From b34aed84f9dcb732f59b36d62958ee4f873c8d33 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 25 Feb 2026 07:42:48 -0500 Subject: [PATCH 06/25] Remove RSVP tags (Attend/Skip/Undecided) from Tag enum Co-Authored-By: Claude Opus 4.6 --- twister/src/tag.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/twister/src/tag.ts b/twister/src/tag.ts index 05c06b1..e20695a 100644 --- a/twister/src/tag.ts +++ b/twister/src/tag.ts @@ -44,9 +44,4 @@ export enum Tag { Applause = 1016, Cool = 1017, Sad = 1018, - // RSVP tags - mutually exclusive per actor - // When an actor adds one of these tags, the other two are automatically removed - Attend = 1019, - Skip = 1020, - Undecided = 1021, } From e67b0e66af56a20273714a6f58e62fae81fac7f8 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 25 Feb 2026 07:49:36 -0500 Subject: [PATCH 07/25] Change ScheduleContact.contact from ActorId to Actor Sources need email addresses, not just IDs, to match attendees. Co-Authored-By: Claude Opus 4.6 --- twister/src/schedule.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts index 45b5c9e..4cdb30e 100644 --- a/twister/src/schedule.ts +++ b/twister/src/schedule.ts @@ -1,5 +1,11 @@ import { type Tag } from "./tag"; -import { type ActorId, type NewActor, type NewTags, type Tags } from "./plot"; +import { + type Actor, + type ActorId, + type NewActor, + type NewTags, + type Tags, +} from "./plot"; import { Uuid } from "./utils/uuid"; export { Uuid } from "./utils/uuid"; @@ -52,7 +58,7 @@ export type ScheduleContactStatus = "attend" | "skip"; export type ScheduleContactRole = "organizer" | "required" | "optional"; export type ScheduleContact = { - contact: ActorId; + contact: Actor; status: ScheduleContactStatus | null; role: ScheduleContactRole | null; archived: boolean; From 3ef632656e4c9044f41de1acf8ba42e938b21b9d Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 25 Feb 2026 07:51:28 -0500 Subject: [PATCH 08/25] Make ScheduleContact.role non-nullable Every attendee has a role; default to 'required'. Co-Authored-By: Claude Opus 4.6 --- twister/src/schedule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts index 4cdb30e..59c1af4 100644 --- a/twister/src/schedule.ts +++ b/twister/src/schedule.ts @@ -60,7 +60,7 @@ export type ScheduleContactRole = "organizer" | "required" | "optional"; export type ScheduleContact = { contact: Actor; status: ScheduleContactStatus | null; - role: ScheduleContactRole | null; + role: ScheduleContactRole; archived: boolean; }; From bb5084e16b1b757a380c5eed8d0c87a94e992b43 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 25 Feb 2026 09:55:08 -0500 Subject: [PATCH 09/25] Use schedule contacts instead of RSVP tags, add links support - Google Calendar & Outlook Calendar: replace RSVP tag mapping with NewScheduleContact[] on schedules and occurrences, remove onThreadUpdated RSVP write-back - Twister SDK: add Link type, saveLink to integrations, thread/link schedule types - All sources: adopt thread/link/schedule API changes Co-Authored-By: Claude Opus 4.6 --- sources/asana/src/asana.ts | 62 +-- sources/github-issues/src/github-issues.ts | 83 +++-- sources/github/src/github.ts | 40 +- sources/gmail/src/gmail-api.ts | 20 +- sources/gmail/src/gmail.ts | 10 +- .../google-calendar/src/google-calendar.ts | 352 ++++-------------- sources/google-drive/src/google-drive.ts | 17 +- sources/jira/src/jira.ts | 50 ++- sources/linear/src/linear.ts | 77 ++-- .../outlook-calendar/src/outlook-calendar.ts | 343 ++++------------- sources/slack/src/slack-api.ts | 20 +- sources/slack/src/slack.ts | 10 +- twister/src/plot.ts | 124 +++++- twister/src/tools/integrations.ts | 39 +- 14 files changed, 530 insertions(+), 717 deletions(-) diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index 918ffb9..13b4493 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -6,8 +6,7 @@ import { ActionType, ThreadMeta, ThreadType, - type NewThreadWithNotes, - type NewNote, + type NewLinkWithNotes, } from "@plotday/twister"; import type { Project, @@ -50,6 +49,16 @@ export class Asana extends Source implements ProjectSource { providers: [{ provider: Asana.PROVIDER, scopes: Asana.SCOPES, + linkTypes: [ + { + type: "task", + label: "Task", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, @@ -283,17 +292,17 @@ export class Asana extends Source implements ProjectSource { } } - const threadWithNotes = await this.convertTaskToThread( + const linkWithNotes = await this.convertTaskToLink( task, projectId ); // Set unread based on sync type (false for initial sync to avoid notification overload) - threadWithNotes.unread = !state.initialSync; + linkWithNotes.unread = !state.initialSync; // Unarchive on initial sync only (preserve user's archive state on incremental syncs) if (state.initialSync) { - threadWithNotes.archived = false; + linkWithNotes.archived = false; } - await this.tools.integrations.saveThread(threadWithNotes); + await this.tools.integrations.saveLink(linkWithNotes); } // Check if more pages by checking if we got a full batch @@ -321,12 +330,12 @@ export class Asana extends Source implements ProjectSource { } /** - * Convert an Asana task to a Plot Thread + * Convert an Asana task to a Plot Link */ - private async convertTaskToThread( + private async convertTaskToLink( task: any, projectId: string - ): Promise { + ): Promise { const createdBy = task.created_by; const assignee = task.assignee; @@ -350,7 +359,7 @@ export class Asana extends Source implements ProjectSource { } // Build notes array: always create initial note with description and link - const notes: NewNote[] = []; + const notes: any[] = []; // Extract description (if any) let description: string | null = null; @@ -374,7 +383,6 @@ export class Asana extends Source implements ProjectSource { // Create initial note with description (actions moved to thread level) notes.push({ - thread: { source: threadSource }, key: "description", content: description, created: task.created_at ? new Date(task.created_at) : undefined, @@ -382,7 +390,7 @@ export class Asana extends Source implements ProjectSource { return { source: threadSource, - type: ThreadType.Action, + type: "task", title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, meta: { @@ -394,10 +402,7 @@ export class Asana extends Source implements ProjectSource { actions: threadActions.length > 0 ? threadActions : undefined, author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned tasks - done: - task.completed && task.completed_at - ? new Date(task.completed_at) - : null, + status: task.completed && task.completed_at ? "done" : "open", notes, preview: description || null, }; @@ -622,10 +627,10 @@ export class Asana extends Source implements ProjectSource { description = task.notes; } - // Create partial thread update (empty notes = doesn't touch existing notes) - const thread: NewThreadWithNotes = { + // Create partial link update (empty notes = doesn't touch existing notes) + const link: NewLinkWithNotes = { source: threadSource, - type: ThreadType.Action, + type: "task", title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, meta: { @@ -636,15 +641,12 @@ export class Asana extends Source implements ProjectSource { }, author: authorContact, assignee: assigneeContact ?? null, - done: - task.completed && task.completed_at - ? new Date(task.completed_at) - : null, + status: task.completed && task.completed_at ? "done" : "open", preview: description || null, notes: [], }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); } catch (error) { console.warn("Failed to process Asana task webhook:", error); } @@ -698,20 +700,20 @@ export class Asana extends Source implements ProjectSource { }; } - // Create thread update with single story note - const thread: NewThreadWithNotes = { + // Create link update with single story note + const link: NewLinkWithNotes = { source: threadSource, - type: ThreadType.Action, // Required field (will match existing thread) + type: "task", + title: taskGid, // Placeholder; upsert by source will preserve existing title notes: [ { key: `story-${latestStory.gid}`, - thread: { source: threadSource }, content: latestStory.text || "", created: latestStory.created_at ? new Date(latestStory.created_at) : undefined, author: storyAuthor, - } as NewNote, + } as any, ], meta: { taskGid, @@ -721,7 +723,7 @@ export class Asana extends Source implements ProjectSource { }, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); } catch (error) { console.warn("Failed to process Asana story webhook:", error); } diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index 59ec3c0..02d7a42 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -5,8 +5,7 @@ import { ActionType, type ThreadMeta, ThreadType, - type NewThreadWithNotes, - type NewNote, + type NewLinkWithNotes, } from "@plotday/twister"; import type { Project, @@ -57,6 +56,25 @@ export class GitHubIssues extends Source implements ProjectSource { provider: GitHubIssues.PROVIDER, scopes: GitHubIssues.SCOPES, + linkTypes: [ + { + type: "issue", + label: "Issue", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + ], + }, + { + type: "pull_request", + label: "Pull Request", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + { status: "merged", label: "Merged" }, + ], + }, + ], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, @@ -300,7 +318,7 @@ export class GitHubIssues extends Source implements ProjectSource // Skip pull requests (GitHub returns PRs in issues endpoint) if (issue.pull_request) continue; - const thread = await this.convertIssueToThread( + const link = await this.convertIssueToLink( octokit, issue, repoId, @@ -308,13 +326,13 @@ export class GitHubIssues extends Source implements ProjectSource state.initialSync ); - if (thread) { - thread.meta = { - ...thread.meta, + if (link) { + link.meta = { + ...link.meta, syncProvider: "github-issues", syncableId: repoId, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); processedInBatch++; } } @@ -360,15 +378,15 @@ export class GitHubIssues extends Source implements ProjectSource } /** - * Convert a GitHub issue to a NewThreadWithNotes + * Convert a GitHub issue to a NewLinkWithNotes */ - private async convertIssueToThread( + private async convertIssueToLink( octokit: Octokit, issue: any, repoId: string, repoFullName: string, initialSync: boolean - ): Promise { + ): Promise { // Build author contact (GitHub users may not have email) let authorContact: NewContact | undefined; if (issue.user) { @@ -459,14 +477,14 @@ export class GitHubIssues extends Source implements ProjectSource ); } - const thread: NewThreadWithNotes = { + const link: NewLinkWithNotes = { source: `github:issue:${repoId}:${issue.number}`, - type: ThreadType.Action, + type: "issue", title: issue.title, created: issue.created_at, author: authorContact, assignee: assigneeContact ?? null, - done: issue.closed_at ?? null, + status: issue.closed_at ? "closed" : "open", meta: { githubIssueNumber: issue.number, githubRepoId: repoId, @@ -480,7 +498,7 @@ export class GitHubIssues extends Source implements ProjectSource ...(initialSync ? { archived: false } : {}), }; - return thread; + return link; } /** @@ -676,14 +694,14 @@ export class GitHubIssues extends Source implements ProjectSource }; } - const thread: NewThreadWithNotes = { + const link: NewLinkWithNotes = { source: `github:issue:${repoId}:${issue.number}`, - type: ThreadType.Action, + type: "issue", title: issue.title, created: issue.created_at, author: authorContact, assignee: assigneeContact ?? null, - done: issue.closed_at ?? null, + status: issue.closed_at ? "closed" : "open", meta: { githubIssueNumber: issue.number, githubRepoId: repoId, @@ -696,7 +714,7 @@ export class GitHubIssues extends Source implements ProjectSource notes: [], }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); } /** @@ -727,19 +745,20 @@ export class GitHubIssues extends Source implements ProjectSource }; } - const threadSource = `github:issue:${repoId}:${issue.number}`; - const note: NewNote = { - key: `comment-${comment.id}`, - thread: { source: threadSource }, - content: comment.body ?? null, - created: comment.created_at, - author: commentAuthor, - }; - - const thread: NewThreadWithNotes = { - source: threadSource, - type: ThreadType.Action, - notes: [note], + const linkSource = `github:issue:${repoId}:${issue.number}`; + + const link: NewLinkWithNotes = { + source: linkSource, + type: "issue", + title: issue.title || `#${issue.number}`, // Placeholder; upsert by source will preserve existing title + notes: [ + { + key: `comment-${comment.id}`, + content: comment.body ?? null, + created: comment.created_at, + author: commentAuthor, + } as any, + ], meta: { githubIssueNumber: issue.number, githubRepoId: repoId, @@ -750,7 +769,7 @@ export class GitHubIssues extends Source implements ProjectSource }, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); } /** diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index a109be2..ff01e2b 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -4,7 +4,7 @@ import { ActionType, type ThreadMeta, ThreadType, - type NewThreadWithNotes, + type NewLinkWithNotes, Source, type ToolBuilder, } from "@plotday/twister"; @@ -110,6 +110,10 @@ export class GitHub extends Source implements SourceControlSource { getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, + linkTypes: [ + { type: "pull_request", label: "Pull Request" }, + { type: "review", label: "Review" }, + ], }, ], }), @@ -477,14 +481,14 @@ export class GitHub extends Source implements SourceControlSource { ? this.userToContact(pr.assignee) : null; - const thread: NewThreadWithNotes = { + const thread: NewLinkWithNotes = { source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ThreadType.Action, + type: "pull_request", title: pr.title, created: new Date(pr.created_at), author: authorContact, assignee: assigneeContact, - done: pr.merged_at ? new Date(pr.merged_at) : null, + status: pr.merged_at ? "done" : null, ...(pr.state === "closed" && !pr.merged_at ? { archived: true } : {}), meta: { provider: "github", @@ -499,7 +503,7 @@ export class GitHub extends Source implements SourceControlSource { notes: [], }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(thread); } /** @@ -524,9 +528,10 @@ export class GitHub extends Source implements SourceControlSource { ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` : review.body || null; - const thread: NewThreadWithNotes = { + const thread: NewLinkWithNotes = { source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ThreadType.Action, + type: "pull_request", + title: pr.title, notes: [ { key: `review-${review.id}`, @@ -546,7 +551,7 @@ export class GitHub extends Source implements SourceControlSource { }, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(thread); } /** @@ -564,9 +569,10 @@ export class GitHub extends Source implements SourceControlSource { const prNumber = issue.number; const commentAuthor = this.userToContact(comment.user); - const thread: NewThreadWithNotes = { + const thread: NewLinkWithNotes = { source: `github:pr:${owner}/${repo}/${prNumber}`, - type: ThreadType.Action, + type: "pull_request", + title: issue.title, notes: [ { key: `comment-${comment.id}`, @@ -585,7 +591,7 @@ export class GitHub extends Source implements SourceControlSource { }, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(thread); } // ---------- Batch sync ---------- @@ -669,7 +675,7 @@ export class GitHub extends Source implements SourceControlSource { syncProvider: "github", syncableId: repositoryId, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(thread); } } @@ -691,7 +697,7 @@ export class GitHub extends Source implements SourceControlSource { } /** - * Convert a GitHub PR to a NewThreadWithNotes + * Convert a GitHub PR to a NewLinkWithNotes */ private async convertPRToThread( token: string, @@ -700,7 +706,7 @@ export class GitHub extends Source implements SourceControlSource { pr: GitHubPullRequest, repositoryId: string, initialSync: boolean, - ): Promise { + ): Promise { const authorContact = this.userToContact(pr.user); const assigneeContact = pr.assignee ? this.userToContact(pr.assignee) @@ -779,14 +785,14 @@ export class GitHub extends Source implements SourceControlSource { console.error("Error fetching PR reviews:", error); } - const thread: NewThreadWithNotes = { + const thread: NewLinkWithNotes = { source: `github:pr:${owner}/${repo}/${pr.number}`, - type: ThreadType.Action, + type: "pull_request", title: pr.title, created: new Date(pr.created_at), author: authorContact, assignee: assigneeContact, - done: pr.merged_at ? new Date(pr.merged_at) : null, + status: pr.merged_at ? "done" : null, meta: { provider: "github", owner, diff --git a/sources/gmail/src/gmail-api.ts b/sources/gmail/src/gmail-api.ts index 7050923..cdf9e8a 100644 --- a/sources/gmail/src/gmail-api.ts +++ b/sources/gmail/src/gmail-api.ts @@ -1,6 +1,5 @@ -import { ThreadType } from "@plotday/twister"; import type { - NewThreadWithNotes, + NewLinkWithNotes, NewActor, } from "@plotday/twister/plot"; @@ -343,14 +342,14 @@ function extractAttachments( } /** - * Transforms a Gmail thread into a NewThreadWithNotes structure. - * The subject becomes the Thread title, and each email becomes a Note. + * Transforms a Gmail thread into a NewLinkWithNotes structure. + * The subject becomes the link title, and each email becomes a Note. */ -export function transformGmailThread(thread: GmailThread): NewThreadWithNotes { +export function transformGmailThread(thread: GmailThread): NewLinkWithNotes { if (!thread.messages || thread.messages.length === 0) { // Return empty structure for invalid threads return { - type: ThreadType.Note, + type: "email", title: "", notes: [], }; @@ -366,10 +365,10 @@ export function transformGmailThread(thread: GmailThread): NewThreadWithNotes { const firstMessageBody = extractBody(parentMessage.payload); const preview = firstMessageBody || parentMessage.snippet || null; - // Create Thread - const plotThread: NewThreadWithNotes = { + // Create link + const plotThread: NewLinkWithNotes = { source: canonicalUrl, - type: ThreadType.Note, + type: "email", title: subject || "Email", created: new Date(parseInt(parentMessage.internalDate)), meta: { @@ -399,7 +398,6 @@ export function transformGmailThread(thread: GmailThread): NewThreadWithNotes { // Create NewNote with idempotent key const note = { - thread: { source: canonicalUrl }, key: message.id, author: { email: sender.email, @@ -409,7 +407,7 @@ export function transformGmailThread(thread: GmailThread): NewThreadWithNotes { mentions: mentions.length > 0 ? mentions : undefined, }; - plotThread.notes.push(note); + plotThread.notes!.push(note); } return plotThread; diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index 6a40963..830086a 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -1,5 +1,4 @@ import { - type NewThreadWithNotes, Source, type ToolBuilder, } from "@plotday/twister"; @@ -53,6 +52,7 @@ export class Gmail extends Source implements MessagingSource { getChannels: this.listSyncChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, + linkTypes: [{ type: "email", label: "Email" }], }, ], }), @@ -281,10 +281,10 @@ export class Gmail extends Source implements MessagingSource { ): Promise { for (const thread of threads) { try { - // Transform Gmail thread to NewThreadWithNotes + // Transform Gmail thread to NewLinkWithNotes const activityThread = transformGmailThread(thread); - if (activityThread.notes.length === 0) continue; + if (!activityThread.notes || activityThread.notes.length === 0) continue; // Inject sync metadata for the parent to identify the source activityThread.meta = { @@ -293,8 +293,8 @@ export class Gmail extends Source implements MessagingSource { syncableId: channelId, }; - // Save thread directly via integrations - await this.tools.integrations.saveThread(activityThread); + // Save link directly via integrations + await this.tools.integrations.saveLink(activityThread); } catch (error) { console.error(`Failed to process Gmail thread ${thread.id}:`, error); // Continue processing other threads diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index ea38e66..909f659 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -1,20 +1,18 @@ import GoogleContacts from "@plotday/source-google-contacts"; import { - type Thread, ActionType, type Action, - ThreadType, - type ActorId, ConferencingProvider, - type NewThreadWithNotes, - type NewActor, + type NewLinkWithNotes, type NewContact, type NewNote, - Tag, Source, type ToolBuilder, } from "@plotday/twister"; -import type { NewScheduleOccurrence } from "@plotday/twister/schedule"; +import type { + NewScheduleContact, + NewScheduleOccurrence, +} from "@plotday/twister/schedule"; import { type Calendar, type CalendarSource, @@ -127,10 +125,10 @@ export class GoogleCalendar GoogleCalendar.SCOPES, GoogleContacts.SCOPES ), + linkTypes: [{ type: "event", label: "Event" }], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, - onThreadUpdated: this.onThreadUpdated, }, ], }), @@ -684,20 +682,19 @@ export class GoogleCalendar const canonicalUrl = `google-calendar:${event.id}`; // Create cancellation note - const cancelNote: NewNote = { - thread: { source: canonicalUrl }, - key: "cancellation", + const cancelNote = { + key: "cancellation" as const, content: "This event was cancelled.", - contentType: "text", + contentType: "text" as const, created: event.updated ? new Date(event.updated) : new Date(), }; - // Convert to Note type with blocked tag and cancellation note - const thread: NewThreadWithNotes = { + // Convert to link with cancellation note + const link: NewLinkWithNotes = { source: canonicalUrl, created: event.created ? new Date(event.created) : undefined, - type: ThreadType.Note, - title: activityData.title, + type: "event", + title: activityData.title || "Cancelled event", preview: "Cancelled", meta: activityData.meta ?? null, notes: [cancelNote], @@ -706,53 +703,31 @@ export class GoogleCalendar }; // Inject sync metadata for the parent to identify the source - thread.meta = { ...thread.meta, syncProvider: "google", syncableId: calendarId }; + link.meta = { ...link.meta, syncProvider: "google", syncableId: calendarId }; - // Send thread - database handles upsert automatically - await this.tools.integrations.saveThread(thread); + // Send link - database handles upsert automatically + await this.tools.integrations.saveLink(link); continue; } - // For recurring events, DON'T add tags at series level - // Tags (RSVPs) should be per-occurrence via the scheduleOccurrences array - // For non-recurring events, add tags normally + // For recurring events, DON'T add contacts at series level + // Contacts (RSVPs) should be per-occurrence via the scheduleOccurrences array + // For non-recurring events, add contacts to the schedule const isRecurring = !!activityData.schedules?.[0]?.recurrenceRule; - let tags: Partial> | null = null; - if (validAttendees.length > 0 && !isRecurring) { - const attendTags: NewActor[] = []; - const skipTags: NewActor[] = []; - const undecidedTags: NewActor[] = []; - - // Iterate through valid attendees and group by response status - validAttendees.forEach((attendee) => { - const newActor: NewActor = { + if (validAttendees.length > 0 && !isRecurring && activityData.schedules?.[0]) { + const contacts: NewScheduleContact[] = validAttendees.map((attendee) => ({ + contact: { email: attendee.email!, name: attendee.displayName, - }; - - if (attendee.responseStatus === "accepted") { - attendTags.push(newActor); - } else if (attendee.responseStatus === "declined") { - skipTags.push(newActor); - } else if ( - attendee.responseStatus === "tentative" || - attendee.responseStatus === "needsAction" - ) { - undecidedTags.push(newActor); - } - }); - - // Only set tags if we have at least one - if ( - attendTags.length > 0 || - skipTags.length > 0 || - undecidedTags.length > 0 - ) { - tags = {}; - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } + }, + status: attendee.responseStatus === "accepted" ? "attend" as const + : attendee.responseStatus === "declined" ? "skip" as const + : null, + role: attendee.organizer ? "organizer" as const + : attendee.optional ? "optional" as const + : "required" as const, + })); + activityData.schedules[0].contacts = contacts; } // Build actions array for videoconferencing and calendar links @@ -806,47 +781,36 @@ export class GoogleCalendar // Canonical source for this event (required for upsert) const canonicalUrl = `google-calendar:${event.id}`; - // Create note with description (actions moved to thread level) - const notes: NewNote[] = []; - if (hasDescription) { - notes.push({ - thread: { source: canonicalUrl }, - key: "description", - content: description, - contentType: - description && containsHtml(description) ? "html" : "text", - created: event.created ? new Date(event.created) : new Date(), - }); - } + // Build description note if available + const descriptionNote = hasDescription ? { + key: "description", + content: description, + contentType: + description && containsHtml(description) ? "html" as const : "text" as const, + created: event.created ? new Date(event.created) : new Date(), + } : null; - const shared = { + const link: NewLinkWithNotes = { source: canonicalUrl, created: event.created ? new Date(event.created) : undefined, + type: "event", title: activityData.title || "", author: authorContact, meta: activityData.meta ?? null, - tags: tags || undefined, actions: hasActions ? actions : undefined, - notes, + notes: descriptionNote ? [descriptionNote] : [], preview: hasDescription ? description : null, schedules: activityData.schedules, scheduleOccurrences: activityData.scheduleOccurrences, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only - } as const; - - const thread: NewThreadWithNotes = - activityData.type === ThreadType.Action - ? { type: ThreadType.Action, ...shared } - : activityData.type === ThreadType.Event - ? { type: ThreadType.Event, ...shared } - : { type: ThreadType.Note, ...shared }; + }; // Inject sync metadata for the parent to identify the source - thread.meta = { ...thread.meta, syncProvider: "google", syncableId: calendarId }; + link.meta = { ...link.meta, syncProvider: "google", syncableId: calendarId }; - // Send thread - database handles upsert automatically - await this.tools.integrations.saveThread(thread); + // Send link - database handles upsert automatically + await this.tools.integrations.saveLink(link); } } catch (error) { console.error(`Failed to process event ${event.id}:`, error); @@ -905,50 +869,38 @@ export class GoogleCalendar archived: true, }; - const occurrenceUpdate: NewThreadWithNotes = { - type: ThreadType.Event, + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", source: masterCanonicalUrl, meta: { syncProvider: "google", syncableId: calendarId }, scheduleOccurrences: [cancelledOccurrence], notes: [], }; - await this.tools.integrations.saveThread(occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); return; } - // Determine RSVP status for attendees + // Build contacts from attendees for this occurrence const validAttendees = event.attendees?.filter((att) => att.email && !att.resource) || []; - let tags: Partial> = {}; - if (validAttendees.length > 0) { - const attendTags: import("@plotday/twister").NewActor[] = []; - const skipTags: import("@plotday/twister").NewActor[] = []; - const undecidedTags: import("@plotday/twister").NewActor[] = []; - - validAttendees.forEach((attendee) => { - const newActor: import("@plotday/twister").NewActor = { - email: attendee.email!, - name: attendee.displayName, - }; - - if (attendee.responseStatus === "accepted") { - attendTags.push(newActor); - } else if (attendee.responseStatus === "declined") { - skipTags.push(newActor); - } else if ( - attendee.responseStatus === "tentative" || - attendee.responseStatus === "needsAction" - ) { - undecidedTags.push(newActor); - } - }); - - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } + const contacts: NewScheduleContact[] | undefined = + validAttendees.length > 0 + ? validAttendees.map((attendee) => ({ + contact: { + email: attendee.email!, + name: attendee.displayName, + }, + status: attendee.responseStatus === "accepted" ? "attend" as const + : attendee.responseStatus === "declined" ? "skip" as const + : null, + role: attendee.organizer ? "organizer" as const + : attendee.optional ? "optional" as const + : "required" as const, + })) + : undefined; // Build schedule occurrence object // Always include start to ensure upsert can infer scheduling when @@ -960,7 +912,7 @@ export class GoogleCalendar const occurrence: NewScheduleOccurrence = { occurrence: new Date(originalStartTime), start: occurrenceStart, - tags: Object.keys(tags).length > 0 ? tags : undefined, + contacts, ...(initialSync ? { unread: false } : {}), }; @@ -969,17 +921,18 @@ export class GoogleCalendar occurrence.end = instanceSchedule.end; } - // Build a minimal NewThread with source and scheduleOccurrences - // The source saves directly via integrations.saveThread - const occurrenceUpdate: NewThreadWithNotes = { - type: ThreadType.Event, + // Build a minimal link with source and scheduleOccurrences + // The source saves directly via integrations.saveLink + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", source: masterCanonicalUrl, meta: { syncProvider: "google", syncableId: calendarId }, scheduleOccurrences: [occurrence], notes: [], }; - await this.tools.integrations.saveThread(occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); } async onCalendarWebhook( @@ -1091,165 +1044,22 @@ export class GoogleCalendar return `${baseEventId}_${instanceDateStr}`; } - async onThreadUpdated( - thread: Thread, - changes: { - tagsAdded: Record; - tagsRemoved: Record; - } - ): Promise { - try { - // Only process calendar events - const source = thread.source; - if ( - !source || - typeof source !== "string" || - !source.startsWith("google-calendar:") - ) { - return; - } - - // Check if RSVP tags changed - const attendChanged = - Tag.Attend in changes.tagsAdded || Tag.Attend in changes.tagsRemoved; - const skipChanged = - Tag.Skip in changes.tagsAdded || Tag.Skip in changes.tagsRemoved; - const undecidedChanged = - Tag.Undecided in changes.tagsAdded || - Tag.Undecided in changes.tagsRemoved; - - if (!attendChanged && !skipChanged && !undecidedChanged) { - return; // No RSVP-related tag changes - } - - // Collect unique actor IDs from RSVP tag changes - const actorIds = new Set(); - for (const tag of [Tag.Attend, Tag.Skip, Tag.Undecided]) { - if (tag in changes.tagsAdded) { - for (const id of changes.tagsAdded[tag]) actorIds.add(id); - } - if (tag in changes.tagsRemoved) { - for (const id of changes.tagsRemoved[tag]) actorIds.add(id); - } - } - - // Determine new RSVP status based on most recent tag change - const hasAttend = - thread.tags?.[Tag.Attend] && thread.tags[Tag.Attend].length > 0; - const hasSkip = - thread.tags?.[Tag.Skip] && thread.tags[Tag.Skip].length > 0; - const hasUndecided = - thread.tags?.[Tag.Undecided] && - thread.tags[Tag.Undecided].length > 0; - - let newStatus: "accepted" | "declined" | "tentative" | "needsAction"; - - // Priority: Attend > Skip > Undecided, using most recent from tagsAdded - if (hasAttend && (hasSkip || hasUndecided)) { - if (Tag.Attend in changes.tagsAdded) { - newStatus = "accepted"; - } else if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentative"; - } else { - return; - } - } else if (hasSkip && hasUndecided) { - if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentative"; - } else { - return; - } - } else if (hasAttend) { - newStatus = "accepted"; - } else if (hasSkip) { - newStatus = "declined"; - } else if (hasUndecided) { - newStatus = "tentative"; - } else { - newStatus = "needsAction"; - } - - // Extract calendar info from metadata - if (!thread.meta) { - console.error("[RSVP Sync] Missing thread metadata", { - thread_id: thread.id, - }); - return; - } - - const baseEventId = thread.meta.id; - const calendarId = thread.meta.calendarId; - - if ( - !baseEventId || - !calendarId || - typeof baseEventId !== "string" || - typeof calendarId !== "string" - ) { - console.error("[RSVP Sync] Missing or invalid event/calendar ID", { - has_event_id: !!baseEventId, - has_calendar_id: !!calendarId, - event_id_type: typeof baseEventId, - calendar_id_type: typeof calendarId, - }); - return; - } - - // Determine the event ID to update - // Note: occurrence-level RSVP changes are handled at the master event level - const eventId = baseEventId; - - // For each actor who changed RSVP, use actAs() to sync with their credentials. - // If the actor has auth, the callback fires immediately. - // If not, actAs() creates a private auth note automatically. - for (const actorId of actorIds) { - await this.tools.integrations.actAs( - GoogleCalendar.PROVIDER, - actorId, - thread.id, - this.syncActorRSVP, - calendarId as string, - eventId, - newStatus, - actorId as string - ); - } - } catch (error) { - console.error("[RSVP Sync] Error in callback", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - thread_id: thread.id, - }); - } - } - /** - * Sync RSVP for an actor. If the actor has auth, this is called immediately. - * If not, actAs() creates a private auth note and calls this when they authorize. + * Sync a schedule contact RSVP change back to Google Calendar. + * Called via actAs() which provides the actor's auth token. */ async syncActorRSVP( token: AuthToken, calendarId: string, eventId: string, status: "accepted" | "declined" | "tentative" | "needsAction", - actorId: string + _actorId: string ): Promise { try { const api = new GoogleApi(token.token); - await this.updateEventRSVPWithApi( - api, - calendarId, - eventId, - status, - actorId as ActorId - ); + await this.updateEventRSVPWithApi(api, calendarId, eventId, status); } catch (error) { console.error("[RSVP Sync] Failed to sync RSVP", { - actor_id: actorId, event_id: eventId, error: error instanceof Error ? error.message : String(error), }); @@ -1257,15 +1067,14 @@ export class GoogleCalendar } /** - * Update RSVP status for a specific actor using a pre-authenticated GoogleApi instance. - * Looks up the actor's email from the calendar API to find the correct attendee. + * Update RSVP status for the authenticated user on a Google Calendar event. + * Looks up the user's email from the calendar API to find the correct attendee. */ private async updateEventRSVPWithApi( api: GoogleApi, calendarId: string, eventId: string, - status: "accepted" | "declined" | "needsAction" | "tentative", - actorId: ActorId + status: "accepted" | "declined" | "needsAction" | "tentative" ): Promise { // Fetch the current event to get attendees list const event = (await api.call( @@ -1294,7 +1103,6 @@ export class GoogleCalendar if (actorAttendeeIndex === -1) { console.warn("[RSVP Sync] Actor is not an attendee of this event", { - actor_id: actorId, event_id: eventId, }); return; diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index c5f6473..00e7f03 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -1,11 +1,8 @@ import GoogleContacts from "@plotday/source-google-contacts"; import { - type ThreadFilter, - ThreadKind, type Action, ActionType, - ThreadType, - type NewThreadWithNotes, + type NewLinkWithNotes, type NewContact, type NewNote, Source, @@ -75,6 +72,7 @@ export class GoogleDrive extends Source implements DocumentSource { getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, + linkTypes: [{ type: "document", label: "Document" }], }, ], }), @@ -525,7 +523,7 @@ export class GoogleDrive extends Source implements DocumentSource { folderId, initialSync ); - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(thread); } catch (error) { console.error(`Failed to process file ${file.id}:`, error); } @@ -588,7 +586,7 @@ export class GoogleDrive extends Source implements DocumentSource { folderId, false // incremental sync ); - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(thread); } catch (error) { console.error( `Failed to process changed file ${change.fileId}:`, @@ -631,7 +629,7 @@ export class GoogleDrive extends Source implements DocumentSource { file: GoogleDriveFile, folderId: string, initialSync: boolean - ): Promise { + ): Promise { const canonicalSource = `google-drive:file:${file.id}`; // Build author contact from file owner @@ -706,10 +704,9 @@ export class GoogleDrive extends Source implements DocumentSource { }); } - const thread: NewThreadWithNotes = { + const thread: NewLinkWithNotes = { source: canonicalSource, - type: ThreadType.Note, - kind: ThreadKind.document, + type: "document", title: file.name, author, actions: actions.length > 0 ? actions : null, diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index afc6966..0ce3096 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -5,7 +5,7 @@ import { type Action, ActionType, ThreadType, - type NewThreadWithNotes, + type NewLinkWithNotes, NewContact, } from "@plotday/twister"; import type { @@ -48,6 +48,16 @@ export class Jira extends Source implements ProjectSource { providers: [{ provider: Jira.PROVIDER, scopes: Jira.SCOPES, + linkTypes: [ + { + type: "issue", + label: "Issue", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, @@ -262,15 +272,15 @@ export class Jira extends Source implements ProjectSource { // Process each issue for (const issue of searchResult.issues || []) { - const threadWithNotes = await this.convertIssueToThread( + const linkWithNotes = await this.convertIssueToLink( issue, projectId ); // Set unread based on sync type (false for initial sync to avoid notification overload) - threadWithNotes.unread = !state.initialSync; + linkWithNotes.unread = !state.initialSync; // Inject sync metadata for filtering on disable - threadWithNotes.meta = { ...threadWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; - await this.tools.integrations.saveThread(threadWithNotes); + linkWithNotes.meta = { ...linkWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; + await this.tools.integrations.saveLink(linkWithNotes); } // Check if more pages @@ -311,12 +321,12 @@ export class Jira extends Source implements ProjectSource { } /** - * Convert a Jira issue to a Plot Thread + * Convert a Jira issue to a Plot Link */ - private async convertIssueToThread( + private async convertIssueToLink( issue: any, projectId: string - ): Promise { + ): Promise { const fields = issue.fields || {}; const comments = fields.comment?.comments || []; const reporter = fields.reporter || fields.creator; @@ -420,7 +430,7 @@ export class Jira extends Source implements ProjectSource { return { ...(source ? { source } : {}), - type: ThreadType.Action, + type: "issue", title: fields.summary || issue.key, created: fields.created ? new Date(fields.created) : undefined, meta: { @@ -429,7 +439,7 @@ export class Jira extends Source implements ProjectSource { }, author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned issues - done: fields.resolutiondate ? new Date(fields.resolutiondate) : null, + status: fields.resolutiondate ? "done" : "open", actions: threadActions.length > 0 ? threadActions : undefined, notes, preview: description || null, @@ -688,10 +698,10 @@ export class Jira extends Source implements ProjectSource { } } - // Create partial thread update (empty notes = doesn't touch existing notes) - const thread: NewThreadWithNotes = { + // Create partial link update (empty notes = doesn't touch existing notes) + const link: NewLinkWithNotes = { ...(source ? { source } : {}), - type: ThreadType.Action, + type: "issue", title: fields.summary || issue.key, created: fields.created ? new Date(fields.created) : undefined, meta: { @@ -700,12 +710,12 @@ export class Jira extends Source implements ProjectSource { }, author: authorContact, assignee: assigneeContact ?? null, - done: fields.resolutiondate ? new Date(fields.resolutiondate) : null, + status: fields.resolutiondate ? "done" : "open", preview: description || null, notes: [], }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); } /** @@ -759,17 +769,17 @@ export class Jira extends Source implements ProjectSource { (p: any) => p.key === "plotNoteId" )?.value; - // Create thread update with single comment note - const thread: NewThreadWithNotes = { + // Create link update with single comment note + const link: NewLinkWithNotes = { ...(source ? { source } : {}), - type: ThreadType.Action, // Required field (will match existing thread) + type: "issue", + title: issue.key, // Placeholder; upsert by source will preserve existing title notes: [ { key: `comment-${comment.id}`, // If this comment originated from Plot, identify by note ID so we update the existing note // rather than creating a duplicate ...(plotNoteId ? { id: plotNoteId } : {}), - thread: source ? { source } : undefined, content: commentText, created: comment.created ? new Date(comment.created) : undefined, author: commentAuthor, @@ -781,7 +791,7 @@ export class Jira extends Source implements ProjectSource { }, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); } /** diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index f702e91..e0f23c9 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -11,8 +11,7 @@ import { ActionType, ThreadMeta, ThreadType, - type NewThreadWithNotes, - type NewNote, + type NewLinkWithNotes, } from "@plotday/twister"; import type { Project, @@ -64,6 +63,16 @@ export class Linear extends Source implements ProjectSource { { provider: Linear.PROVIDER, scopes: Linear.SCOPES, + linkTypes: [ + { + type: "issue", + label: "Issue", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, @@ -260,20 +269,20 @@ export class Linear extends Source implements ProjectSource { // Process each issue for (const issue of issuesConnection.nodes) { - const thread = await this.convertIssueToThread( + const link = await this.convertIssueToLink( issue, projectId, state.initialSync ); - if (thread) { + if (link) { // Inject sync metadata for bulk operations (e.g. disable filtering) - thread.meta = { - ...thread.meta, + link.meta = { + ...link.meta, syncProvider: "linear", syncableId: projectId, }; - await this.tools.integrations.saveThread(thread); + await this.tools.integrations.saveLink(link); } } @@ -300,13 +309,13 @@ export class Linear extends Source implements ProjectSource { } /** - * Convert a Linear issue to a NewThreadWithNotes + * Convert a Linear issue to a NewLinkWithNotes */ - private async convertIssueToThread( + private async convertIssueToLink( issue: Issue, projectId: string, initialSync: boolean - ): Promise { + ): Promise { let creator, assignee, comments; try { @@ -410,15 +419,14 @@ export class Linear extends Source implements ProjectSource { }); } - const newThread: NewThreadWithNotes = { + const newLink: NewLinkWithNotes = { source: `linear:issue:${issue.id}`, - type: ThreadType.Action, + type: "issue", title: issue.title, created: issue.createdAt, author: authorContact, assignee: assigneeContact ?? null, - done: issue.completedAt ?? issue.canceledAt ?? null, - order: issue.sortOrder, + status: issue.completedAt || issue.canceledAt ? "done" : "open", meta: { linearId: issue.id, projectId, @@ -430,7 +438,7 @@ export class Linear extends Source implements ProjectSource { ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; - return newThread; + return newLink; } /** @@ -671,21 +679,16 @@ export class Linear extends Source implements ProjectSource { }; } - // Create partial thread update (empty notes = doesn't touch existing notes) + // Create partial link update (empty notes = doesn't touch existing notes) // Note: webhook payload dates are JSON strings, must convert to Date - const newThread: NewThreadWithNotes = { + const newLink: NewLinkWithNotes = { source: `linear:issue:${issue.id}`, - type: ThreadType.Action, + type: "issue", title: issue.title, created: new Date(issue.createdAt), author: authorContact, assignee: assigneeContact ?? null, - done: issue.completedAt - ? new Date(issue.completedAt) - : issue.canceledAt - ? new Date(issue.canceledAt) - : null, - order: issue.sortOrder, + status: issue.completedAt || issue.canceledAt ? "done" : "open", meta: { linearId: issue.id, projectId, @@ -696,7 +699,7 @@ export class Linear extends Source implements ProjectSource { notes: [], }; - await this.tools.integrations.saveThread(newThread); + await this.tools.integrations.saveLink(newLink); } /** @@ -733,18 +736,18 @@ export class Linear extends Source implements ProjectSource { // Create thread update with single comment note // Type is required by NewThread, but upsert will use existing thread's type const threadSource = `linear:issue:${issueId}`; - const note: NewNote = { - key: `comment-${comment.id}`, - thread: { source: threadSource }, - content: comment.body, - created: new Date(comment.createdAt), - author: commentAuthor, - }; - - const newThread: NewThreadWithNotes = { + const newLink: NewLinkWithNotes = { source: threadSource, - type: ThreadType.Action, // Required field (will match existing thread) - notes: [note], + type: "issue", + title: issueId, // Placeholder; upsert by source will preserve existing title + notes: [ + { + key: `comment-${comment.id}`, + content: comment.body, + created: new Date(comment.createdAt), + author: commentAuthor, + } as any, + ], meta: { linearId: issueId, projectId, @@ -753,7 +756,7 @@ export class Linear extends Source implements ProjectSource { }, }; - await this.tools.integrations.saveThread(newThread); + await this.tools.integrations.saveLink(newLink); } /** diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index 5dc7ceb..9037de9 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -2,11 +2,10 @@ import { type Thread, type Action, ActionType, - ThreadType, type ActorId, ConferencingProvider, type ContentType, - type NewThreadWithNotes, + type NewLinkWithNotes, type NewActor, type NewContact, type NewNote, @@ -14,7 +13,10 @@ import { Source, type ToolBuilder, } from "@plotday/twister"; -import type { NewScheduleOccurrence } from "@plotday/twister/schedule"; +import type { + NewScheduleContact, + NewScheduleOccurrence, +} from "@plotday/twister/schedule"; import type { Calendar, CalendarSource, @@ -115,10 +117,10 @@ export class OutlookCalendar { provider: OutlookCalendar.PROVIDER, scopes: OutlookCalendar.SCOPES, + linkTypes: [{ type: "event", label: "Event" }], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, - onThreadUpdated: this.onThreadUpdated, }, ], }), @@ -417,19 +419,19 @@ export class OutlookCalendar const source = `outlook-calendar:${outlookEvent.id}`; // Create cancellation note - const cancelNote: NewNote = { - thread: { source }, - key: "cancellation", + const cancelNote = { + key: "cancellation" as const, content: "This event was cancelled.", - contentType: "text", + contentType: "text" as const, created: outlookEvent.lastModifiedDateTime ? new Date(outlookEvent.lastModifiedDateTime) : new Date(), }; - // Convert to Note type with blocked tag and cancellation note - const thread: NewThreadWithNotes = { - type: ThreadType.Note, + // Convert to link with cancellation note + const link: NewLinkWithNotes = { + type: "event", + title: "Cancelled Event", created: outlookEvent.createdDateTime ? new Date(outlookEvent.createdDateTime) : new Date(), @@ -441,8 +443,8 @@ export class OutlookCalendar ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; - // Send thread update - await this.tools.integrations.saveThread(thread); + // Send link update + await this.tools.integrations.saveLink(link); continue; } @@ -494,49 +496,24 @@ export class OutlookCalendar continue; } - // For recurring events, DON'T add tags at series level - // Tags (RSVPs) should be per-occurrence via the scheduleOccurrences array - // For non-recurring events, add tags normally - let tags: Partial> | null = null; + // For recurring events, DON'T add contacts at series level + // Contacts (RSVPs) should be per-occurrence via the scheduleOccurrences array + // For non-recurring events, add contacts to the schedule const hasRecurrence = !!threadData.schedules?.[0]?.recurrenceRule; - if (validAttendees.length > 0 && !hasRecurrence) { - const attendTags: NewActor[] = []; - const skipTags: NewActor[] = []; - const undecidedTags: NewActor[] = []; - - // Iterate through valid attendees and group by response status - validAttendees.forEach((attendee) => { - const newActor: NewActor = { + if (validAttendees.length > 0 && !hasRecurrence && threadData.schedules?.[0]) { + const contacts: NewScheduleContact[] = validAttendees.map((attendee) => ({ + contact: { email: attendee.emailAddress!.address!, name: attendee.emailAddress!.name, - }; - - const response = attendee.status?.response; - if (response === "accepted") { - attendTags.push(newActor); - } else if (response === "declined") { - skipTags.push(newActor); - } else if ( - response === "tentativelyAccepted" || - response === "none" || - response === "notResponded" - ) { - undecidedTags.push(newActor); - } - // organizer has no response status, so they won't get a tag - }); - - // Only set tags if we have at least one - if ( - attendTags.length > 0 || - skipTags.length > 0 || - undecidedTags.length > 0 - ) { - tags = {}; - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } + }, + status: attendee.status?.response === "accepted" ? "attend" as const + : attendee.status?.response === "declined" ? "skip" as const + : null, + role: attendee.type === "required" ? "required" as const + : attendee.type === "optional" ? "optional" as const + : "required" as const, + })); + threadData.schedules[0].contacts = contacts; } // Build actions array for videoconferencing and calendar links @@ -562,45 +539,43 @@ export class OutlookCalendar }); } - // Create note with description (actions moved to thread level) - const notes: NewNote[] = []; + // Build description note if available const hasDescription = outlookEvent.body?.content && outlookEvent.body.content.trim().length > 0; const hasActions = actions.length > 0; - if (hasDescription) { - notes.push({ - thread: { - source: `outlook-calendar:${outlookEvent.id}`, - }, - key: "description", - content: outlookEvent.body!.content!, - contentType: (outlookEvent.body?.contentType === "html" - ? "html" - : "text") as ContentType, - }); - } - - // Build NewThreadWithNotes from the transformed thread - const threadWithNotes: NewThreadWithNotes = { - ...threadData, + const descriptionNote = hasDescription ? { + key: "description", + content: outlookEvent.body!.content!, + contentType: (outlookEvent.body?.contentType === "html" + ? "html" + : "text") as ContentType, + } : null; + + // Build NewLinkWithNotes from the transformed thread data + const linkWithNotes: NewLinkWithNotes = { + source: `outlook-calendar:${outlookEvent.id}`, + type: "event", + title: threadData.title || "", + created: threadData.created, author: authorContact, meta: { ...threadData.meta, syncProvider: "microsoft", syncableId: calendarId, }, - tags: tags && Object.keys(tags).length > 0 ? tags : threadData.tags, actions: hasActions ? actions : undefined, - notes, + notes: descriptionNote ? [descriptionNote] : [], preview: hasDescription ? outlookEvent.body!.content! : null, + schedules: threadData.schedules, + scheduleOccurrences: threadData.scheduleOccurrences, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates ...(initialSync ? { archived: false } : {}), // unarchive on initial sync only }; - // Call the event callback using hoisted token - await this.tools.integrations.saveThread(threadWithNotes); + // Save link - database handles upsert automatically + await this.tools.integrations.saveLink(linkWithNotes); } catch (error) { console.error(`Error processing event ${outlookEvent.id}:`, error); // Continue processing other events @@ -647,54 +622,40 @@ export class OutlookCalendar archived: true, }; - const occurrenceUpdate: NewThreadWithNotes = { - type: ThreadType.Event, + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, scheduleOccurrences: [cancelledOccurrence], notes: [], }; - await this.tools.integrations.saveThread(occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); return; } - // Determine RSVP status for attendees + // Build contacts from attendees for this occurrence const validAttendees = event.attendees?.filter( (att) => att.emailAddress?.address && att.type !== "resource" ) || []; - let tags: Partial> = {}; - if (validAttendees.length > 0) { - const attendTags: import("@plotday/twister").NewActor[] = []; - const skipTags: import("@plotday/twister").NewActor[] = []; - const undecidedTags: import("@plotday/twister").NewActor[] = []; - - validAttendees.forEach((attendee) => { - const newActor: import("@plotday/twister").NewActor = { - email: attendee.emailAddress!.address!, - name: attendee.emailAddress!.name, - }; - - const response = attendee.status?.response; - if (response === "accepted") { - attendTags.push(newActor); - } else if (response === "declined") { - skipTags.push(newActor); - } else if ( - response === "tentativelyAccepted" || - response === "none" || - response === "notResponded" - ) { - undecidedTags.push(newActor); - } - }); - - if (attendTags.length > 0) tags[Tag.Attend] = attendTags; - if (skipTags.length > 0) tags[Tag.Skip] = skipTags; - if (undecidedTags.length > 0) tags[Tag.Undecided] = undecidedTags; - } + const contacts: NewScheduleContact[] | undefined = + validAttendees.length > 0 + ? validAttendees.map((attendee) => ({ + contact: { + email: attendee.emailAddress!.address!, + name: attendee.emailAddress!.name, + }, + status: attendee.status?.response === "accepted" ? "attend" as const + : attendee.status?.response === "declined" ? "skip" as const + : null, + role: attendee.type === "required" ? "required" as const + : attendee.type === "optional" ? "optional" as const + : "required" as const, + })) + : undefined; // Build schedule occurrence object // Always include start to ensure upsert can infer scheduling when @@ -706,7 +667,7 @@ export class OutlookCalendar const occurrence: NewScheduleOccurrence = { occurrence: new Date(originalStart), start: occurrenceStart, - tags: Object.keys(tags).length > 0 ? tags : undefined, + contacts, ...(initialSync ? { unread: false } : {}), }; @@ -715,18 +676,18 @@ export class OutlookCalendar occurrence.end = instanceSchedule.end; } - // Send occurrence data to the twist via callback - // Build a minimal NewThread with source and scheduleOccurrences - // The source saves directly via integrations.saveThread - const occurrenceUpdate: NewThreadWithNotes = { - type: ThreadType.Event, + // Send occurrence data via saveLink + // Build a minimal link with source and scheduleOccurrences + const occurrenceUpdate: NewLinkWithNotes = { + type: "event", + title: "", source: masterCanonicalUrl, meta: { syncProvider: "microsoft", syncableId: calendarId }, scheduleOccurrences: [occurrence], notes: [], }; - await this.tools.integrations.saveThread(occurrenceUpdate); + await this.tools.integrations.saveLink(occurrenceUpdate); } async onOutlookWebhook( @@ -775,145 +736,9 @@ export class OutlookCalendar await this.runTask(callback); } - async onThreadUpdated( - thread: Thread, - changes: { - tagsAdded: Record; - tagsRemoved: Record; - } - ): Promise { - try { - // Only process calendar events - const source = thread.source; - if ( - !source || - typeof source !== "string" || - !source.startsWith("outlook-calendar:") - ) { - return; - } - - // Check if RSVP tags changed - const attendChanged = - Tag.Attend in changes.tagsAdded || Tag.Attend in changes.tagsRemoved; - const skipChanged = - Tag.Skip in changes.tagsAdded || Tag.Skip in changes.tagsRemoved; - const undecidedChanged = - Tag.Undecided in changes.tagsAdded || - Tag.Undecided in changes.tagsRemoved; - - if (!attendChanged && !skipChanged && !undecidedChanged) { - return; // No RSVP-related tag changes - } - - // Collect unique actor IDs from RSVP tag changes - const actorIds = new Set(); - for (const tag of [Tag.Attend, Tag.Skip, Tag.Undecided]) { - if (tag in changes.tagsAdded) { - for (const id of changes.tagsAdded[tag]) actorIds.add(id); - } - if (tag in changes.tagsRemoved) { - for (const id of changes.tagsRemoved[tag]) actorIds.add(id); - } - } - - // Determine new RSVP status based on most recent tag change - const hasAttend = - thread.tags?.[Tag.Attend] && thread.tags[Tag.Attend].length > 0; - const hasSkip = - thread.tags?.[Tag.Skip] && thread.tags[Tag.Skip].length > 0; - const hasUndecided = - thread.tags?.[Tag.Undecided] && - thread.tags[Tag.Undecided].length > 0; - - let newStatus: "accepted" | "declined" | "tentativelyAccepted"; - - // Priority: Attend > Skip > Undecided, using most recent from tagsAdded - if (hasAttend && (hasSkip || hasUndecided)) { - if (Tag.Attend in changes.tagsAdded) { - newStatus = "accepted"; - } else if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentativelyAccepted"; - } else { - return; - } - } else if (hasSkip && hasUndecided) { - if (Tag.Skip in changes.tagsAdded) { - newStatus = "declined"; - } else if (Tag.Undecided in changes.tagsAdded) { - newStatus = "tentativelyAccepted"; - } else { - return; - } - } else if (hasAttend) { - newStatus = "accepted"; - } else if (hasSkip) { - newStatus = "declined"; - } else if (hasUndecided) { - newStatus = "tentativelyAccepted"; - } else { - // No RSVP tags present - reset to tentativelyAccepted (acts as "needsAction") - newStatus = "tentativelyAccepted"; - } - - // Extract calendar info from metadata - if (!thread.meta) { - console.error("[RSVP Sync] Missing thread metadata", { - thread_id: thread.id, - }); - return; - } - - const baseEventId = thread.meta.id; - const calendarId = thread.meta.calendarId; - - if ( - !baseEventId || - !calendarId || - typeof baseEventId !== "string" || - typeof calendarId !== "string" - ) { - console.error("[RSVP Sync] Missing or invalid event/calendar ID", { - has_event_id: !!baseEventId, - has_calendar_id: !!calendarId, - event_id_type: typeof baseEventId, - calendar_id_type: typeof calendarId, - }); - return; - } - - // Determine the event ID to update - const eventId = baseEventId; - - // For each actor who changed RSVP, use actAs() to sync with their credentials. - // If the actor has auth, the callback fires immediately. - // If not, actAs() creates a private auth note automatically. - for (const actorId of actorIds) { - await this.tools.integrations.actAs( - OutlookCalendar.PROVIDER, - actorId, - thread.id, - this.syncActorRSVP, - calendarId as string, - eventId, - newStatus, - actorId as string - ); - } - } catch (error) { - console.error("[RSVP Sync] Error in callback", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - thread_id: thread.id, - }); - } - } - /** - * Sync RSVP for an actor. If the actor has auth, this is called immediately. - * If not, actAs() creates a private auth note and calls this when they authorize. + * Sync a schedule contact RSVP change back to Outlook Calendar. + * Called via actAs() which provides the actor's auth token. */ async syncActorRSVP( token: AuthToken, @@ -933,7 +758,6 @@ export class OutlookCalendar ); } catch (error) { console.error("[RSVP Sync] Failed to sync RSVP", { - actor_id: actorId, event_id: eventId, error: error instanceof Error ? error.message : String(error), }); @@ -1001,7 +825,7 @@ export class OutlookCalendar } /** - * Update RSVP status for a specific actor using a pre-authenticated GraphApi instance. + * Update RSVP status for the authenticated user on an Outlook Calendar event. * Looks up the actor's email from the Graph API to find the correct attendee. */ private async updateEventRSVPWithApi( @@ -1009,7 +833,7 @@ export class OutlookCalendar calendarId: string, eventId: string, status: "accepted" | "declined" | "tentativelyAccepted", - actorId: ActorId + _actorId: ActorId ): Promise { // First, fetch the current event to check if status already matches const resource = @@ -1034,9 +858,7 @@ export class OutlookCalendar const actorEmail = meData?.mail || meData?.userPrincipalName; if (!actorEmail) { - console.warn("[RSVP Sync] Could not determine actor email", { - actor_id: actorId, - }); + console.warn("[RSVP Sync] Could not determine actor email"); return; } @@ -1049,7 +871,6 @@ export class OutlookCalendar if (!actorAttendee) { console.warn("[RSVP Sync] Actor is not an attendee of this event", { - actor_id: actorId, event_id: eventId, }); return; diff --git a/sources/slack/src/slack-api.ts b/sources/slack/src/slack-api.ts index c6d0c74..6bb1443 100644 --- a/sources/slack/src/slack-api.ts +++ b/sources/slack/src/slack-api.ts @@ -1,6 +1,5 @@ -import { ThreadType } from "@plotday/twister"; import type { - NewThreadWithNotes, + NewLinkWithNotes, NewActor, } from "@plotday/twister/plot"; @@ -233,19 +232,19 @@ function formatSlackText(text: string): string { } /** - * Transforms a Slack message thread into a NewThreadWithNotes structure. - * The first message snippet becomes the Thread title, and each message becomes a Note. + * Transforms a Slack message thread into a NewLinkWithNotes structure. + * The first message snippet becomes the link title, and each message becomes a Note. */ export function transformSlackThread( messages: SlackMessage[], channelId: string -): NewThreadWithNotes { +): NewLinkWithNotes { const parentMessage = messages[0]; if (!parentMessage) { // Return empty structure for invalid threads return { - type: ThreadType.Note, + type: "message", title: "Empty thread", notes: [], }; @@ -258,10 +257,10 @@ export function transformSlackThread( // Canonical URL using Slack's app_redirect (works across all workspaces) const canonicalUrl = `https://slack.com/app_redirect?channel=${channelId}&message_ts=${threadTs}`; - // Create Thread - const thread: NewThreadWithNotes = { + // Create link + const thread: NewLinkWithNotes = { source: canonicalUrl, - type: ThreadType.Note, + type: "message", title, created: new Date(parseFloat(parentMessage.ts) * 1000), meta: { @@ -282,14 +281,13 @@ export function transformSlackThread( // Create NewNote with idempotent key const note = { - thread: { source: canonicalUrl }, key: message.ts, author: slackUserToNewActor(userId), content: text, mentions: mentions.length > 0 ? mentions : undefined, }; - thread.notes.push(note); + thread.notes!.push(note); } return thread; diff --git a/sources/slack/src/slack.ts b/sources/slack/src/slack.ts index 7512425..66c2965 100644 --- a/sources/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -1,5 +1,4 @@ import { - type NewThreadWithNotes, Source, type ToolBuilder, } from "@plotday/twister"; @@ -77,6 +76,7 @@ export class Slack extends Source implements MessagingSource { getChannels: this.listSyncChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, + linkTypes: [{ type: "message", label: "Message" }], }, ], }), @@ -272,10 +272,10 @@ export class Slack extends Source implements MessagingSource { ): Promise { for (const thread of threads) { try { - // Transform Slack thread to NewThreadWithNotes + // Transform Slack thread to NewLinkWithNotes const activityThread = transformSlackThread(thread, channelId); - if (activityThread.notes.length === 0) continue; + if (!activityThread.notes || activityThread.notes.length === 0) continue; // Inject sync metadata for the parent to identify the source activityThread.meta = { @@ -284,8 +284,8 @@ export class Slack extends Source implements MessagingSource { syncableId: channelId, }; - // Save thread directly via integrations - await this.tools.integrations.saveThread(activityThread); + // Save link directly via integrations + await this.tools.integrations.saveLink(activityThread); } catch (error) { console.error(`Failed to process thread:`, error); // Continue processing other threads diff --git a/twister/src/plot.ts b/twister/src/plot.ts index a8a39f4..1deb870 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -227,7 +227,7 @@ export enum ActionType { /** @deprecated Use ActionType instead */ export const LinkType = ActionType; -/** @deprecated Use ActionType instead */ +/** @deprecated Use ActionType instead. Note: LinkType previously aliased ActionType; the new Link type is a different concept (external entity). */ export type LinkType = ActionType; /** @@ -1068,8 +1068,126 @@ export type NewContact = { export type ContentType = "text" | "markdown" | "html"; -/** @deprecated Use Action instead */ -export type Link = Action; +/** + * Represents an external entity linked to a thread. + * + * Links are created by sources to represent external entities (issues, emails, calendar events) + * attached to a thread container. A thread can have multiple links (1:many). + * Links store source-specific data like type, status, metadata, and embeddings. + * + * @example + * ```typescript + * // A link representing a Linear issue + * const link: Link = { + * id: "..." as Uuid, + * threadId: "..." as Uuid, + * source: "linear:issue:549dd8bd-2bc9-43d1-95d5-4b4af0c5af1b", + * created: new Date(), + * author: { id: "..." as ActorId, type: ActorType.Contact, name: "Alice" }, + * title: "Fix login bug", + * type: "issue", + * status: "open", + * meta: { projectId: "TEAM", url: "https://linear.app/team/TEAM-123" }, + * assignee: null, + * actions: null, + * }; + * ``` + */ +export type Link = { + /** Unique identifier for the link */ + id: Uuid; + /** The thread this link belongs to */ + threadId: Uuid; + /** External source identifier for dedup/upsert */ + source: string | null; + /** When this link was originally created in its source system */ + created: Date; + /** The actor credited with creating this link */ + author: Actor | null; + /** Display title */ + title: string; + /** Truncated preview */ + preview: string | null; + /** The actor assigned to this link */ + assignee: Actor | null; + /** Source-defined type string (e.g., issue, pull_request, email, event) */ + type: string | null; + /** Source-defined status string (e.g., open, done, closed) */ + status: string | null; + /** Interactive action buttons */ + actions: Array | null; + /** Source metadata */ + meta: ThreadMeta | null; +}; + +/** + * Type for creating new links. + * + * Links are created by sources to represent external entities. + * Requires a source identifier for dedup/upsert. + */ +export type NewLink = ( + | { + /** Unique identifier for the link, generated by Uuid.Generate() */ + id: Uuid; + } + | { + /** + * Canonical ID for the item in an external system. + * When set, uniquely identifies the link within a priority tree. This performs + * an upsert. + */ + source: string; + } + | {} +) & + Partial< + Omit + > & { + /** The person that created the item. By default, it will be the twist itself. */ + author?: NewActor; + /** The person assigned to the item. */ + assignee?: NewActor | null; + /** + * Whether the thread should be marked as unread for users. + * - undefined/omitted (default): Thread is unread for users, except auto-marked + * as read for the author if they are the twist owner (user) + * - false: Thread is marked as read for all users in the priority at creation time + */ + unread?: boolean; + /** + * Whether the thread is archived. + * - true: Archive the thread + * - false: Unarchive the thread + * - undefined (default): Preserve current archive state + */ + archived?: boolean; + /** + * Configuration for automatic priority selection based on similarity. + * Only used when the link creates a new thread. + */ + pickPriority?: PickPriorityConfig; + /** + * Explicit priority (disables automatic priority matching). + * Only used when the link creates a new thread. + */ + priority?: Pick; + }; + +/** + * A new link with notes to save via integrations.saveLink(). + * Creates a thread+link pair, with notes attached to the thread. + */ +export type NewLinkWithNotes = NewLink & { + /** Title for the link and its thread container */ + title: string; + /** Notes to attach to the thread */ + notes?: Omit[]; + /** Schedules to create for the link */ + schedules?: Array>; + /** Schedule occurrence overrides */ + scheduleOccurrences?: NewScheduleOccurrence[]; +}; /** @deprecated Use ActionType instead */ export const ActivityLinkType = ActionType; diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index bdafde6..0bd64c9 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -2,6 +2,7 @@ import { type Actor, type ActorId, type NewContact, + type NewLinkWithNotes, type NewThreadWithNotes, type Note, type Thread, @@ -32,11 +33,31 @@ export type Syncable = Channel; * Configuration for an OAuth provider in a source's build options. * Declares the provider, scopes, and lifecycle callbacks. */ +/** + * Describes a link type that a source creates. + * Used for display in the UI (icons, labels). + */ +export type LinkTypeConfig = { + /** Machine-readable type identifier (e.g., "issue", "pull_request") */ + type: string; + /** Human-readable label (e.g., "Issue", "Pull Request") */ + label: string; + /** Possible status values for this type */ + statuses?: Array<{ + /** Machine-readable status (e.g., "open", "done") */ + status: string; + /** Human-readable label (e.g., "Open", "Done") */ + label: string; + }>; +}; + export type IntegrationProviderConfig = { /** The OAuth provider */ provider: AuthProvider; /** OAuth scopes to request */ scopes: string[]; + /** Registry of link types this source creates */ + linkTypes?: LinkTypeConfig[]; /** Returns available channels for the authorized actor. Must not use Plot tool. */ getChannels: (auth: Authorization, token: AuthToken) => Promise; /** Called when a channel resource is enabled for syncing */ @@ -161,11 +182,23 @@ export abstract class Integrations extends ITool { ): Promise; /** - * Saves a thread with notes to the source's priority. + * Saves a link with notes to the source's priority. + * + * Creates a thread+link pair. The thread is a lightweight container; + * the link holds the external entity data (source, meta, type, status, etc.). * * This method is available only to Sources (not regular Twists). - * It replaces the old pattern of passing threads via callbacks to a Twist - * which then called plot.createThread(). + * + * @param link - The link with notes to save + * @returns Promise resolving to the saved thread's UUID + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract saveLink(link: NewLinkWithNotes): Promise; + + /** + * Saves a thread with notes to the source's priority. + * + * @deprecated Use saveLink() instead. saveThread() will be removed in a future version. * * @param thread - The thread with notes to save * @returns Promise resolving to the saved thread's UUID From 520335c519d1e259eb3a48ca9f98ce563b1c187f Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 26 Feb 2026 08:32:05 -0500 Subject: [PATCH 10/25] Update scripts for sources --- pnpm-lock.yaml | 64 --------------------------- sources/asana/package.json | 4 +- sources/github-issues/package.json | 4 +- sources/github/package.json | 4 +- sources/gmail/package.json | 4 +- sources/google-calendar/package.json | 4 +- sources/google-contacts/package.json | 4 +- sources/google-drive/package.json | 4 +- sources/jira/package.json | 4 +- sources/linear/package.json | 4 +- sources/outlook-calendar/package.json | 4 +- sources/slack/package.json | 4 +- 12 files changed, 33 insertions(+), 75 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5472934..c9804b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,22 +207,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - twists/calendar-sync: - dependencies: - '@plotday/source-google-calendar': - specifier: workspace:^ - version: link:../../sources/google-calendar - '@plotday/source-outlook-calendar': - specifier: workspace:^ - version: link:../../sources/outlook-calendar - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - twists/chat: dependencies: '@plotday/twister': @@ -236,32 +220,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - twists/code-review: - dependencies: - '@plotday/source-github': - specifier: workspace:^ - version: link:../../sources/github - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - - twists/document-actions: - dependencies: - '@plotday/source-google-drive': - specifier: workspace:^ - version: link:../../sources/google-drive - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - twists/message-tasks: dependencies: '@plotday/source-gmail': @@ -281,28 +239,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - twists/project-sync: - dependencies: - '@plotday/source-asana': - specifier: workspace:^ - version: link:../../sources/asana - '@plotday/source-jira': - specifier: workspace:^ - version: link:../../sources/jira - '@plotday/source-linear': - specifier: workspace:^ - version: link:../../sources/linear - '@plotday/tool-github-issues': - specifier: workspace:^ - version: link:../../sources/github-issues - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - packages: '@babel/cli@7.28.3': diff --git a/sources/asana/package.json b/sources/asana/package.json index 1080673..f520de7 100644 --- a/sources/asana/package.json +++ b/sources/asana/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-asana", + "plotTwistId": "d8f4e839-c152-41a2-926c-700e23d4fc77", "displayName": "Asana", "description": "Sync with Asana project management", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/github-issues/package.json b/sources/github-issues/package.json index 405ccf8..db331ad 100644 --- a/sources/github-issues/package.json +++ b/sources/github-issues/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-github-issues", + "plotTwistId": "465a9d14-f175-47f5-bbac-1b9d45d1d099", "displayName": "GitHub Issues", "description": "Sync with GitHub Issues", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/github/package.json b/sources/github/package.json index 297a452..6510b7f 100644 --- a/sources/github/package.json +++ b/sources/github/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-github", + "plotTwistId": "ba3afb64-af6e-4c64-bcff-5fa8575d21f0", "displayName": "GitHub", "description": "Sync with GitHub pull requests and code reviews", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/gmail/package.json b/sources/gmail/package.json index be958a6..7828d58 100644 --- a/sources/gmail/package.json +++ b/sources/gmail/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-gmail", + "plotTwistId": "7176b853-4495-4bba-82ee-645ae5d398d7", "displayName": "Gmail", "description": "Sync with Gmail inbox and messages", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/google-calendar/package.json b/sources/google-calendar/package.json index a732789..6e32a22 100644 --- a/sources/google-calendar/package.json +++ b/sources/google-calendar/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-google-calendar", + "plotTwistId": "2ed4fcf8-6524-410f-b318-f9316e71c8b0", "displayName": "Google Calendar", "description": "Sync with Google Calendar", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/source-google-contacts": "workspace:^", diff --git a/sources/google-contacts/package.json b/sources/google-contacts/package.json index ffc5b26..e3d4b5f 100644 --- a/sources/google-contacts/package.json +++ b/sources/google-contacts/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-google-contacts", + "plotTwistId": "748bb590-5da3-4782-aa6f-6a98fc2d3555", "displayName": "Google Contacts", "description": "Sync with Google Contacts", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/google-drive/package.json b/sources/google-drive/package.json index 801e3c6..ecdab77 100644 --- a/sources/google-drive/package.json +++ b/sources/google-drive/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-google-drive", + "plotTwistId": "c27741c2-0f26-444c-9e50-e1d70dab0dee", "displayName": "Google Drive", "description": "Sync documents comments from Google Drive", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/source-google-contacts": "workspace:^", diff --git a/sources/jira/package.json b/sources/jira/package.json index e294776..d167f2f 100644 --- a/sources/jira/package.json +++ b/sources/jira/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-jira", + "plotTwistId": "07f11ed7-9555-431d-b01a-99aff550d778", "displayName": "Jira", "description": "Sync with Jira project management", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/linear/package.json b/sources/linear/package.json index 85b7d1a..58a0ba7 100644 --- a/sources/linear/package.json +++ b/sources/linear/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-linear", + "plotTwistId": "d23218d6-1e26-4a4d-9a24-63898a494c51", "displayName": "Linear", "description": "Sync with Linear project management", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/outlook-calendar/package.json b/sources/outlook-calendar/package.json index a0739ff..4de7dab 100644 --- a/sources/outlook-calendar/package.json +++ b/sources/outlook-calendar/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-outlook-calendar", + "plotTwistId": "cf518010-30c1-4594-b3df-295a19d65459", "displayName": "Outlook Calendar", "description": "Sync with Microsoft Outlook Calendar", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/slack/package.json b/sources/slack/package.json index 2ec5653..6a2be87 100644 --- a/sources/slack/package.json +++ b/sources/slack/package.json @@ -1,5 +1,6 @@ { "name": "@plotday/source-slack", + "plotTwistId": "d8cbc41f-71f5-4cb6-a0bb-3ade462c4084", "displayName": "Slack", "description": "Sync with Slack channels and messages", "author": "Plot (https://plot.day)", @@ -22,7 +23,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "deploy": "plot deploy" }, "dependencies": { "@plotday/twister": "workspace:^" From 30afad309f1d3b6393479893f166c022f64dae26 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 26 Feb 2026 09:07:50 -0500 Subject: [PATCH 11/25] Remove deprecated twister functions and types --- sources/AGENTS.md | 8 +- sources/asana/src/asana.ts | 7 +- sources/github-issues/src/github-issues.ts | 7 +- sources/github/src/github.ts | 7 +- sources/gmail/src/gmail-api.ts | 1 + sources/gmail/src/gmail.ts | 6 - .../google-calendar/src/google-calendar.ts | 6 +- sources/google-drive/src/google-drive.ts | 7 +- sources/jira/src/jira.ts | 7 +- sources/linear/src/linear.ts | 7 +- .../outlook-calendar/src/outlook-calendar.ts | 6 +- sources/slack/src/slack-api.ts | 1 + sources/slack/src/slack.ts | 6 - twister/cli/templates/AGENTS.template.md | 261 +++++++----------- twister/src/common/calendar.ts | 3 - twister/src/common/documents.ts | 3 - twister/src/common/messaging.ts | 3 - twister/src/common/projects.ts | 3 - twister/src/common/source-control.ts | 3 - twister/src/plot.ts | 69 +---- twister/src/schedule.ts | 3 - twister/src/tool.ts | 13 - twister/src/tools/integrations.ts | 35 +-- twister/src/tools/plot.ts | 5 - 24 files changed, 120 insertions(+), 357 deletions(-) diff --git a/sources/AGENTS.md b/sources/AGENTS.md index d8c8b6c..f5e18bd 100644 --- a/sources/AGENTS.md +++ b/sources/AGENTS.md @@ -459,11 +459,11 @@ build(build: SourceBuilder) { ## Architecture: Sources Save Directly -**Sources save data directly** via `integrations.saveThread()`. Sources build `NewActivityWithNotes` objects and save them, rather than passing them through a parent twist. +**Sources save data directly** via `integrations.saveLink()`. Sources build `NewLinkWithNotes` objects and save them, rather than passing them through a parent twist. This means: -- Sources request `Plot` with `ContactAccess.Write` (for contacts on activities) -- Sources declare `static readonly Options: SyncToolOptions` to receive configuration from the parent +- Sources request `Plot` with `ContactAccess.Write` (for contacts on threads) +- Sources declare providers via `Integrations` with lifecycle callbacks - Sources call save methods directly to persist synced data ## Critical: Callback Serialization Pattern @@ -809,7 +809,7 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove 5. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key) 6. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit 7. **❌ Missing localhost guard** — Webhook registration fails silently on localhost -8. **❌ Calling `plot.createActivity()` from a source** — Sources save data directly via `integrations.saveThread()` +8. **❌ Calling `plot.createThread()` from a source** — Sources save data directly via `integrations.saveLink()` 9. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only 10. **❌ Passing `undefined` in serializable values** — Use `null` instead 11. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index 13b4493..267c94a 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -114,12 +114,6 @@ export class Asana extends Source implements ProjectSource { */ async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "asana", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); } @@ -400,6 +394,7 @@ export class Asana extends Source implements ProjectSource { syncableId: projectId, }, actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: taskUrl, author: authorContact, assignee: assigneeContact ?? null, // Explicitly set to null for unassigned tasks status: task.completed && task.completed_at ? "done" : "open", diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index 02d7a42..8bca8c8 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -156,12 +156,6 @@ export class GitHubIssues extends Source implements ProjectSource */ async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "github-issues", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); await this.clear(`repo_info_${channel.id}`); } @@ -492,6 +486,7 @@ export class GitHubIssues extends Source implements ProjectSource projectId: repoId, }, actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: issue.html_url ?? null, notes, preview: hasDescription ? description : null, ...(initialSync ? { unread: false } : {}), diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index ff01e2b..4f77587 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -210,12 +210,6 @@ export class GitHub extends Source implements SourceControlSource { */ async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "github", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); } @@ -801,6 +795,7 @@ export class GitHub extends Source implements SourceControlSource { prNodeId: pr.id, }, actions: threadActions, + sourceUrl: pr.html_url, notes, preview: hasDescription ? pr.body : null, ...(initialSync ? { unread: false } : {}), diff --git a/sources/gmail/src/gmail-api.ts b/sources/gmail/src/gmail-api.ts index cdf9e8a..d6612e3 100644 --- a/sources/gmail/src/gmail-api.ts +++ b/sources/gmail/src/gmail-api.ts @@ -375,6 +375,7 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes { threadId: thread.id, historyId: thread.historyId, }, + sourceUrl: canonicalUrl, notes: [], preview, }; diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index 830086a..b744791 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -100,12 +100,6 @@ export class Gmail extends Source implements MessagingSource { async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "google", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); } diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index 909f659..a0066ca 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -207,11 +207,6 @@ export class GoogleCalendar */ async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "google", syncableId: channel.id }, - }); } private async getApi(calendarId: string): Promise { @@ -798,6 +793,7 @@ export class GoogleCalendar author: authorContact, meta: activityData.meta ?? null, actions: hasActions ? actions : undefined, + sourceUrl: event.htmlLink ?? null, notes: descriptionNote ? [descriptionNote] : [], preview: hasDescription ? description : null, schedules: activityData.schedules, diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index 00e7f03..f8b9286 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -187,12 +187,6 @@ export class GoogleDrive extends Source implements DocumentSource { */ async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "google", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); } @@ -709,6 +703,7 @@ export class GoogleDrive extends Source implements DocumentSource { type: "document", title: file.name, author, + sourceUrl: file.webViewLink ?? null, actions: actions.length > 0 ? actions : null, meta: { fileId: file.id, diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index 0ce3096..071f97a 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -127,12 +127,6 @@ export class Jira extends Source implements ProjectSource { */ async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "atlassian", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); } @@ -441,6 +435,7 @@ export class Jira extends Source implements ProjectSource { assignee: assigneeContact ?? null, // Explicitly set to null for unassigned issues status: fields.resolutiondate ? "done" : "open", actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: issueUrl ?? null, notes, preview: description || null, }; diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index e0f23c9..c61e5aa 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -128,12 +128,6 @@ export class Linear extends Source implements ProjectSource { */ async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "linear", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); } @@ -432,6 +426,7 @@ export class Linear extends Source implements ProjectSource { projectId, }, actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: issue.url ?? null, notes, preview: hasDescription ? description : null, ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index 9037de9..01e204e 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -176,11 +176,6 @@ export class OutlookCalendar async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); await this.clear(`sync_enabled_${channel.id}`); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "microsoft", syncableId: channel.id }, - }); } private async getApi(calendarId: string): Promise { @@ -565,6 +560,7 @@ export class OutlookCalendar syncProvider: "microsoft", syncableId: calendarId, }, + sourceUrl: outlookEvent.webLink ?? null, actions: hasActions ? actions : undefined, notes: descriptionNote ? [descriptionNote] : [], preview: hasDescription ? outlookEvent.body!.content! : null, diff --git a/sources/slack/src/slack-api.ts b/sources/slack/src/slack-api.ts index 6bb1443..aff0f38 100644 --- a/sources/slack/src/slack-api.ts +++ b/sources/slack/src/slack-api.ts @@ -267,6 +267,7 @@ export function transformSlackThread( channelId: channelId, threadTs: threadTs, }, + sourceUrl: canonicalUrl, notes: [], preview: firstText || null, }; diff --git a/sources/slack/src/slack.ts b/sources/slack/src/slack.ts index 66c2965..afc4dd3 100644 --- a/sources/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -123,12 +123,6 @@ export class Slack extends Source implements MessagingSource { async onChannelDisabled(channel: Channel): Promise { await this.stopSync(channel.id); - - // Archive all threads from this channel - await this.tools.integrations.archiveThreads({ - meta: { syncProvider: "slack", syncableId: channel.id }, - }); - await this.clear(`sync_enabled_${channel.id}`); } diff --git a/twister/cli/templates/AGENTS.template.md b/twister/cli/templates/AGENTS.template.md index 6474990..fe8e605 100644 --- a/twister/cli/templates/AGENTS.template.md +++ b/twister/cli/templates/AGENTS.template.md @@ -19,65 +19,65 @@ Plot Twists are TypeScript classes that extend the `Twist` base class. Twists in - **Store intermediate state**: Use the Store tool to persist state between batches - **Examples**: Syncing large datasets, processing many API calls, or performing batch operations -## Understanding Activities and Notes +## Understanding Threads and Notes -**CRITICAL CONCEPT**: An **Activity** represents something done or to be done (a task, event, or conversation), while **Notes** represent the updates and details on that activity. +**CRITICAL CONCEPT**: A **Thread** represents something done or to be done (a task, event, or conversation), while **Notes** represent the updates and details on that thread. -**Think of an Activity as a thread** on a messaging platform, and **Notes as the messages in that thread**. +**Think of a Thread as a thread** on a messaging platform, and **Notes as the messages in that thread**. ### Key Guidelines -1. **Always create Activities with an initial Note** - The title is just a summary; detailed content goes in Notes -2. **Add Notes to existing Activities for updates** - Don't create a new Activity for each related message -3. **Use Activity.source and Note.key for automatic upserts (Recommended)** - Set Activity.source to the external item's URL for deduplication, and use Note.key for upsertable note content. No manual ID tracking needed. -4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot activities per external item (see SYNC_STRATEGIES.md) -5. **Most Activities should be `ActivityType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end` +1. **Always create Threads with an initial Note** - The title is just a summary; detailed content goes in Notes +2. **Add Notes to existing Threads for updates** - Don't create a new Thread for each related message +3. **Use Thread.source and Note.key for automatic upserts (Recommended)** - Set Thread.source to the external item's URL for deduplication, and use Note.key for upsertable note content. No manual ID tracking needed. +4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot threads per external item (see SYNC_STRATEGIES.md) +5. **Most Threads should be `ThreadType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end` ### Recommended Decision Tree (Strategy 2: Upsert via Source/Key) ``` New event/task/conversation from external system? ├─ Has stable URL or ID? - │ └─ Yes → Set Activity.source to the canonical URL/ID - │ Create Activity (Plot handles deduplication automatically) + │ └─ Yes → Set Thread.source to the canonical URL/ID + │ Create Thread (Plot handles deduplication automatically) │ Use Note.key for different note types: │ - "description" for main content │ - "metadata" for status/priority/assignee │ - "comment-{id}" for individual comments │ - └─ No stable identifier OR need multiple Plot activities per external item? + └─ No stable identifier OR need multiple Plot threads per external item? └─ Use Advanced Pattern (Strategy 3: Generate and Store IDs) See SYNC_STRATEGIES.md for details ``` ### Advanced Decision Tree (Strategy 3: Generate and Store IDs) -Only use when source/key upserts aren't sufficient (e.g., creating multiple activities from one external item): +Only use when source/key upserts aren't sufficient (e.g., creating multiple threads from one external item): ``` New event/task/conversation? ├─ Yes → Generate UUID with Uuid.Generate() - │ Create new Activity with that UUID - │ Store mapping: external_id → activity_uuid + │ Create new Thread with that UUID + │ Store mapping: external_id → thread_uuid │ └─ No (update/reply/comment) → Look up mapping by external_id - ├─ Found → Add Note to existing Activity using stored UUID - └─ Not found → Create new Activity with UUID + store mapping + ├─ Found → Add Note to existing Thread using stored UUID + └─ Not found → Create new Thread with UUID + store mapping ``` ## Twist Structure Pattern ```typescript import { - type Activity, - type NewActivityWithNotes, - type ActivityFilter, + type Thread, + type NewThreadWithNotes, + type ThreadFilter, type Priority, type ToolBuilder, Twist, - ActivityType, + ThreadType, } from "@plotday/twister"; -import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; +import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; // Import your tools: // import { GoogleCalendar } from "@plotday/tool-google-calendar"; // import { Linear } from "@plotday/tool-linear"; @@ -85,12 +85,8 @@ import { ActivityAccess, Plot } from "@plotday/twister/tools/plot"; export default class MyTwist extends Twist { build(build: ToolBuilder) { return { - // myTool: build(MyTool, { - // onItem: this.handleItem, - // onSyncableDisabled: this.onSyncableDisabled, - // }), plot: build(Plot, { - activity: { access: ActivityAccess.Create }, + thread: { access: ThreadAccess.Create }, }), }; } @@ -98,14 +94,6 @@ export default class MyTwist extends Twist { async activate(_priority: Pick) { // Auth and resource selection handled in the twist edit modal. } - - async handleItem(activity: NewActivityWithNotes): Promise { - await this.tools.plot.createActivity(activity); - } - - async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); - } } ``` @@ -185,18 +173,18 @@ async activate(_priority: Pick) { } ``` -**Store Parent Activity for Later (optional):** +**Store Parent Thread for Later (optional):** ```typescript async activate(_priority: Pick) { - const activityId = await this.tools.plot.createActivity({ - type: ActivityType.Note, + const threadId = await this.tools.plot.createThread({ + type: ThreadType.Note, title: "Setup complete", notes: [{ - content: "Your twist is ready. Activities will appear as they sync.", + content: "Your twist is ready. Threads will appear as they sync.", }], }); - await this.set("setup_activity_id", activityId); + await this.set("setup_thread_id", threadId); } ``` @@ -204,44 +192,22 @@ async activate(_priority: Pick) { Twists respond to events through callbacks declared in `build()`: -**Receive synced items from a tool (most common):** - -```typescript -build(build: ToolBuilder) { - return { - myTool: build(MyTool, { - onItem: this.handleItem, - onSyncableDisabled: this.onSyncableDisabled, - }), - plot: build(Plot, { activity: { access: ActivityAccess.Create } }), - }; -} - -async handleItem(activity: NewActivityWithNotes): Promise { - await this.tools.plot.createActivity(activity); -} - -async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); -} -``` - -**React to activity changes (for two-way sync):** +**React to thread changes (for two-way sync):** ```typescript plot: build(Plot, { - activity: { - access: ActivityAccess.Create, - updated: this.onActivityUpdated, + thread: { + access: ThreadAccess.Create, + updated: this.onThreadUpdated, }, note: { created: this.onNoteCreated, }, }), -async onActivityUpdated(activity: Activity, changes: { tagsAdded, tagsRemoved }): Promise { - const tool = this.getToolForActivity(activity); - if (tool?.updateIssue) await tool.updateIssue(activity); +async onThreadUpdated(thread: Thread, changes: { tagsAdded, tagsRemoved }): Promise { + const tool = this.getToolForThread(thread); + if (tool?.updateIssue) await tool.updateIssue(thread); } async onNoteCreated(note: Note): Promise { @@ -254,7 +220,7 @@ async onNoteCreated(note: Note): Promise { ```typescript plot: build(Plot, { - activity: { access: ActivityAccess.Respond }, + thread: { access: ThreadAccess.Respond }, note: { intents: [{ description: "Respond to general questions", @@ -265,43 +231,43 @@ plot: build(Plot, { }), ``` -## Activity Links +## Actions -Activity links enable user interaction: +Actions enable user interaction: ```typescript -import { type ActivityLink, ActivityLinkType } from "@plotday/twister"; +import { type Action, ActionType } from "@plotday/twister"; -// External URL link -const urlLink: ActivityLink = { +// External URL action +const urlAction: Action = { title: "Open website", - type: ActivityLinkType.external, + type: ActionType.external, url: "https://example.com", }; -// Callback link (uses Callbacks tool — use linkCallback, not callback) -const token = await this.linkCallback(this.onLinkClicked, "context"); -const callbackLink: ActivityLink = { +// Callback action (uses Callbacks tool — use linkCallback, not callback) +const token = await this.linkCallback(this.onActionClicked, "context"); +const callbackAction: Action = { title: "Click me", - type: ActivityLinkType.callback, + type: ActionType.callback, callback: token, }; -// Add to activity note -await this.tools.plot.createActivity({ - type: ActivityType.Note, - title: "Task with links", +// Add to thread note +await this.tools.plot.createThread({ + type: ThreadType.Note, + title: "Task with actions", notes: [ { - content: "Click the links below to take action.", - links: [urlLink, callbackLink], + content: "Click the actions below to take action.", + actions: [urlAction, callbackAction], }, ], }); -// Callback handler receives the ActivityLink as first argument -async onLinkClicked(link: ActivityLink, context: string): Promise { - // Handle link click +// Callback handler receives the Action as first argument +async onActionClicked(action: Action, context: string): Promise { + // Handle action click } ``` @@ -317,9 +283,9 @@ build(build: ToolBuilder) { providers: [{ provider: AuthProvider.Google, scopes: ["https://www.googleapis.com/auth/calendar"], - getSyncables: this.getSyncables, // List available resources after auth - onSyncEnabled: this.onSyncEnabled, // User enabled a resource - onSyncDisabled: this.onSyncDisabled, // User disabled a resource + getChannels: this.getChannels, // List available resources after auth + onChannelEnabled: this.onChannelEnabled, // User enabled a resource + onChannelDisabled: this.onChannelDisabled, // User disabled a resource }], }), // ... @@ -327,7 +293,7 @@ build(build: ToolBuilder) { } // Get a token for API calls: -const token = await this.tools.integrations.get(AuthProvider.Google, syncableId); +const token = await this.tools.integrations.get(AuthProvider.Google, channelId); if (!token) throw new Error("No auth token available"); const client = new ApiClient({ accessToken: token.token }); ``` @@ -338,7 +304,7 @@ For per-user write-backs (e.g., RSVP, comments attributed to the acting user): await this.tools.integrations.actAs( AuthProvider.Google, actorId, // The user who performed the action - activityId, // Activity to prompt for auth if needed + threadId, // Thread to prompt for auth if needed this.performWriteBack, ...extraArgs ); @@ -346,70 +312,43 @@ await this.tools.integrations.actAs( ## Sync Pattern -### Recommended: Using External Tools with SyncToolOptions - -Most twists use external tools (CalendarTool, ProjectTool, etc.) that handle sync internally. The twist just receives `NewActivityWithNotes` objects and saves them: - -```typescript -build(build: ToolBuilder) { - return { - calendarTool: build(GoogleCalendar, { - onItem: this.handleEvent, // Receives synced items - onSyncableDisabled: this.onSyncableDisabled, // Clean up when disabled - }), - plot: build(Plot, { activity: { access: ActivityAccess.Create } }), - }; -} - -// Tools deliver NewActivityWithNotes — twist saves them -async handleEvent(activity: NewActivityWithNotes): Promise { - await this.tools.plot.createActivity(activity); -} - -async onSyncableDisabled(filter: ActivityFilter): Promise { - await this.tools.plot.updateActivity({ match: filter, archived: true }); -} -``` - -### Custom Sync: Upsert via Source/Key (Strategy 2) +### Upsert via Source/Key (Strategy 2) -For direct API integration without an external tool, use source/key for automatic upserts: +Use source/key for automatic upserts: ```typescript async handleEvent(event: ExternalEvent): Promise { - const activity: NewActivityWithNotes = { + const thread: NewThreadWithNotes = { source: event.htmlLink, // Canonical URL for automatic deduplication - type: ActivityType.Event, + type: ThreadType.Event, title: event.summary || "(No title)", - start: event.start?.dateTime || event.start?.date || null, - end: event.end?.dateTime || event.end?.date || null, notes: [], }; if (event.description) { - activity.notes.push({ - activity: { source: event.htmlLink }, + thread.notes.push({ + thread: { source: event.htmlLink }, key: "description", // This key enables note-level upserts content: event.description, }); } // Create or update — Plot handles deduplication automatically - await this.tools.plot.createActivity(activity); + await this.tools.plot.createThread(thread); } ``` ### Advanced: Generate and Store IDs (Strategy 3) -Only use this pattern when you need to create multiple Plot activities from a single external item, or when the external system doesn't provide stable identifiers. See SYNC_STRATEGIES.md for details. +Only use this pattern when you need to create multiple Plot threads from a single external item, or when the external system doesn't provide stable identifiers. See SYNC_STRATEGIES.md for details. ```typescript async handleEventAdvanced( - incomingActivity: NewActivityWithNotes, + incomingThread: NewThreadWithNotes, calendarId: string ): Promise { // Extract external event ID from meta (adapt based on your tool's data) - const externalId = incomingActivity.meta?.eventId; + const externalId = incomingThread.meta?.eventId; if (!externalId) { console.error("Event missing external ID"); @@ -418,38 +357,38 @@ async handleEventAdvanced( // Check if we've already synced this event const mappingKey = `event_mapping:${calendarId}:${externalId}`; - const existingActivityId = await this.get(mappingKey); + const existingThreadId = await this.get(mappingKey); - if (existingActivityId) { + if (existingThreadId) { // Event already exists - add update as a Note (add message to thread) - if (incomingActivity.notes?.[0]?.content) { + if (incomingThread.notes?.[0]?.content) { await this.tools.plot.createNote({ - activity: { id: existingActivityId }, - content: incomingActivity.notes[0].content, + thread: { id: existingThreadId }, + content: incomingThread.notes[0].content, }); } return; } // New event - generate UUID and store mapping - const activityId = Uuid.Generate(); - await this.set(mappingKey, activityId); + const threadId = Uuid.Generate(); + await this.set(mappingKey, threadId); - // Create new Activity with initial Note (new thread with first message) - await this.tools.plot.createActivity({ - ...incomingActivity, - id: activityId, + // Create new Thread with initial Note (new thread with first message) + await this.tools.plot.createThread({ + ...incomingThread, + id: threadId, }); } ``` ## Resource Selection -Resource selection (calendars, projects, channels) is handled automatically in the twist edit modal via the Integrations tool. Users see a list of available resources returned by your tool's `getSyncables()` method and toggle them on/off. You do **not** need to build custom selection UI. +Resource selection (calendars, projects, channels) is handled automatically in the twist edit modal via the Integrations tool. Users see a list of available resources returned by your tool's `getChannels()` method and toggle them on/off. You do **not** need to build custom selection UI. ```typescript // In your tool: -async getSyncables(_auth: Authorization, token: AuthToken): Promise { +async getChannels(_auth: Authorization, token: AuthToken): Promise { const client = new ApiClient({ accessToken: token.token }); const calendars = await client.listCalendars(); return calendars.map(c => ({ @@ -501,10 +440,10 @@ async syncBatch(resourceId: string): Promise { // Process results using source/key pattern (automatic upserts, no manual tracking) // If each item makes ~10 requests, keep batch size ≤ 100 items to stay under limit for (const item of result.items) { - // Each createActivity may make ~5-10 requests depending on notes/links - await this.tools.plot.createActivity({ + // Each createThread may make ~5-10 requests depending on notes/links + await this.tools.plot.createThread({ source: item.url, // Use item's canonical URL for automatic deduplication - type: ActivityType.Note, + type: ThreadType.Note, title: item.title, notes: [{ activity: { source: item.url }, @@ -533,8 +472,8 @@ async syncBatch(resourceId: string): Promise { await this.clear(`sync_state_${resourceId}`); // Optionally notify user of completion - await this.tools.plot.createActivity({ - type: ActivityType.Note, + await this.tools.plot.createThread({ + type: ThreadType.Note, title: "Sync complete", notes: [ { @@ -546,9 +485,9 @@ async syncBatch(resourceId: string): Promise { } ``` -## Activity Sync Best Practices +## Thread Sync Best Practices -When syncing activities from external systems, follow these patterns for optimal user experience: +When syncing threads from external systems, follow these patterns for optimal user experience: ### The `initialSync` Flag @@ -561,8 +500,8 @@ All sync-based tools should distinguish between initial sync (first import) and **Example:** ```typescript -const activity: NewActivity = { - type: ActivityType.Event, +const thread: NewThread = { + type: ThreadType.Event, source: event.url, title: event.title, ...(initialSync ? { unread: false } : {}), // false for initial, omit for incremental @@ -577,9 +516,9 @@ const activity: NewActivity = { ### Two-Way Sync: Avoiding Race Conditions -When implementing two-way sync where items created in Plot are pushed to an external system (e.g. Notes becoming comments), a race condition can occur: the external system may send a webhook for the newly created item before you've updated the Activity/Note with the external key. The webhook handler won't find the item by external key and may create a duplicate. +When implementing two-way sync where items created in Plot are pushed to an external system (e.g. Notes becoming comments), a race condition can occur: the external system may send a webhook for the newly created item before you've updated the Thread/Note with the external key. The webhook handler won't find the item by external key and may create a duplicate. -**Solution:** Embed the Plot `Activity.id` / `Note.id` in the external item's metadata when creating it, and update `Activity.source` / `Note.key` after creation. When processing webhooks, check for the Plot ID in metadata first. +**Solution:** Embed the Plot `Thread.id` / `Note.id` in the external item's metadata when creating it, and update `Thread.source` / `Note.key` after creation. When processing webhooks, check for the Plot ID in metadata first. ```typescript async pushNoteAsComment(note: Note, externalItemId: string): Promise { @@ -622,8 +561,8 @@ try { } catch (error) { console.error("Operation failed:", error); - await this.tools.plot.createActivity({ - type: ActivityType.Note, + await this.tools.plot.createThread({ + type: ThreadType.Note, title: "Operation failed", notes: [ { @@ -637,11 +576,11 @@ try { ## Common Pitfalls - **Don't use instance variables for state** - Anything stored in memory is lost after function execution. Always use the Store tool for data that needs to persist. -- **Processing self-created activities** - Other users may change an Activity created by the twist, resulting in an \`activity\` call. Be sure to check the \`changes === null\` and/or \`activity.author.id !== this.id\` to avoid re-processing. -- **Always create Activities with Notes** - See "Understanding Activities and Notes" section above for the thread/message pattern and decision tree. -- **Use correct Activity types** - Most should be `ActivityType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`. -- **Use Activity.source and Note.key for automatic upserts (Recommended)** - Set Activity.source to the external item's URL for automatic deduplication. Only use UUID generation and storage for advanced cases (see SYNC_STRATEGIES.md). -- **Add Notes to existing Activities** - For source/key pattern, reference activities by source. For UUID pattern, look up stored mappings before creating new Activities. Think thread replies, not new threads. +- **Processing self-created threads** - Other users may change a Thread created by the twist, resulting in a callback. Be sure to check the `changes === null` and/or `thread.author.id !== this.id` to avoid re-processing. +- **Always create Threads with Notes** - See "Understanding Threads and Notes" section above for the thread/message pattern and decision tree. +- **Use correct Thread types** - Most should be `ThreadType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`. +- **Use Thread.source and Note.key for automatic upserts (Recommended)** - Set Thread.source to the external item's URL for automatic deduplication. Only use UUID generation and storage for advanced cases (see SYNC_STRATEGIES.md). +- **Add Notes to existing Threads** - For source/key pattern, reference threads by source. For UUID pattern, look up stored mappings before creating new Threads. Think thread replies, not new threads. - Tools are declared in the `build` method and accessed via `this.tools.toolName` in twist methods. - **Don't forget request limits** - Each execution has ~1000 requests (HTTP requests, tool calls). Break long loops into batches with `this.runTask()` to get fresh request limits. Calculate requests per item to determine safe batch size (e.g., if each item needs ~10 requests, batch size = ~100 items). - **Always use Callbacks tool for persistent references** - Direct function references don't survive worker restarts. diff --git a/twister/src/common/calendar.ts b/twister/src/common/calendar.ts index 93f9791..0d25445 100644 --- a/twister/src/common/calendar.ts +++ b/twister/src/common/calendar.ts @@ -124,6 +124,3 @@ export type CalendarSource = { */ stopSync(calendarId: string): Promise; }; - -/** @deprecated Use CalendarSource instead */ -export type CalendarTool = CalendarSource; diff --git a/twister/src/common/documents.ts b/twister/src/common/documents.ts index 75947ab..da24702 100644 --- a/twister/src/common/documents.ts +++ b/twister/src/common/documents.ts @@ -133,6 +133,3 @@ export type DocumentSource = { noteId?: string, ): Promise; }; - -/** @deprecated Use DocumentSource instead */ -export type DocumentTool = DocumentSource; diff --git a/twister/src/common/messaging.ts b/twister/src/common/messaging.ts index 4147844..22b0208 100644 --- a/twister/src/common/messaging.ts +++ b/twister/src/common/messaging.ts @@ -80,6 +80,3 @@ export type MessagingSource = { */ stopSync(channelId: string): Promise; }; - -/** @deprecated Use MessagingSource instead */ -export type MessagingTool = MessagingSource; diff --git a/twister/src/common/projects.ts b/twister/src/common/projects.ts index 0cb21d3..ac99880 100644 --- a/twister/src/common/projects.ts +++ b/twister/src/common/projects.ts @@ -119,6 +119,3 @@ export type ProjectSource = { noteId?: string, ): Promise; }; - -/** @deprecated Use ProjectSource instead */ -export type ProjectTool = ProjectSource; diff --git a/twister/src/common/source-control.ts b/twister/src/common/source-control.ts index 4ff4dac..268d3d6 100644 --- a/twister/src/common/source-control.ts +++ b/twister/src/common/source-control.ts @@ -133,6 +133,3 @@ export type SourceControlSource = { */ closePR?(meta: ThreadMeta): Promise; }; - -/** @deprecated Use SourceControlSource instead */ -export type SourceControlTool = SourceControlSource; diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 1deb870..2998c74 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -183,11 +183,6 @@ export enum ThreadType { Event, } -/** @deprecated Use ThreadType instead */ -export const ActivityType = ThreadType; -/** @deprecated Use ThreadType instead */ -export type ActivityType = ThreadType; - /** * Kinds of threads. Used only for visual categorization (icon). */ @@ -225,11 +220,6 @@ export enum ActionType { file = "file", } -/** @deprecated Use ActionType instead */ -export const LinkType = ActionType; -/** @deprecated Use ActionType instead. Note: LinkType previously aliased ActionType; the new Link type is a different concept (external entity). */ -export type LinkType = ActionType; - /** * Video conferencing providers for conferencing links. * @@ -379,9 +369,6 @@ export type ThreadMeta = { [key: string]: JSONValue; }; -/** @deprecated Use ThreadMeta instead */ -export type ActivityMeta = ThreadMeta; - /** * Tags on an item, along with the actors who added each tag. */ @@ -513,41 +500,14 @@ export type Thread = ThreadFields & | { type: ThreadType.Event } ); -/** @deprecated Use Thread instead */ -export type Activity = Thread; - export type ThreadWithNotes = Thread & { notes: Note[]; }; -/** @deprecated Use ThreadWithNotes instead */ -export type ActivityWithNotes = ThreadWithNotes; - export type NewThreadWithNotes = NewThread & { notes: Omit[]; }; -/** @deprecated Use NewThreadWithNotes instead */ -export type NewActivityWithNotes = NewThreadWithNotes; - -/** @deprecated ThreadOccurrence has moved to Schedule. Use ScheduleOccurrence from @plotday/twister/schedule instead. */ -export type ThreadOccurrence = never; - -/** @deprecated Use ScheduleOccurrence from @plotday/twister/schedule instead */ -export type ActivityOccurrence = never; - -/** @deprecated Use NewScheduleOccurrence from @plotday/twister/schedule instead */ -export type NewThreadOccurrence = never; - -/** @deprecated Use NewScheduleOccurrence from @plotday/twister/schedule instead */ -export type NewActivityOccurrence = never; - -/** @deprecated Use ScheduleOccurrenceUpdate from @plotday/twister/schedule instead */ -export type ThreadOccurrenceUpdate = never; - -/** @deprecated Use ScheduleOccurrenceUpdate from @plotday/twister/schedule instead */ -export type ActivityOccurrenceUpdate = never; - /** * Configuration for automatic priority selection based on thread similarity. * @@ -763,9 +723,6 @@ export type NewThread = ( scheduleOccurrences?: NewScheduleOccurrence[]; }; -/** @deprecated Use NewThread instead */ -export type NewActivity = NewThread; - export type ThreadFilter = { type?: ActorType; meta?: { @@ -773,9 +730,6 @@ export type ThreadFilter = { }; }; -/** @deprecated Use ThreadFilter instead */ -export type ActivityFilter = ThreadFilter; - /** * Fields supported by bulk updates via `match`. Only simple scalar fields * that can be applied uniformly across many threads are included. @@ -834,9 +788,6 @@ export type ThreadUpdate = match: ThreadFilter; } & ThreadBulkUpdateFields); -/** @deprecated Use ThreadUpdate instead */ -export type ActivityUpdate = ThreadUpdate; - /** * Represents a note within a thread. * @@ -1118,6 +1069,10 @@ export type Link = { actions: Array | null; /** Source metadata */ meta: ThreadMeta | null; + /** URL to the source logo image for this link's type */ + logo: string | null; + /** URL to open the original item in its source application (e.g., "Open in Linear") */ + sourceUrl: string | null; }; /** @@ -1189,19 +1144,3 @@ export type NewLinkWithNotes = NewLink & { scheduleOccurrences?: NewScheduleOccurrence[]; }; -/** @deprecated Use ActionType instead */ -export const ActivityLinkType = ActionType; -/** @deprecated Use ActionType instead */ -export type ActivityLinkType = ActionType; -/** @deprecated Use Action instead */ -export type ActivityLink = Action; - -/** @deprecated Use ThreadKind instead */ -export const ActivityKind = ThreadKind; -/** @deprecated Use ThreadKind instead */ -export type ActivityKind = ThreadKind; - -/** @deprecated Use ThreadCommon instead */ -export type ActivityCommon = ThreadCommon; -/** @deprecated Use ThreadFields instead */ -export type ActivityFields = ThreadFields; diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts index 59c1af4..7299974 100644 --- a/twister/src/schedule.ts +++ b/twister/src/schedule.ts @@ -140,9 +140,6 @@ export type NewSchedule = { contacts?: NewScheduleContact[]; }; -/** @deprecated Schedules are updated via Thread. Use NewSchedule instead. */ -export type ScheduleUpdate = Partial>; - /** * Represents a specific instance of a recurring schedule. * All field values are computed by merging the recurring schedule's diff --git a/twister/src/tool.ts b/twister/src/tool.ts index 8df5157..31bd21e 100644 --- a/twister/src/tool.ts +++ b/twister/src/tool.ts @@ -1,7 +1,5 @@ import { type Actor, - type ThreadFilter, - type NewThreadWithNotes, type Priority, } from "./plot"; import type { Callback } from "./tools/callbacks"; @@ -15,17 +13,6 @@ import type { export type { ToolBuilder }; -/** - * @deprecated Sources now save threads directly via integrations.saveThread() - * instead of using callbacks. Use Source class instead of Tool + SyncToolOptions. - */ -export type SyncToolOptions = { - /** @deprecated Callback invoked for each synced item. */ - onItem: (item: NewThreadWithNotes) => Promise; - /** @deprecated Callback invoked when a syncable is disabled. */ - onSyncableDisabled?: (filter: ThreadFilter) => Promise; -}; - /** * Abstrtact parent for both built-in tools and regular Tools. * Regular tools extend Tool. diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index 0bd64c9..f9288d2 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -3,10 +3,8 @@ import { type ActorId, type NewContact, type NewLinkWithNotes, - type NewThreadWithNotes, type Note, type Thread, - type ThreadFilter, type ThreadMeta, ITool, Serializable, @@ -26,9 +24,6 @@ export type Channel = { children?: Channel[]; }; -/** @deprecated Use Channel instead */ -export type Syncable = Channel; - /** * Configuration for an OAuth provider in a source's build options. * Declares the provider, scopes, and lifecycle callbacks. @@ -42,6 +37,8 @@ export type LinkTypeConfig = { type: string; /** Human-readable label (e.g., "Issue", "Pull Request") */ label: string; + /** Filename of a static asset in the source's assets/ directory (e.g., "issue.svg") */ + logo?: string; /** Possible status values for this type */ statuses?: Array<{ /** Machine-readable status (e.g., "open", "done") */ @@ -75,13 +72,6 @@ export type IntegrationProviderConfig = { */ onNoteCreated?: (note: Note, meta: ThreadMeta) => Promise; - // Deprecated aliases - /** @deprecated Use getChannels instead */ - getSyncables?: (auth: Authorization, token: AuthToken) => Promise; - /** @deprecated Use onChannelEnabled instead */ - onSyncEnabled?: (channel: Channel) => Promise; - /** @deprecated Use onChannelDisabled instead */ - onSyncDisabled?: (channel: Channel) => Promise; }; /** @@ -195,17 +185,6 @@ export abstract class Integrations extends ITool { // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract saveLink(link: NewLinkWithNotes): Promise; - /** - * Saves a thread with notes to the source's priority. - * - * @deprecated Use saveLink() instead. saveThread() will be removed in a future version. - * - * @param thread - The thread with notes to save - * @returns Promise resolving to the saved thread's UUID - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract saveThread(thread: NewThreadWithNotes): Promise; - /** * Saves contacts to the source's priority. * @@ -215,16 +194,6 @@ export abstract class Integrations extends ITool { // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract saveContacts(contacts: NewContact[]): Promise; - /** - * Archives threads matching a filter. - * - * Useful for bulk archiving when a channel is disabled. - * - * @param filter - Filter to match threads to archive - * @returns Promise that resolves when archiving is complete - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract archiveThreads(filter: ThreadFilter): Promise; } /** diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index 0a3bfbb..8f2d155 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -35,11 +35,6 @@ export enum ThreadAccess { Create, } -/** @deprecated Use ThreadAccess instead */ -export const ActivityAccess = ThreadAccess; -/** @deprecated Use ThreadAccess instead */ -export type ActivityAccess = ThreadAccess; - export enum PriorityAccess { /** * Create a new Priority within the twist's Priority. From f442b2dc9babfed8ad85ea734cab7b31ee1309b7 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 26 Feb 2026 10:22:08 -0500 Subject: [PATCH 12/25] Link types --- sources/asana/src/asana.ts | 1 + sources/github-issues/src/github-issues.ts | 2 + sources/github/src/github.ts | 24 +++++++-- sources/gmail/src/gmail.ts | 2 +- .../google-calendar/src/google-calendar.ts | 2 +- sources/google-drive/src/google-drive.ts | 2 +- sources/jira/src/jira.ts | 1 + sources/linear/src/linear.ts | 42 +++++++++++++++ .../outlook-calendar/src/outlook-calendar.ts | 2 +- sources/slack/src/slack.ts | 2 +- twister/docs/TOOLS_GUIDE.md | 53 +++++++++++++++++++ twister/src/plot.ts | 2 - twister/src/tools/integrations.ts | 10 ++-- 13 files changed, 129 insertions(+), 16 deletions(-) diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index 267c94a..28417b8 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -53,6 +53,7 @@ export class Asana extends Source implements ProjectSource { { type: "task", label: "Task", + logo: "https://api.iconify.design/logos/asana.svg", statuses: [ { status: "open", label: "Open" }, { status: "done", label: "Done" }, diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index 8bca8c8..c6acaf4 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -60,6 +60,7 @@ export class GitHubIssues extends Source implements ProjectSource { type: "issue", label: "Issue", + logo: "https://api.iconify.design/logos/github-icon.svg", statuses: [ { status: "open", label: "Open" }, { status: "closed", label: "Closed" }, @@ -68,6 +69,7 @@ export class GitHubIssues extends Source implements ProjectSource { type: "pull_request", label: "Pull Request", + logo: "https://api.iconify.design/logos/github-icon.svg", statuses: [ { status: "open", label: "Open" }, { status: "closed", label: "Closed" }, diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index 4f77587..f9ab735 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -111,8 +111,16 @@ export class GitHub extends Source implements SourceControlSource { onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, linkTypes: [ - { type: "pull_request", label: "Pull Request" }, - { type: "review", label: "Review" }, + { + type: "pull_request", + label: "Pull Request", + logo: "https://api.iconify.design/logos/github-icon.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + { status: "merged", label: "Merged" }, + ], + }, ], }, ], @@ -482,7 +490,11 @@ export class GitHub extends Source implements SourceControlSource { created: new Date(pr.created_at), author: authorContact, assignee: assigneeContact, - status: pr.merged_at ? "done" : null, + status: pr.merged_at + ? "merged" + : pr.state === "closed" + ? "closed" + : "open", ...(pr.state === "closed" && !pr.merged_at ? { archived: true } : {}), meta: { provider: "github", @@ -786,7 +798,11 @@ export class GitHub extends Source implements SourceControlSource { created: new Date(pr.created_at), author: authorContact, assignee: assigneeContact, - status: pr.merged_at ? "done" : null, + status: pr.merged_at + ? "merged" + : pr.state === "closed" + ? "closed" + : "open", meta: { provider: "github", owner, diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index b744791..a568cf9 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -52,7 +52,7 @@ export class Gmail extends Source implements MessagingSource { getChannels: this.listSyncChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, - linkTypes: [{ type: "email", label: "Email" }], + linkTypes: [{ type: "email", label: "Email", logo: "https://api.iconify.design/logos/google-gmail.svg" }], }, ], }), diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index a0066ca..489d83d 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -125,7 +125,7 @@ export class GoogleCalendar GoogleCalendar.SCOPES, GoogleContacts.SCOPES ), - linkTypes: [{ type: "event", label: "Event" }], + linkTypes: [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/google-calendar.svg" }], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index f8b9286..6ecb451 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -72,7 +72,7 @@ export class GoogleDrive extends Source implements DocumentSource { getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, - linkTypes: [{ type: "document", label: "Document" }], + linkTypes: [{ type: "document", label: "Document", logo: "https://api.iconify.design/logos/google-drive.svg" }], }, ], }), diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index 071f97a..efe0679 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -52,6 +52,7 @@ export class Jira extends Source implements ProjectSource { { type: "issue", label: "Issue", + logo: "https://api.iconify.design/logos/jira.svg", statuses: [ { status: "open", label: "Open" }, { status: "done", label: "Done" }, diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index c61e5aa..b1d17c2 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -9,6 +9,7 @@ import { LinearWebhookClient } from "@linear/sdk/webhooks"; import { type Action, ActionType, + type Link, ThreadMeta, ThreadType, type NewLinkWithNotes, @@ -67,12 +68,14 @@ export class Linear extends Source implements ProjectSource { { type: "issue", label: "Issue", + logo: "https://api.iconify.design/logos/linear-icon.svg", statuses: [ { status: "open", label: "Open" }, { status: "done", label: "Done" }, ], }, ], + onLinkUpdated: this.onLinkUpdated, getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, @@ -131,6 +134,45 @@ export class Linear extends Source implements ProjectSource { await this.clear(`sync_enabled_${channel.id}`); } + /** + * Called when a link's status is changed from the Flutter app. + * Maps the link status back to a Linear workflow state. + */ + async onLinkUpdated(link: Link): Promise { + const issueId = link.meta?.linearId as string | undefined; + if (!issueId) return; + + const projectId = link.meta?.projectId as string | undefined; + if (!projectId) return; + + const client = await this.getClient(projectId); + const issue = await client.issue(issueId); + const team = await issue.team; + if (!team) return; + + const states = await team.states(); + let targetState; + + if (link.status === "done") { + targetState = states.nodes.find( + (s) => + s.name === "Done" || + s.name === "Completed" || + s.type === "completed" + ); + } else { + // "open" or any non-done status -> reopen + targetState = states.nodes.find( + (s) => + s.name === "Todo" || s.name === "Backlog" || s.type === "unstarted" + ); + } + + if (targetState) { + await client.updateIssue(issueId, { stateId: targetState.id }); + } + } + /** * Get list of Linear teams (projects) */ diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index 01e204e..c6d4244 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -117,7 +117,7 @@ export class OutlookCalendar { provider: OutlookCalendar.PROVIDER, scopes: OutlookCalendar.SCOPES, - linkTypes: [{ type: "event", label: "Event" }], + linkTypes: [{ type: "event", label: "Event", logo: "https://api.iconify.design/simple-icons/microsoftoutlook.svg" }], getChannels: this.getChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, diff --git a/sources/slack/src/slack.ts b/sources/slack/src/slack.ts index afc4dd3..1e3aa14 100644 --- a/sources/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -76,7 +76,7 @@ export class Slack extends Source implements MessagingSource { getChannels: this.listSyncChannels, onChannelEnabled: this.onChannelEnabled, onChannelDisabled: this.onChannelDisabled, - linkTypes: [{ type: "message", label: "Message" }], + linkTypes: [{ type: "message", label: "Message", logo: "https://api.iconify.design/logos/slack-icon.svg" }], }, ], }), diff --git a/twister/docs/TOOLS_GUIDE.md b/twister/docs/TOOLS_GUIDE.md index 47e346e..424566b 100644 --- a/twister/docs/TOOLS_GUIDE.md +++ b/twister/docs/TOOLS_GUIDE.md @@ -1104,6 +1104,59 @@ async triageEmail(emailContent: string) { --- +## Link Type Safety Pattern + +When defining `linkTypes` in your source's provider config, use `as const satisfies` to get type-safe status strings: + +```typescript +import type { LinkTypeConfig } from "@plotday/twister/tools/integrations"; + +const LINK_TYPES = [ + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/simple-icons/linear.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + { + type: "pull_request", + label: "Pull Request", + logo: "https://api.iconify.design/simple-icons/github.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "merged", label: "Merged" }, + { status: "closed", label: "Closed" }, + ], + }, +] as const satisfies LinkTypeConfig[]; + +// Derive type-safe union types from the config +type IssueStatus = (typeof LINK_TYPES)[0]["statuses"][number]["status"]; // "open" | "done" +type PRStatus = (typeof LINK_TYPES)[1]["statuses"][number]["status"]; // "open" | "merged" | "closed" +``` + +Then reference `LINK_TYPES` in your provider config: + +```typescript +build(build: SourceBuilder) { + return { + integrations: build(Integrations, { + providers: [{ + provider: MySource.PROVIDER, + scopes: MySource.SCOPES, + linkTypes: [...LINK_TYPES], + // ... + }], + }), + }; +} +``` + +--- + ## Next Steps - **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own reusable tools diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 2998c74..33ed35d 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -1069,8 +1069,6 @@ export type Link = { actions: Array | null; /** Source metadata */ meta: ThreadMeta | null; - /** URL to the source logo image for this link's type */ - logo: string | null; /** URL to open the original item in its source application (e.g., "Open in Linear") */ sourceUrl: string | null; }; diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index f9288d2..e9b9844 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -1,10 +1,10 @@ import { type Actor, type ActorId, + type Link, type NewContact, type NewLinkWithNotes, type Note, - type Thread, type ThreadMeta, ITool, Serializable, @@ -37,7 +37,7 @@ export type LinkTypeConfig = { type: string; /** Human-readable label (e.g., "Issue", "Pull Request") */ label: string; - /** Filename of a static asset in the source's assets/ directory (e.g., "issue.svg") */ + /** URL to an icon for this link type. Prefer Iconify URLs (e.g., "https://api.iconify.design/simple-icons/linear.svg") */ logo?: string; /** Possible status values for this type */ statuses?: Array<{ @@ -62,10 +62,10 @@ export type IntegrationProviderConfig = { /** Called when a channel resource is disabled */ onChannelDisabled: (channel: Channel) => Promise; /** - * Called when a thread created by this source is updated by the user. - * Used for write-back to external services (e.g., marking an issue as done). + * Called when a link created by this source is updated by the user. + * Used for write-back to external services (e.g., changing issue status). */ - onThreadUpdated?: (thread: Thread) => Promise; + onLinkUpdated?: (link: Link) => Promise; /** * Called when a note is created on a thread owned by this source. * Used for write-back to external services (e.g., adding a comment to an issue). From c3021b2498954603884892b46076f38e9e0462b9 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Thu, 26 Feb 2026 22:33:41 -0500 Subject: [PATCH 13/25] Clean up threads and tags --- sources/asana/src/asana.ts | 31 +- sources/github-issues/src/github-issues.ts | 34 +- sources/github/src/github.ts | 23 +- sources/google-calendar/src/google-api.ts | 36 ++- .../google-calendar/src/google-calendar.ts | 2 - sources/google-drive/src/google-drive.ts | 2 +- sources/jira/src/jira.ts | 71 ++--- sources/linear/src/linear.ts | 46 +-- sources/outlook-calendar/src/graph-api.ts | 52 ++-- .../outlook-calendar/src/outlook-calendar.ts | 4 - twister/src/common/projects.ts | 13 +- twister/src/common/source-control.ts | 8 +- twister/src/plot.ts | 294 ++---------------- twister/src/tag.ts | 6 +- twister/src/tools/plot.ts | 2 - twister/src/twist.ts | 3 +- twists/chat/src/index.ts | 2 +- twists/message-tasks/src/index.ts | 23 +- 18 files changed, 179 insertions(+), 473 deletions(-) diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index 28417b8..dfe2ce4 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -1,11 +1,9 @@ import * as asana from "asana"; import { - type Thread, type Action, ActionType, ThreadMeta, - ThreadType, type NewLinkWithNotes, } from "@plotday/twister"; import type { @@ -405,38 +403,33 @@ export class Asana extends Source implements ProjectSource { } /** - * Update task with new values - * - * @param thread - The updated thread + * Update task with new values from the app */ - async updateIssue(thread: Thread): Promise { - // Extract Asana task GID and project ID from meta - const taskGid = thread.meta?.taskGid as string | undefined; + async updateIssue(link: import("@plotday/twister").Link): Promise { + const taskGid = link.meta?.taskGid as string | undefined; if (!taskGid) { - throw new Error("Asana task GID not found in thread meta"); + throw new Error("Asana task GID not found in link meta"); } - const projectId = thread.meta?.projectId as string | undefined; + const projectId = link.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Asana project ID not found in thread meta"); + throw new Error("Asana project ID not found in link meta"); } const client = await this.getClient(projectId); const updateFields: any = {}; // Handle title - if (thread.title !== null) { - updateFields.name = thread.title; + if (link.title) { + updateFields.name = link.title; } // Handle assignee - updateFields.assignee = thread.assignee?.id || null; + updateFields.assignee = link.assignee?.id || null; - // Handle completion status based on done - // Asana only has completed boolean (no In Progress state) - updateFields.completed = - thread.type === ThreadType.Action && thread.done !== null; + // Handle completion status based on link status + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed"; + updateFields.completed = isDone; - // Apply updates if any fields changed if (Object.keys(updateFields).length > 0) { await client.tasks.updateTask(taskGid, updateFields); } diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index c6acaf4..750e000 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -4,7 +4,6 @@ import { type Action, ActionType, type ThreadMeta, - ThreadType, type NewLinkWithNotes, } from "@plotday/twister"; import type { @@ -499,26 +498,24 @@ export class GitHubIssues extends Source implements ProjectSource } /** - * Update issue with new values + * Update issue with new values from the app */ async updateIssue( - thread: import("@plotday/twister").Thread + link: import("@plotday/twister").Link ): Promise { - const issueNumber = thread.meta?.githubIssueNumber as number | undefined; + const issueNumber = link.meta?.githubIssueNumber as number | undefined; if (!issueNumber) { - throw new Error("GitHub issue number not found in thread meta"); + throw new Error("GitHub issue number not found in link meta"); } - const repoFullName = thread.meta?.githubRepoFullName as - | string - | undefined; + const repoFullName = link.meta?.githubRepoFullName as string | undefined; if (!repoFullName) { - throw new Error("GitHub repo name not found in thread meta"); + throw new Error("GitHub repo name not found in link meta"); } - const projectId = thread.meta?.projectId as string | undefined; + const projectId = link.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in thread meta"); + throw new Error("Project ID not found in link meta"); } const octokit = await this.getClient(projectId); @@ -529,17 +526,14 @@ export class GitHubIssues extends Source implements ProjectSource assignees?: string[]; } = {}; - // Handle open/close status - if (thread.type === ThreadType.Action && thread.done !== null) { - updateFields.state = "closed"; - } else { - updateFields.state = "open"; - } + // Handle open/close status based on link status + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed"; + updateFields.state = isDone ? "closed" : "open"; // Handle assignee - use actor name as GitHub login - if (thread.assignee) { - if (thread.assignee.name) { - updateFields.assignees = [thread.assignee.name]; + if (link.assignee) { + if (link.assignee.name) { + updateFields.assignees = [link.assignee.name]; } } else { updateFields.assignees = []; diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index f9ab735..a4ec5b0 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -1,9 +1,7 @@ import { - type Thread, type Action, ActionType, type ThreadMeta, - ThreadType, type NewLinkWithNotes, Source, type ToolBuilder, @@ -871,24 +869,23 @@ export class GitHub extends Source implements SourceControlSource { /** * Update a PR's review status (approve or request changes) */ - async updatePRStatus(thread: Thread): Promise { - const meta = thread.meta; - if (!meta) return; + async updatePRStatus(link: import("@plotday/twister").Link): Promise { + if (!link.meta) return; - const owner = meta.owner as string; - const repo = meta.repo as string; - const prNumber = meta.prNumber as number; + const owner = link.meta.owner as string; + const repo = link.meta.repo as string; + const prNumber = link.meta.prNumber as number; const syncableId = `${owner}/${repo}`; if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in thread meta"); + throw new Error("Owner, repo, and prNumber required in link meta"); } const token = await this.getToken(syncableId); - // Map thread done state to review event - // done = approved, not done = no action (can't undo approval via API easily) - if (thread.type === ThreadType.Action && thread.done !== null) { + // Map link status to PR review event + const isDone = link.status === "done" || link.status === "closed" || link.status === "approved"; + if (isDone) { const response = await this.githubFetch( token, `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, @@ -903,7 +900,7 @@ export class GitHub extends Source implements SourceControlSource { if (!response.ok) { throw new Error( - `Failed to update PR status: ${response.status} ${await response.text()}`, + `Failed to update PR status: ${response.status}`, ); } } diff --git a/sources/google-calendar/src/google-api.ts b/sources/google-calendar/src/google-api.ts index 59056bb..0659113 100644 --- a/sources/google-calendar/src/google-api.ts +++ b/sources/google-calendar/src/google-api.ts @@ -1,7 +1,19 @@ -import type { NewThread } from "@plotday/twister"; -import { ThreadType, ConferencingProvider } from "@plotday/twister"; +import { ConferencingProvider } from "@plotday/twister"; import type { NewSchedule, NewScheduleOccurrence } from "@plotday/twister/schedule"; +/** + * Intermediate representation of a transformed Google Calendar event. + * Contains the fields needed to construct a `NewLinkWithNotes` in the calendar source. + */ +export type TransformedEvent = { + title: string; + meta: Record; + /** "event" for timed events, undefined for cancelled/all-day events */ + type?: string; + schedules?: Array>; + scheduleOccurrences?: NewScheduleOccurrence[]; +}; + export type GoogleEvent = { id: string; recurringEventId?: string; @@ -340,7 +352,7 @@ export function extractConferencingLinks( export function transformGoogleEvent( event: GoogleEvent, calendarId: string -): NewThread { +): TransformedEvent { // Determine if this is an all-day event const isAllDay = event.start?.date && !event.start?.dateTime; @@ -359,8 +371,7 @@ export function transformGoogleEvent( // Handle cancelled events differently const isCancelled = event.status === "cancelled"; - const shared = { - source: `google-calendar:${event.id}`, + const result: TransformedEvent = { title: event.summary || (isCancelled ? "Cancelled event" : ""), meta: { id: event.id, @@ -380,12 +391,9 @@ export function transformGoogleEvent( : null, description: event.description || null, }, - } as const; - - const thread: NewThread = - isCancelled || isAllDay - ? { type: ThreadType.Note, ...shared } - : { type: ThreadType.Event, ...shared }; + // Timed events get type "event"; cancelled/all-day events get no type + type: isCancelled || isAllDay ? undefined : "event", + }; // Build schedule from start/end if not cancelled if (!isCancelled && start) { @@ -419,17 +427,17 @@ export function transformGoogleEvent( // and create schedule occurrence entries for each const rdates = parseRDates(event.recurrence); if (rdates.length > 0) { - thread.scheduleOccurrences = rdates.map((rdate) => ({ + result.scheduleOccurrences = rdates.map((rdate) => ({ occurrence: rdate, start: rdate, })); } } - thread.schedules = [schedule]; + result.schedules = [schedule]; } - return thread; + return result; } export async function syncGoogleCalendar( diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index 489d83d..4aed5db 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -73,9 +73,7 @@ import { * }); * * await this.plot.createThread({ - * type: ThreadType.Action, * title: "Connect Google Calendar", - * actions: [authLink] * }); * } * diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index 6ecb451..2fab5f5 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -748,7 +748,7 @@ export class GoogleDrive extends Source implements DocumentSource { author: commentAuthor, created: new Date(comment.createdTime), ...(comment.assigneeEmailAddress - ? { tags: { [Tag.Now]: [{ email: comment.assigneeEmailAddress }] } } + ? { tags: { [Tag.Todo]: [{ email: comment.assigneeEmailAddress }] } } : {}), }; } diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index efe0679..322291f 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -1,10 +1,8 @@ import { Version3Client } from "jira.js"; import { - type Thread, type Action, ActionType, - ThreadType, type NewLinkWithNotes, NewContact, } from "@plotday/twister"; @@ -473,32 +471,28 @@ export class Jira extends Source implements ProjectSource { } /** - * Update issue with new values - * - * @param thread - The updated thread + * Update issue with new values from the app */ - async updateIssue(thread: Thread): Promise { - // Extract Jira issue key and project ID from meta - const issueKey = thread.meta?.issueKey as string | undefined; + async updateIssue(link: import("@plotday/twister").Link): Promise { + const issueKey = link.meta?.issueKey as string | undefined; if (!issueKey) { - throw new Error("Jira issue key not found in thread meta"); + throw new Error("Jira issue key not found in link meta"); } - const projectId = thread.meta?.projectId as string; + const projectId = link.meta?.projectId as string; const client = await this.getClient(projectId); // Handle field updates (title, assignee) const updateFields: any = {}; - if (thread.title !== null) { - updateFields.summary = thread.title; + if (link.title) { + updateFields.summary = link.title; } - updateFields.assignee = thread.assignee - ? { id: thread.assignee.id } + updateFields.assignee = link.assignee + ? { id: link.assignee.id } : null; - // Apply field updates if any if (Object.keys(updateFields).length > 0) { await client.issues.editIssue({ issueIdOrKey: issueKey, @@ -506,47 +500,42 @@ export class Jira extends Source implements ProjectSource { }); } - // Handle workflow state transitions based on assignee + done combination - // Get available transitions for this issue + // Handle workflow state transitions based on link status and assignee const transitions = await client.issues.getTransitions({ issueIdOrKey: issueKey, }); let targetTransition; - // Determine target state based on combination - if (thread.type === ThreadType.Action && thread.done !== null) { - // Completed - look for "Done", "Close", or "Resolve" transition + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed" || link.status === "resolved"; + if (isDone) { targetTransition = transitions.transitions?.find( - (t) => - t.name?.toLowerCase() === "done" || - t.name?.toLowerCase() === "close" || - t.name?.toLowerCase() === "resolve" || - t.to?.name?.toLowerCase() === "done" || - t.to?.name?.toLowerCase() === "closed" || - t.to?.name?.toLowerCase() === "resolved" + (tr) => + tr.name?.toLowerCase() === "done" || + tr.name?.toLowerCase() === "close" || + tr.name?.toLowerCase() === "resolve" || + tr.to?.name?.toLowerCase() === "done" || + tr.to?.name?.toLowerCase() === "closed" || + tr.to?.name?.toLowerCase() === "resolved" ); - } else if (thread.assignee !== null) { - // In Progress (has assignee, not done) - look for "Start Progress" or "In Progress" transition + } else if (link.assignee) { targetTransition = transitions.transitions?.find( - (t) => - t.name?.toLowerCase() === "start progress" || - t.name?.toLowerCase() === "in progress" || - t.to?.name?.toLowerCase() === "in progress" + (tr) => + tr.name?.toLowerCase() === "start progress" || + tr.name?.toLowerCase() === "in progress" || + tr.to?.name?.toLowerCase() === "in progress" ); } else { - // Backlog/Todo (no assignee, not done) - look for "To Do", "Open", or "Reopen" transition targetTransition = transitions.transitions?.find( - (t) => - t.name?.toLowerCase() === "reopen" || - t.name?.toLowerCase() === "to do" || - t.name?.toLowerCase() === "open" || - t.to?.name?.toLowerCase() === "to do" || - t.to?.name?.toLowerCase() === "open" + (tr) => + tr.name?.toLowerCase() === "reopen" || + tr.name?.toLowerCase() === "to do" || + tr.name?.toLowerCase() === "open" || + tr.to?.name?.toLowerCase() === "to do" || + tr.to?.name?.toLowerCase() === "open" ); } - // Execute transition if found if (targetTransition) { await client.issues.doTransition({ issueIdOrKey: issueKey, diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index b1d17c2..4dc5e6f 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -11,7 +11,6 @@ import { ActionType, type Link, ThreadMeta, - ThreadType, type NewLinkWithNotes, } from "@plotday/twister"; import type { @@ -479,22 +478,19 @@ export class Linear extends Source implements ProjectSource { } /** - * Update issue with new values - * - * @param thread - The updated thread + * Update issue with new values from the app */ async updateIssue( - thread: import("@plotday/twister").Thread + link: import("@plotday/twister").Link ): Promise { - // Get the Linear issue ID from thread meta - const issueId = thread.meta?.linearId as string | undefined; + const issueId = link.meta?.linearId as string | undefined; if (!issueId) { - throw new Error("Linear issue ID not found in thread meta"); + throw new Error("Linear issue ID not found in link meta"); } - const projectId = thread.meta?.projectId as string | undefined; + const projectId = link.meta?.projectId as string | undefined; if (!projectId) { - throw new Error("Project ID not found in thread meta"); + throw new Error("Project ID not found in link meta"); } const client = await this.getClient(projectId); @@ -502,37 +498,27 @@ export class Linear extends Source implements ProjectSource { const updateFields: any = {}; // Handle title - if (thread.title !== null) { - updateFields.title = thread.title; - } - - // Handle order -> sortOrder - if (thread.order !== undefined && thread.order !== null) { - updateFields.sortOrder = thread.order; + if (link.title) { + updateFields.title = link.title; } // Handle assignee - map Plot actor to Linear user via email lookup - if (!thread.assignee) { + if (!link.assignee) { updateFields.assigneeId = null; } else { - const email = thread.assignee.email; + const email = link.assignee.email; if (email) { - // Check cache first let linearUserId = await this.get(`linear_user:${email}`); - if (!linearUserId) { - // Query Linear for user by email const users = await client.users({ filter: { email: { eq: email } }, }); const linearUser = users.nodes[0]; - if (linearUser) { linearUserId = linearUser.id; await this.set(`linear_user:${email}`, linearUserId); } } - if (linearUserId) { updateFields.assigneeId = linearUserId; } else { @@ -547,28 +533,25 @@ export class Linear extends Source implements ProjectSource { } } - // Handle state based on assignee + done combination + // Handle state based on link status and assignee const team = await issue.team; if (team) { const states = await team.states(); let targetState; - // Determine target state based on combination - if (thread.type === ThreadType.Action && thread.done !== null) { - // Completed + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed" || link.status === "resolved"; + if (isDone) { targetState = states.nodes.find( (s) => s.name === "Done" || s.name === "Completed" || s.type === "completed" ); - } else if (thread.assignee !== null) { - // In Progress (has assignee, not done) + } else if (link.assignee) { targetState = states.nodes.find( (s) => s.name === "In Progress" || s.type === "started" ); } else { - // Backlog/Todo (no assignee, not done) targetState = states.nodes.find( (s) => s.name === "Todo" || s.name === "Backlog" || s.type === "unstarted" @@ -580,7 +563,6 @@ export class Linear extends Source implements ProjectSource { } } - // Apply updates if any fields changed if (Object.keys(updateFields).length > 0) { await client.updateIssue(issueId, updateFields); } diff --git a/sources/outlook-calendar/src/graph-api.ts b/sources/outlook-calendar/src/graph-api.ts index aaf4e8c..ab7659f 100644 --- a/sources/outlook-calendar/src/graph-api.ts +++ b/sources/outlook-calendar/src/graph-api.ts @@ -1,8 +1,20 @@ -import type { NewThread } from "@plotday/twister"; -import { ThreadType } from "@plotday/twister"; +import type { ThreadMeta } from "@plotday/twister"; import type { NewSchedule, NewScheduleOccurrence } from "@plotday/twister/schedule"; import type { Calendar } from "@plotday/twister/common/calendar"; +/** + * Intermediate type returned by transformOutlookEvent. + * Contains the data extracted from an Outlook event that will be + * assembled into a NewLinkWithNotes by the caller. + */ +export type TransformedOutlookEvent = { + title: string; + meta: ThreadMeta; + created?: Date; + schedules?: Array>; + scheduleOccurrences?: NewScheduleOccurrence[]; +}; + /** * Microsoft Graph API event type * https://learn.microsoft.com/en-us/graph/api/resources/event @@ -463,12 +475,13 @@ export function parseOutlookRecurrenceCount( } /** - * Transform Microsoft Graph event to Plot Thread + * Transform Microsoft Graph event into an intermediate representation. + * The caller assembles this into a NewLinkWithNotes. */ export function transformOutlookEvent( event: OutlookEvent, calendarId: string -): NewThread | null { +): TransformedOutlookEvent | null { // Skip deleted events if (event["@removed"]) { return null; @@ -489,8 +502,7 @@ export function transformOutlookEvent( // Handle cancelled events differently const isCancelled = event.isCancelled === true; - const shared = { - source: `outlook-calendar:${event.id}`, + const result: TransformedOutlookEvent = { title: isCancelled ? event.subject ? `Cancelled: ${event.subject}` @@ -505,12 +517,10 @@ export function transformOutlookEvent( originalStart: start instanceof Date ? start.toISOString() : start, originalEnd: end instanceof Date ? end.toISOString() : end, }, - } as const; - - const thread: NewThread = - isCancelled || isAllDay - ? { type: ThreadType.Note, ...shared } - : { type: ThreadType.Event, ...shared }; + created: event.createdDateTime + ? new Date(event.createdDateTime) + : undefined, + }; // Build the primary schedule from start/end if (!isCancelled && start) { @@ -542,14 +552,14 @@ export function transformOutlookEvent( } } - thread.schedules = [schedule]; + result.schedules = [schedule]; // Parse RDATEs (additional occurrence dates not in the recurrence rule) // Note: Microsoft Graph API doesn't support RDATE, so this will always be empty if (event.recurrence && event.type === "seriesMaster") { const rdates = parseOutlookRDates(event.recurrence); if (rdates.length > 0) { - thread.scheduleOccurrences = rdates.map((rdate) => ({ + result.scheduleOccurrences = rdates.map((rdate) => ({ occurrence: rdate, start: rdate, })); @@ -565,17 +575,13 @@ export function transformOutlookEvent( event.seriesMasterId && event.originalStart ) { - // This is a modified instance of a recurring event - // Store the exception info in metadata - if (thread.meta) { - thread.meta.seriesMasterId = event.seriesMasterId; - thread.meta.originalStartDate = new Date( - event.originalStart - ).toISOString(); - } + result.meta.seriesMasterId = event.seriesMasterId; + result.meta.originalStartDate = new Date( + event.originalStart + ).toISOString(); } - return thread; + return result; } /** diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index c6d4244..2ee8d15 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -1,15 +1,11 @@ import { - type Thread, type Action, ActionType, type ActorId, ConferencingProvider, type ContentType, type NewLinkWithNotes, - type NewActor, type NewContact, - type NewNote, - Tag, Source, type ToolBuilder, } from "@plotday/twister"; diff --git a/twister/src/common/projects.ts b/twister/src/common/projects.ts index ac99880..2772d40 100644 --- a/twister/src/common/projects.ts +++ b/twister/src/common/projects.ts @@ -1,5 +1,5 @@ import type { - Thread, + Link, ThreadMeta, } from "../index"; @@ -86,18 +86,15 @@ export type ProjectSource = { stopSync(projectId: string): Promise; /** - * Updates an issue/task with new values. + * Updates an issue/task in the external service based on link changes. * * Optional method for bidirectional sync. When implemented, allows Plot to - * sync thread updates back to the external service. + * sync link updates (status, assignee, title) back to the external service. * - * Auth is obtained automatically via integrations.get(provider, projectId) - * using the projectId from thread.meta. - * - * @param thread - The updated thread + * @param link - The updated link with source metadata * @returns Promise that resolves when the update is synced */ - updateIssue?(thread: Thread): Promise; + updateIssue?(link: Link): Promise; /** * Adds a comment to an issue/task. diff --git a/twister/src/common/source-control.ts b/twister/src/common/source-control.ts index 268d3d6..86b6b9a 100644 --- a/twister/src/common/source-control.ts +++ b/twister/src/common/source-control.ts @@ -1,5 +1,5 @@ import type { - Thread, + Link, ThreadMeta, } from "../index"; @@ -116,12 +116,12 @@ export type SourceControlSource = { * Updates a pull request's review status (approve, request changes). * * Optional method for bidirectional sync. When implemented, allows Plot to - * sync thread status changes back to the external service. + * sync link status changes back to the external service. * - * @param thread - The updated thread with review status + * @param link - The updated link with review status * @returns Promise that resolves when the update is synced */ - updatePRStatus?(thread: Thread): Promise; + updatePRStatus?(link: Link): Promise; /** * Closes a pull request without merging. diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 33ed35d..f0c5349 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -20,7 +20,7 @@ export { type AuthProvider } from "./tools/integrations"; * * ### Entity Types (Thread, Priority, Note, Actor) * - **Required fields**: No `?`, cannot be `undefined` - * - Example: `id: Uuid`, `type: ThreadType` + * - Example: `id: Uuid`, `title: string` * - **Nullable fields**: Use `| null` to allow explicit clearing * - Example: `assignee: ActorId | null`, `done: Date | null` * - `null` = field is explicitly unset/cleared @@ -34,7 +34,7 @@ export { type AuthProvider } from "./tools/integrations"; * ### New* Types (NewThread, NewNote, NewPriority) * Used for creating or updating entities. Support partial updates by distinguishing omitted vs cleared fields: * - **Required fields**: Must be provided (no `?`) - * - Example: `type: ThreadType` in NewThread + * - Example: `title: string` in NewPriority * - **Optional fields**: Use `?` to make them optional * - Example: `title?: string`, `author?: NewActor` * - `undefined` (omitted) = don't set/update this field @@ -54,18 +54,13 @@ export { type AuthProvider } from "./tools/integrations"; * ```typescript * // Creating a new thread * const newThread: NewThread = { - * type: ThreadType.Action, // Required - * title: "Review PR", // Optional, provided - * assignee: null, // Optional nullable, explicitly clearing - * // priority is omitted (undefined), will auto-select or use default + * title: "Review pull request", * }; * * // Updating a thread - only change what's specified * const update: ThreadUpdate = { * id: threadId, - * done: new Date(), // Mark as done - * assignee: null, // Clear assignee - * // title is omitted, won't be changed + * archived: true, * }; * ``` */ @@ -168,39 +163,6 @@ export type NewPriority = Pick & export type PriorityUpdate = ({ id: Uuid } | { key: string }) & Partial>; -/** - * Enumeration of supported thread types in Plot. - * - * Each thread type has different behaviors and rendering characteristics - * within the Plot application. - */ -export enum ThreadType { - /** A note or piece of information without actionable requirements */ - Note, - /** An actionable item that can be completed */ - Action, - /** A scheduled occurrence with start and optional end time */ - Event, -} - -/** - * Kinds of threads. Used only for visual categorization (icon). - */ -export enum ThreadKind { - document = "document", // any external document or item in an external system - messages = "messages", // emails and chat threads - meeting = "meeting", // in-person meeting - videoconference = "videoconference", - phone = "phone", - focus = "focus", - meal = "meal", - exercise = "exercise", - family = "family", - travel = "travel", - social = "social", - entertainment = "entertainment", -} - /** * Enumeration of supported action types. * @@ -342,9 +304,7 @@ export type Action = * ```typescript * // Calendar event metadata * await plot.createThread({ - * type: ThreadType.Event, * title: "Team Meeting", - * start: new Date("2024-01-15T10:00:00Z"), * meta: { * calendarId: "primary", * htmlLink: "https://calendar.google.com/event/abc123", @@ -354,7 +314,6 @@ export type Action = * * // Project issue metadata * await plot.createThread({ - * type: ThreadType.Action, * title: "Fix login bug", * meta: { * projectId: "TEAM", @@ -386,18 +345,10 @@ export type ThreadCommon = { /** Unique identifier for the thread */ id: Uuid; /** - * When this thread was originally created in its source system. - * - * For threads created in Plot, this is when the user created it. - * For threads synced from external systems (GitHub issues, emails, calendar events), - * this is the original creation time in that system. - * - * Defaults to the current time when creating new threads. + * When this thread was created. */ created: Date; - /** Information about who created the thread */ - author: Actor; - /** Whether this thread is private (only visible to author) */ + /** Whether this thread is private (only visible to creator) */ private: boolean; /** Whether this thread has been archived */ archived: boolean; @@ -408,97 +359,19 @@ export type ThreadCommon = { }; /** - * Common fields shared by all thread types (Note, Action, Event). - * Does not include the discriminant `type` field or type-specific fields like `done`. + * Fields on a Thread entity. + * Threads are simple containers for links and notes. */ type ThreadFields = ThreadCommon & { - /** - * Globally unique, stable identifier for the item in an external system. - * MUST use immutable system-generated IDs, not human-readable slugs or titles. - * - * Recommended format: `${domain}:${type}:${id}` - * - * Examples: - * - `linear:issue:549dd8bd-2bc9-43d1-95d5-4b4af0c5af1b` (Linear issue by UUID) - * - `jira:10001:issue:12345` (Jira issue by numeric ID with cloud ID) - * - `gmail:thread:18d4e5f2a3b1c9d7` (Gmail thread by system ID) - * - * ⚠️ AVOID: URLs with mutable components like team names or issue keys - * - Bad: `https://linear.app/team/issue/TEAM-123/title` (team and title can change) - * - Bad: `jira:issue:PROJECT-42` (issue key can change) - * - * When set, uniquely identifies the thread within a priority tree for upsert operations. - */ - source: string | null; /** The display title/summary of the thread */ title: string; - /** Optional kind for additional categorization within the thread */ - kind: ThreadKind | null; - /** - * The actor assigned to this thread. - * - * **For actions (tasks):** - * - If not provided (undefined), defaults to the user who installed the twist (twist owner) - * - To create an **unassigned action**, explicitly set `assignee: null` - * - For synced tasks from external systems, typically set `assignee: null` for unassigned items - * - * **For notes and events:** Assignee is optional and typically null. - * When marking a thread as done, it becomes an Action; if no assignee is set, - * the twist owner is assigned automatically. - * - * @example - * ```typescript - * // Create action assigned to twist owner (default behavior) - * const task: NewThread = { - * type: ThreadType.Action, - * title: "Follow up on email" - * // assignee omitted → defaults to twist owner - * }; - * - * // Create UNASSIGNED action (for backlog items) - * const backlogTask: NewThread = { - * type: ThreadType.Action, - * title: "Review PR #123", - * assignee: null // Explicitly set to null - * }; - * - * // Create action with explicit assignee - * const assignedTask: NewThread = { - * type: ThreadType.Action, - * title: "Deploy to production", - * assignee: { - * id: userId as ActorId, - * type: ActorType.User, - * name: "Alice" - * } - * }; - * ``` - */ - assignee: Actor | null; /** The priority context this thread belongs to */ priority: Priority; - /** Metadata about the thread, typically from an external system that created it */ - meta: ThreadMeta | null; - /** Sort order for the thread (fractional positioning) */ - order: number; - /** Array of interactive actions attached to the thread (external, conferencing, callback) */ - actions: Array | null; /** The schedule associated with this thread, if any */ schedule?: Schedule; }; -export type Thread = ThreadFields & - ( - | { type: ThreadType.Note } - | { - type: ThreadType.Action; - /** - * Timestamp when the thread was marked as complete. Null if not completed. - */ - done: Date | null; - } - | { type: ThreadType.Event } - ); +export type Thread = ThreadFields; export type ThreadWithNotes = Thread & { notes: Note[]; @@ -517,7 +390,6 @@ export type NewThreadWithNotes = NewThread & { * * Scoring rules: * - content: Uses vector similarity on thread embedding (cosine similarity) - * - type: Exact match on ThreadType * - mentions: Percentage of existing thread's mentions that appear in new thread * - meta.field: Exact match on top-level meta fields (e.g., "meta.sourceId") * @@ -529,8 +401,8 @@ export type NewThreadWithNotes = NewThread & { * // Require exact content match with strong similarity * pickPriority: { content: true } * - * // Score based on content (max 100 points) and require exact type match - * pickPriority: { content: 100, type: true } + * // Score based on content (max 100 points) and mentions + * pickPriority: { content: 100, mentions: true } * * // Match on meta and score content * pickPriority: { "meta.projectId": true, content: 50 } @@ -538,7 +410,6 @@ export type NewThreadWithNotes = NewThread & { */ export type PickPriorityConfig = { content?: number | true; - type?: number | true; mentions?: number | true; [key: `meta.${string}`]: number | true; }; @@ -546,86 +417,25 @@ export type PickPriorityConfig = { /** * Type for creating new threads. * - * Requires only the thread type, with all other fields optional. - * The author will be automatically assigned by the Plot system based on - * the current execution context. The ID can be optionally provided by - * tools for tracking and update detection purposes. - * - * **Important: Defaults for Actions** - * - * When creating a Thread of type `Action`: - * - **`assignee` omitted** → Defaults to twist owner → Assigned action - * - * To create unassigned backlog items (common for synced tasks), you MUST explicitly set: - * - `assignee: null` → Unassigned - * - * Scheduling is handled separately via the Schedule type. - * Use `plot.createSchedule()` to schedule threads. - * - * Priority can be specified in three ways: - * 1. Explicit priority: `priority: { id: "..." }` - Use specific priority (disables pickPriority) - * 2. Pick priority config: `pickPriority: { ... }` - Auto-select based on similarity - * 3. Neither: Defaults to `pickPriority: { content: true }` for automatic matching + * Threads are simple containers. All other fields are optional. * * @example * ```typescript - * // Action assigned to twist owner - * const urgentTask: NewThread = { - * type: ThreadType.Action, + * const thread: NewThread = { * title: "Review pull request" - * // assignee omitted → defaults to twist owner - * }; - * - * // UNASSIGNED backlog item (for synced tasks) - * const backlogTask: NewThread = { - * type: ThreadType.Action, - * title: "Refactor user service", - * assignee: null // Must explicitly set to null - * }; - * - * // Note - * const note: NewThread = { - * type: ThreadType.Note, - * title: "Meeting notes" - * }; - * - * // Event (schedule separately with plot.createSchedule()) - * const event: NewThread = { - * type: ThreadType.Event, - * title: "Team standup" * }; * ``` */ -export type NewThread = ( - | { type: ThreadType.Note; done?: never } - | { type: ThreadType.Action; done?: Date | null } - | { type: ThreadType.Event; done?: never } -) & - Partial< - Omit< - ThreadFields, - "author" | "assignee" | "priority" | "tags" | "mentions" | "id" | "source" - > - > & +export type NewThread = Partial< + Omit +> & ( | { - /** - * Unique identifier for the thread, generated by Uuid.Generate(). - * Specifying an ID allows tools to track and upsert threads. - */ + /** Unique identifier for the thread, generated by Uuid.Generate(). */ id: Uuid; } | { - /** - * Canonical URL for the item in an external system. - * For example, https://acme.atlassian.net/browse/PROJ-42 could represent a Jira issue. - * When set, it uniquely identifies the thread within a priority tree. This performs - * an upsert. - */ - source: string; - } - | { - /* Neither id nor source is required. An id will be generated and returned. */ + /* id is optional. An id will be generated and returned. */ } ) & ( @@ -638,16 +448,6 @@ export type NewThread = ( pickPriority?: PickPriorityConfig; } ) & { - /** - * The person that created the item. By default, it will be the twist itself. - */ - author?: NewActor; - - /** - * The person that assigned to the item. - */ - assignee?: NewActor | null; - /** * All tags to set on the new thread. */ @@ -659,9 +459,6 @@ export type NewThread = ( * as read for the author if they are the twist owner (user) * - true: Thread is explicitly unread for ALL users (use sparingly) * - false: Thread is marked as read for all users in the priority at creation time - * - * For the default behavior, omit this field entirely. - * Use false for initial sync to avoid marking historical items as unread. */ unread?: boolean; @@ -670,61 +467,27 @@ export type NewThread = ( * - true: Archive the thread * - false: Unarchive the thread * - undefined (default): Preserve current archive state - * - * Best practice: Set to false during initial syncs to ensure threads - * are unarchived. Omit during incremental syncs to preserve user's choice. */ archived?: boolean; /** * Optional preview content for the thread. Can be Markdown formatted. * The preview will be automatically generated from this content (truncated to 100 chars). - * - * - string: Use this content for preview generation - * - null: Explicitly disable preview (no preview will be shown) - * - undefined (default): Fall back to legacy behavior (generate from first note with content) - * - * This field is write-only and won't be returned when reading threads. */ preview?: string | null; /** * Optional schedules to create alongside the thread. - * - * When provided, schedules are created after the thread is inserted. - * The threadId is automatically filled from the created thread. - * - * For calendar integrations, this replaces the old start/end/recurrenceRule - * fields that were previously on the thread itself. - * - * @example - * ```typescript - * const event: NewThread = { - * type: ThreadType.Event, - * title: "Team standup", - * schedules: [{ - * start: new Date("2025-01-15T10:00:00Z"), - * end: new Date("2025-01-15T10:30:00Z"), - * recurrenceRule: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" - * }] - * }; - * ``` */ schedules?: Array>; /** - * Optional schedule occurrence overrides. For recurring schedules, - * these define per-occurrence modifications (e.g., rescheduled meetings, - * per-occurrence RSVP tags). - * - * Requires a schedule to be present (either via `schedules` field or - * an existing schedule on the thread). + * Optional schedule occurrence overrides. */ scheduleOccurrences?: NewScheduleOccurrence[]; }; export type ThreadFilter = { - type?: ActorType; meta?: { [key: string]: JSONValue; }; @@ -735,23 +498,14 @@ export type ThreadFilter = { * that can be applied uniformly across many threads are included. */ type ThreadBulkUpdateFields = Partial< - Pick -> & { - /** Update the type of all matching threads. */ - type?: ThreadType; - /** - * Timestamp when the threads were marked as complete. Null to clear. - * Setting done will automatically set the type to Action if not already. - */ - done?: Date | null; -}; + Pick +>; /** * Fields supported by single-thread updates via `id` or `source`. - * Includes all bulk fields plus assignee, tags, and preview. + * Includes all bulk fields plus tags and preview. */ -type ThreadSingleUpdateFields = ThreadBulkUpdateFields & - Partial> & { +type ThreadSingleUpdateFields = ThreadBulkUpdateFields & { /** * Tags to change on the thread. Use an empty array of NewActor to remove a tag. * Use twistTags to add/remove the twist from tags to avoid clearing other actors' tags. @@ -795,6 +549,8 @@ export type ThreadUpdate = * They are always ordered by creation time within their parent thread. */ export type Note = ThreadCommon & { + /** The author of this note */ + author: Actor; /** * Globally unique, stable identifier for the note within its thread. * Can be used to upsert without knowing the id. diff --git a/twister/src/tag.ts b/twister/src/tag.ts index e20695a..872739d 100644 --- a/twister/src/tag.ts +++ b/twister/src/tag.ts @@ -6,16 +6,12 @@ */ export enum Tag { // Special tags - Now = 1, - Later = 2, + Todo = 1, Done = 3, - Archived = 4, - Someday = 7, // Toggle tags Pinned = 100, Urgent = 101, - Inbox = 102, Goal = 103, Decision = 104, Waiting = 105, diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index 8f2d155..c8a606f 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -89,7 +89,6 @@ export type NoteIntentHandler = { * async activate(priority) { * // Create a welcome thread * await this.plot.createThread({ - * type: ThreadType.Note, * title: "Welcome to Plot!", * actions: [{ * title: "Get Started", @@ -497,7 +496,6 @@ export abstract class Plot extends ITool { * ```typescript * // Schedule a timed event * const threadId = await this.plot.createThread({ - * type: ThreadType.Event, * title: "Team standup" * }); * await this.plot.createSchedule({ diff --git a/twister/src/twist.ts b/twister/src/twist.ts index 1d580eb..a33759d 100644 --- a/twister/src/twist.ts +++ b/twister/src/twist.ts @@ -24,8 +24,7 @@ import type { InferTools, ToolBuilder, ToolShed } from "./utils/types"; * async activate(priority: Pick) { * // Initialize twist for the given priority * await this.tools.plot.createThread({ - * type: ThreadType.Note, - * note: "Hello, good looking!", + * title: "Hello, good looking!", * }); * } * } diff --git a/twists/chat/src/index.ts b/twists/chat/src/index.ts index 5eefdd6..d56de6d 100644 --- a/twists/chat/src/index.ts +++ b/twists/chat/src/index.ts @@ -152,7 +152,7 @@ You can provide either or both inline and standalone links. Only use standalone thread, content: task, tags: { - [Tag.Now]: [{ id: note.author.id }], + [Tag.Todo]: [{ id: note.author.id }], }, }) ) ?? []), diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index 7cf30c1..1cc16d8 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -4,7 +4,6 @@ import { Gmail } from "@plotday/source-gmail"; import { Slack } from "@plotday/source-slack"; import { type ThreadFilter, - ThreadType, type NewThreadWithNotes, type NewContact, type Note, @@ -92,12 +91,14 @@ export default class MessageTasksTwist extends Twist { } async onSlackThread(thread: NewThreadWithNotes): Promise { - const channelId = thread.meta?.channelId as string; + // TODO: meta was removed from threads; channelId may still be present at runtime from sources + const channelId = (thread as any).meta?.channelId as string; return this.onMessageThread(thread, "slack", channelId); } async onGmailThread(thread: NewThreadWithNotes): Promise { - const channelId = thread.meta?.channelId as string; + // TODO: meta was removed from threads; channelId may still be present at runtime from sources + const channelId = (thread as any).meta?.channelId as string; return this.onMessageThread(thread, "gmail", channelId); } @@ -311,7 +312,8 @@ export default class MessageTasksTwist extends Twist { ): Promise { if (!thread.notes || thread.notes.length === 0) return; - const threadId = "source" in thread ? thread.source : undefined; + // TODO: source was removed from threads; may still be present at runtime from sources + const threadId = (thread as any).source as string | undefined; if (!threadId) { console.warn("Thread has no source, skipping"); return; @@ -480,7 +482,8 @@ If a task is needed, create a clear, actionable title that describes what the us provider: MessageProvider, channelId: string ): Promise { - const threadId = "source" in thread ? thread.source : undefined; + // TODO: source was removed from threads; may still be present at runtime from sources + const threadId = (thread as any).source as string | undefined; if (!threadId) { console.warn("Thread has no source, skipping task creation"); return; @@ -488,10 +491,8 @@ If a task is needed, create a clear, actionable title that describes what the us const sourceRef = this.formatSourceReference(thread, provider, channelId); - // Create task thread - database handles upsert automatically + // Create task thread const taskId = await this.tools.plot.createThread({ - source: `message-tasks:${threadId}`, - type: ThreadType.Action, title: analysis.taskTitle || thread.title || "Action needed from message", notes: analysis.taskNote ? [ @@ -507,10 +508,6 @@ If a task is needed, create a clear, actionable title that describes what the us preview: analysis.taskNote ? `${analysis.taskNote}\n\n---\n${sourceRef}` : sourceRef, - meta: { - originalThreadId: threadId, - channelId, - }, // Use pickPriority for automatic priority matching pickPriority: { content: 50, mentions: 50 }, }); @@ -573,7 +570,7 @@ Return true only if there's clear evidence the task is done.`, if (result.isCompleted && result.confidence >= 0.7) { await this.tools.plot.updateThread({ id: taskInfo.taskId, - done: new Date(), + archived: true, }); } } catch (error) { From b6e6f5086274ca6fde5d959c1ef989c59efeae78 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Fri, 27 Feb 2026 09:10:11 -0500 Subject: [PATCH 14/25] Add lint to sources --- sources/asana/package.json | 3 ++- sources/github-issues/package.json | 3 ++- sources/github/package.json | 3 ++- sources/gmail/package.json | 3 ++- sources/google-calendar/package.json | 3 ++- sources/google-contacts/package.json | 3 ++- sources/google-drive/package.json | 3 ++- sources/jira/package.json | 3 ++- sources/linear/package.json | 3 ++- sources/outlook-calendar/package.json | 3 ++- sources/slack/package.json | 3 ++- 11 files changed, 22 insertions(+), 11 deletions(-) diff --git a/sources/asana/package.json b/sources/asana/package.json index f520de7..9475281 100644 --- a/sources/asana/package.json +++ b/sources/asana/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/github-issues/package.json b/sources/github-issues/package.json index db331ad..b2492db 100644 --- a/sources/github-issues/package.json +++ b/sources/github-issues/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/github/package.json b/sources/github/package.json index 6510b7f..e32cb5a 100644 --- a/sources/github/package.json +++ b/sources/github/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/gmail/package.json b/sources/gmail/package.json index 7828d58..05727ef 100644 --- a/sources/gmail/package.json +++ b/sources/gmail/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/google-calendar/package.json b/sources/google-calendar/package.json index 6e32a22..dbdd783 100644 --- a/sources/google-calendar/package.json +++ b/sources/google-calendar/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/source-google-contacts": "workspace:^", diff --git a/sources/google-contacts/package.json b/sources/google-contacts/package.json index e3d4b5f..c20ad9c 100644 --- a/sources/google-contacts/package.json +++ b/sources/google-contacts/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/google-drive/package.json b/sources/google-drive/package.json index ecdab77..9901260 100644 --- a/sources/google-drive/package.json +++ b/sources/google-drive/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/source-google-contacts": "workspace:^", diff --git a/sources/jira/package.json b/sources/jira/package.json index d167f2f..4fe4968 100644 --- a/sources/jira/package.json +++ b/sources/jira/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/linear/package.json b/sources/linear/package.json index 58a0ba7..87e6bf8 100644 --- a/sources/linear/package.json +++ b/sources/linear/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^", diff --git a/sources/outlook-calendar/package.json b/sources/outlook-calendar/package.json index 4de7dab..faa0d60 100644 --- a/sources/outlook-calendar/package.json +++ b/sources/outlook-calendar/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" diff --git a/sources/slack/package.json b/sources/slack/package.json index 6a2be87..8dbd228 100644 --- a/sources/slack/package.json +++ b/sources/slack/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "rm -rf dist", - "deploy": "plot deploy" + "deploy": "plot deploy", + "lint": "plot lint" }, "dependencies": { "@plotday/twister": "workspace:^" From c50958f8165bdbc61c509b3a1b08f111bf0a255f Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Fri, 27 Feb 2026 10:46:09 -0500 Subject: [PATCH 15/25] Add channelId to Link type and all sources for account-based priority routing Sources now set channelId on link objects (not just in meta) so the API can resolve the target priority via source_channel table. Also adds priorityId to Channel type in integrations for channel-to-priority mapping. Co-Authored-By: Claude Opus 4.6 --- sources/asana/src/asana.ts | 3 +++ sources/github-issues/src/github-issues.ts | 3 +++ sources/github/src/github.ts | 4 ++++ sources/gmail/src/gmail.ts | 3 ++- sources/google-calendar/src/google-calendar.ts | 4 ++++ sources/google-drive/src/google-drive.ts | 1 + sources/jira/src/jira.ts | 1 + sources/linear/src/linear.ts | 3 +++ sources/outlook-calendar/src/outlook-calendar.ts | 4 ++++ sources/slack/src/slack.ts | 1 + twister/src/plot.ts | 2 ++ twister/src/tools/integrations.ts | 12 +++++++++++- 12 files changed, 39 insertions(+), 2 deletions(-) diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index dfe2ce4..b0cd1e3 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -386,6 +386,7 @@ export class Asana extends Source implements ProjectSource { type: "task", title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, + channelId: projectId, meta: { taskGid: task.gid, projectId, @@ -622,6 +623,7 @@ export class Asana extends Source implements ProjectSource { type: "task", title: task.name, created: task.created_at ? new Date(task.created_at) : undefined, + channelId: projectId, meta: { taskGid: task.gid, projectId, @@ -704,6 +706,7 @@ export class Asana extends Source implements ProjectSource { author: storyAuthor, } as any, ], + channelId: projectId, meta: { taskGid, projectId, diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index 750e000..98b54d6 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -322,6 +322,7 @@ export class GitHubIssues extends Source implements ProjectSource ); if (link) { + link.channelId = repoId; link.meta = { ...link.meta, syncProvider: "github-issues", @@ -693,6 +694,7 @@ export class GitHubIssues extends Source implements ProjectSource author: authorContact, assignee: assigneeContact ?? null, status: issue.closed_at ? "closed" : "open", + channelId: repoId, meta: { githubIssueNumber: issue.number, githubRepoId: repoId, @@ -750,6 +752,7 @@ export class GitHubIssues extends Source implements ProjectSource author: commentAuthor, } as any, ], + channelId: repoId, meta: { githubIssueNumber: issue.number, githubRepoId: repoId, diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index a4ec5b0..5402a99 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -494,6 +494,7 @@ export class GitHub extends Source implements SourceControlSource { ? "closed" : "open", ...(pr.state === "closed" && !pr.merged_at ? { archived: true } : {}), + channelId: repositoryId, meta: { provider: "github", owner, @@ -544,6 +545,7 @@ export class GitHub extends Source implements SourceControlSource { author: reviewAuthor, } as any, ], + channelId: repositoryId, meta: { provider: "github", owner, @@ -585,6 +587,7 @@ export class GitHub extends Source implements SourceControlSource { author: commentAuthor, } as any, ], + channelId: repositoryId, meta: { provider: "github", owner, @@ -674,6 +677,7 @@ export class GitHub extends Source implements SourceControlSource { ); if (thread) { + thread.channelId = repositoryId; thread.meta = { ...thread.meta, syncProvider: "github", diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index a568cf9..616262e 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -280,7 +280,8 @@ export class Gmail extends Source implements MessagingSource { if (!activityThread.notes || activityThread.notes.length === 0) continue; - // Inject sync metadata for the parent to identify the source + // Inject channel ID for priority routing and sync metadata + activityThread.channelId = channelId; activityThread.meta = { ...activityThread.meta, syncProvider: "google", diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index 4aed5db..8ece932 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -696,6 +696,7 @@ export class GoogleCalendar }; // Inject sync metadata for the parent to identify the source + link.channelId = calendarId; link.meta = { ...link.meta, syncProvider: "google", syncableId: calendarId }; // Send link - database handles upsert automatically @@ -801,6 +802,7 @@ export class GoogleCalendar }; // Inject sync metadata for the parent to identify the source + link.channelId = calendarId; link.meta = { ...link.meta, syncProvider: "google", syncableId: calendarId }; // Send link - database handles upsert automatically @@ -867,6 +869,7 @@ export class GoogleCalendar type: "event", title: "", source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "google", syncableId: calendarId }, scheduleOccurrences: [cancelledOccurrence], notes: [], @@ -921,6 +924,7 @@ export class GoogleCalendar type: "event", title: "", source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "google", syncableId: calendarId }, scheduleOccurrences: [occurrence], notes: [], diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index 2fab5f5..a6fa94f 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -705,6 +705,7 @@ export class GoogleDrive extends Source implements DocumentSource { author, sourceUrl: file.webViewLink ?? null, actions: actions.length > 0 ? actions : null, + channelId: folderId, meta: { fileId: file.id, folderId, diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index 322291f..68e9161 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -272,6 +272,7 @@ export class Jira extends Source implements ProjectSource { // Set unread based on sync type (false for initial sync to avoid notification overload) linkWithNotes.unread = !state.initialSync; // Inject sync metadata for filtering on disable + linkWithNotes.channelId = projectId; linkWithNotes.meta = { ...linkWithNotes.meta, syncProvider: "atlassian", syncableId: projectId }; await this.tools.integrations.saveLink(linkWithNotes); } diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index 4dc5e6f..e385360 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -312,6 +312,7 @@ export class Linear extends Source implements ProjectSource { if (link) { // Inject sync metadata for bulk operations (e.g. disable filtering) + link.channelId = projectId; link.meta = { ...link.meta, syncProvider: "linear", @@ -708,6 +709,7 @@ export class Linear extends Source implements ProjectSource { author: authorContact, assignee: assigneeContact ?? null, status: issue.completedAt || issue.canceledAt ? "done" : "open", + channelId: projectId, meta: { linearId: issue.id, projectId, @@ -767,6 +769,7 @@ export class Linear extends Source implements ProjectSource { author: commentAuthor, } as any, ], + channelId: projectId, meta: { linearId: issueId, projectId, diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index 2ee8d15..49323ec 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -428,6 +428,7 @@ export class OutlookCalendar : new Date(), preview: "Cancelled", source, + channelId: calendarId, meta: { syncProvider: "microsoft", syncableId: calendarId }, notes: [cancelNote], ...(initialSync ? { unread: false } : {}), // false for initial sync, omit for incremental updates @@ -551,6 +552,7 @@ export class OutlookCalendar title: threadData.title || "", created: threadData.created, author: authorContact, + channelId: calendarId, meta: { ...threadData.meta, syncProvider: "microsoft", @@ -618,6 +620,7 @@ export class OutlookCalendar type: "event", title: "", source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "microsoft", syncableId: calendarId }, scheduleOccurrences: [cancelledOccurrence], notes: [], @@ -674,6 +677,7 @@ export class OutlookCalendar type: "event", title: "", source: masterCanonicalUrl, + channelId: calendarId, meta: { syncProvider: "microsoft", syncableId: calendarId }, scheduleOccurrences: [occurrence], notes: [], diff --git a/sources/slack/src/slack.ts b/sources/slack/src/slack.ts index 1e3aa14..98d5b8f 100644 --- a/sources/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -272,6 +272,7 @@ export class Slack extends Source implements MessagingSource { if (!activityThread.notes || activityThread.notes.length === 0) continue; // Inject sync metadata for the parent to identify the source + activityThread.channelId = channelId; activityThread.meta = { ...activityThread.meta, syncProvider: "slack", diff --git a/twister/src/plot.ts b/twister/src/plot.ts index f0c5349..2fee293 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -827,6 +827,8 @@ export type Link = { meta: ThreadMeta | null; /** URL to open the original item in its source application (e.g., "Open in Linear") */ sourceUrl: string | null; + /** Channel ID that produced this link (matches source_channel.channel_id) */ + channelId: string | null; }; /** diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index e9b9844..ff55365 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -22,6 +22,8 @@ export type Channel = { title: string; /** Optional nested channel resources (e.g., subfolders) */ children?: Channel[]; + /** Priority ID this channel is routed to (set when channel is enabled) */ + priorityId?: string; }; /** @@ -139,10 +141,18 @@ export abstract class Integrations extends ITool { * Returns the token of the user who enabled sync on the given channel. * If the channel is not enabled or the token is expired/invalid, returns null. * - * @param provider - The OAuth provider * @param channelId - The channel resource ID (e.g., calendar ID) * @returns Promise resolving to the access token or null */ + abstract get(channelId: string): Promise; + /** + * Retrieves an access token for a channel resource. + * + * @param provider - The OAuth provider (deprecated, ignored for single-provider sources) + * @param channelId - The channel resource ID (e.g., calendar ID) + * @returns Promise resolving to the access token or null + * @deprecated Use get(channelId) instead. The provider is implicit from the source. + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract get(provider: AuthProvider, channelId: string): Promise; From 4eecad3368cb4f3d37887a2772270b2cdc3abadf Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Fri, 27 Feb 2026 11:01:00 -0500 Subject: [PATCH 16/25] Remove unnecessary common interfaces for sources --- AGENTS.md | 11 -- sources/AGENTS.md | 24 +--- sources/asana/src/asana.ts | 18 ++- sources/github-issues/src/github-issues.ts | 18 ++- sources/github/src/github.ts | 21 ++- sources/gmail/src/gmail.ts | 18 ++- .../google-calendar/src/google-calendar.ts | 22 +-- sources/google-drive/src/google-drive.ts | 18 ++- sources/jira/src/jira.ts | 18 ++- sources/linear/src/linear.ts | 18 ++- sources/outlook-calendar/src/graph-api.ts | 7 +- .../outlook-calendar/src/outlook-calendar.ts | 22 +-- sources/slack/src/slack.ts | 18 ++- twister/package.json | 50 ------- twister/src/common/calendar.ts | 126 ---------------- twister/src/common/documents.ts | 135 ------------------ twister/src/common/messaging.ts | 82 ----------- twister/src/common/projects.ts | 118 --------------- twister/src/common/source-control.ts | 135 ------------------ twister/typedoc.json | 1 - 20 files changed, 133 insertions(+), 747 deletions(-) delete mode 100644 twister/src/common/calendar.ts delete mode 100644 twister/src/common/documents.ts delete mode 100644 twister/src/common/messaging.ts delete mode 100644 twister/src/common/projects.ts delete mode 100644 twister/src/common/source-control.ts diff --git a/AGENTS.md b/AGENTS.md index 882e9b8..30071c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,15 +10,6 @@ Sources are packages that connect to external services (Linear, Slack, Google Ca **Start here:** `sources/AGENTS.md` — Complete source development guide with scaffold, patterns, and checklist. -**Choose your interface:** - -| Interface | For | Import | -|-----------|-----|--------| -| `CalendarSource` | Calendar/scheduling | `@plotday/twister/common/calendar` | -| `ProjectSource` | Project/task management | `@plotday/twister/common/projects` | -| `MessagingSource` | Email and chat | `@plotday/twister/common/messaging` | -| `DocumentSource` | Document/file storage | `@plotday/twister/common/documents` | - ### Building a Twist (orchestrator) Twists are the entry point that users install. They declare which tools to use and implement domain logic. @@ -34,8 +25,6 @@ All types in `twister/src/` with full JSDoc: - **Twist base**: `twister/src/twist.ts` - **Built-in tools**: `twister/src/tools/*.ts` - `callbacks.ts`, `store.ts`, `tasks.ts`, `plot.ts`, `ai.ts`, `network.ts`, `integrations.ts`, `twists.ts` -- **Common interfaces**: `twister/src/common/*.ts` - - `calendar.ts`, `messaging.ts`, `projects.ts`, `documents.ts` - **Core types**: `twister/src/plot.ts`, `twister/src/tag.ts` ## Additional Resources diff --git a/sources/AGENTS.md b/sources/AGENTS.md index f5e18bd..9362f2c 100644 --- a/sources/AGENTS.md +++ b/sources/AGENTS.md @@ -114,12 +114,6 @@ import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { ContactAccess, Plot } from "@plotday/twister/tools/plot"; import { Tasks } from "@plotday/twister/tools/tasks"; -// Choose the correct common interface for your source category: -// import type { CalendarSource, SyncOptions } from "@plotday/twister/common/calendar"; -// import type { ProjectSource, ProjectSyncOptions } from "@plotday/twister/common/projects"; -// import type { MessagingSource, MessageSyncOptions } from "@plotday/twister/common/messaging"; -// import type { DocumentSource, DocumentSyncOptions } from "@plotday/twister/common/documents"; - type SyncState = { cursor: string | null; batchNumber: number; @@ -127,7 +121,7 @@ type SyncState = { initialSync: boolean; }; -export class MySource extends Source implements ProjectSource { +export class MySource extends Source { // 1. Static constants static readonly PROVIDER = AuthProvider.Linear; // Use appropriate provider static readonly SCOPES = ["read", "write"]; @@ -379,20 +373,6 @@ export class MySource extends Source implements ProjectSource { export default MySource; ``` -## Common Source Interfaces - -Choose the correct interface based on what your service provides. Import from `@plotday/twister/common/*`. - -| Interface | For | Examples | Key resource | -|-----------|-----|----------|-------------| -| `CalendarSource` | Calendar/scheduling services | Google Calendar, Outlook, Apple Calendar | Calendars with events | -| `ProjectSource` | Project/task management | Linear, Jira, Asana, GitHub Issues, Todoist, ClickUp, Trello, Monday | Projects with issues/tasks | -| `MessagingSource` | Email and chat services | Gmail, Slack, Discord, Microsoft Teams, Intercom | Channels/inboxes with threads | -| `DocumentSource` | Document/file services | Google Drive, Notion, Dropbox, OneDrive, Confluence | Folders with documents | -| None | Services that don't fit above | CRM, analytics, monitoring | Define your own interface | - -Each interface requires these methods: `get[Resources]()`, `startSync()`, `stopSync()`. Some have optional methods for bidirectional sync (`updateIssue`, `addIssueComment`, `addDocumentComment`, etc.). - ## The Integrations Pattern (Auth + Channels) **This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Sources declare their provider config in `build()`. @@ -780,7 +760,7 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove ## Source Development Checklist -- [ ] Extend `Source` and implement the correct common interface +- [ ] Extend `Source` - [ ] Declare `static readonly PROVIDER`, `static readonly SCOPES` - [ ] Declare `static readonly Options: SyncToolOptions` and `declare readonly Options: SyncToolOptions` - [ ] Declare all dependencies in `build()`: Integrations, Network, Callbacks, Tasks, Plot diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index b0cd1e3..5fbd277 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -6,11 +6,6 @@ import { ThreadMeta, type NewLinkWithNotes, } from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectSource, -} from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; import { Source } from "@plotday/twister/source"; import type { ToolBuilder } from "@plotday/twister/tool"; @@ -24,6 +19,17 @@ import { import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Project = { + id: string; + name: string; + description: string | null; + key: string | null; +}; + +type ProjectSyncOptions = { + timeMin?: Date; +}; + type SyncState = { offset: number; batchNumber: number; @@ -37,7 +43,7 @@ type SyncState = { * Implements the ProjectSource interface for syncing Asana projects and tasks * with Plot threads. */ -export class Asana extends Source implements ProjectSource { +export class Asana extends Source { static readonly PROVIDER = AuthProvider.Asana; static readonly SCOPES = ["default"]; diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index 98b54d6..ec8b138 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -6,11 +6,6 @@ import { type ThreadMeta, type NewLinkWithNotes, } from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectSource, -} from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; import { Source } from "@plotday/twister/source"; import type { ToolBuilder } from "@plotday/twister/tool"; @@ -24,6 +19,17 @@ import { import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Project = { + id: string; + name: string; + description: string | null; + key: string | null; +}; + +type ProjectSyncOptions = { + timeMin?: Date; +}; + type SyncState = { page: number; batchNumber: number; @@ -44,7 +50,7 @@ type RepoInfo = { * Implements the ProjectSource interface for syncing GitHub Issues * with Plot threads. Explicitly filters out pull requests. */ -export class GitHubIssues extends Source implements ProjectSource { +export class GitHubIssues extends Source { static readonly PROVIDER = AuthProvider.GitHub; static readonly SCOPES = ["repo"]; diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index 5402a99..1328776 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -6,11 +6,6 @@ import { Source, type ToolBuilder, } from "@plotday/twister"; -import type { - Repository, - SourceControlSyncOptions, - SourceControlSource, -} from "@plotday/twister/common/source-control"; import type { NewContact } from "@plotday/twister/plot"; import { AuthProvider, @@ -22,6 +17,20 @@ import { import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Repository = { + id: string; + name: string; + description: string | null; + url: string | null; + owner: string | null; + defaultBranch: string | null; + private: boolean; +}; + +type SourceControlSyncOptions = { + timeMin?: Date; +}; + type SyncState = { page: number; batchNumber: number; @@ -89,7 +98,7 @@ type GitHubRepo = { * Implements the SourceControlSource interface for syncing GitHub repositories * and pull requests with Plot threads. */ -export class GitHub extends Source implements SourceControlSource { +export class GitHub extends Source { static readonly PROVIDER = AuthProvider.GitHub; static readonly SCOPES = ["repo"]; diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index 616262e..45cb54e 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -2,11 +2,6 @@ import { Source, type ToolBuilder, } from "@plotday/twister"; -import { - type MessageChannel, - type MessageSyncOptions, - type MessagingSource, -} from "@plotday/twister/common/messaging"; import { AuthProvider, type AuthToken, @@ -16,6 +11,17 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +type MessageChannel = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type MessageSyncOptions = { + timeMin?: Date; +}; + import { GmailApi, type GmailThread, @@ -35,7 +41,7 @@ import { * - `https://www.googleapis.com/auth/gmail.readonly` - Read emails * - `https://www.googleapis.com/auth/gmail.modify` - Modify labels */ -export class Gmail extends Source implements MessagingSource { +export class Gmail extends Source { static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index 8ece932..0b819ff 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -13,11 +13,6 @@ import type { NewScheduleContact, NewScheduleOccurrence, } from "@plotday/twister/schedule"; -import { - type Calendar, - type CalendarSource, - type SyncOptions, -} from "@plotday/twister/common/calendar"; import { AuthProvider, type AuthToken, @@ -27,6 +22,18 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +type Calendar = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type SyncOptions = { + timeMin?: Date | null; + timeMax?: Date | null; +}; + import { GoogleApi, type GoogleEvent, @@ -103,10 +110,7 @@ import { * } * ``` */ -export class GoogleCalendar - extends Source - implements CalendarSource -{ +export class GoogleCalendar extends Source { static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = [ "https://www.googleapis.com/auth/calendar.calendarlist.readonly", diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index a6fa94f..3b18e9e 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -9,11 +9,6 @@ import { type ToolBuilder, Tag, } from "@plotday/twister"; -import { - type DocumentFolder, - type DocumentSyncOptions, - type DocumentSource, -} from "@plotday/twister/common/documents"; import { AuthProvider, type AuthToken, @@ -23,6 +18,17 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +type DocumentFolder = { + id: string; + name: string; + description: string | null; + root: boolean; +}; + +type DocumentSyncOptions = { + timeMin?: Date; +}; + import { GoogleApi, type GoogleDriveComment, @@ -55,7 +61,7 @@ import { * **Required OAuth Scopes:** * - `https://www.googleapis.com/auth/drive` - Read/write files, folders, comments */ -export class GoogleDrive extends Source implements DocumentSource { +export class GoogleDrive extends Source { static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = ["https://www.googleapis.com/auth/drive"]; diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index 68e9161..c7f9fa0 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -6,11 +6,6 @@ import { type NewLinkWithNotes, NewContact, } from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectSource, -} from "@plotday/twister/common/projects"; import { Source } from "@plotday/twister/source"; import type { ToolBuilder } from "@plotday/twister/tool"; import { @@ -23,6 +18,17 @@ import { import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Project = { + id: string; + name: string; + description: string | null; + key: string | null; +}; + +type ProjectSyncOptions = { + timeMin?: Date; +}; + type SyncState = { startAt: number; batchNumber: number; @@ -36,7 +42,7 @@ type SyncState = { * Implements the ProjectSource interface for syncing Jira projects and issues * with Plot threads. */ -export class Jira extends Source implements ProjectSource { +export class Jira extends Source { static readonly PROVIDER = AuthProvider.Atlassian; static readonly SCOPES = ["read:jira-work", "write:jira-work", "read:jira-user"]; diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index e385360..c16ade0 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -13,11 +13,6 @@ import { ThreadMeta, type NewLinkWithNotes, } from "@plotday/twister"; -import type { - Project, - ProjectSyncOptions, - ProjectSource, -} from "@plotday/twister/common/projects"; import type { NewContact } from "@plotday/twister/plot"; import { Source } from "@plotday/twister/source"; import type { ToolBuilder } from "@plotday/twister/tool"; @@ -31,6 +26,17 @@ import { import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { Tasks } from "@plotday/twister/tools/tasks"; +type Project = { + id: string; + name: string; + description: string | null; + key: string | null; +}; + +type ProjectSyncOptions = { + timeMin?: Date; +}; + // Cloudflare Workers provides Buffer global declare const Buffer: { from( @@ -52,7 +58,7 @@ type SyncState = { * Implements the ProjectSource interface for syncing Linear teams and issues * with Plot threads. */ -export class Linear extends Source implements ProjectSource { +export class Linear extends Source { static readonly PROVIDER = AuthProvider.Linear; static readonly SCOPES = ["read", "write", "admin"]; diff --git a/sources/outlook-calendar/src/graph-api.ts b/sources/outlook-calendar/src/graph-api.ts index ab7659f..f1598bc 100644 --- a/sources/outlook-calendar/src/graph-api.ts +++ b/sources/outlook-calendar/src/graph-api.ts @@ -1,6 +1,11 @@ import type { ThreadMeta } from "@plotday/twister"; import type { NewSchedule, NewScheduleOccurrence } from "@plotday/twister/schedule"; -import type { Calendar } from "@plotday/twister/common/calendar"; +type Calendar = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; /** * Intermediate type returned by transformOutlookEvent. diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index 49323ec..e54ca4f 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -13,11 +13,6 @@ import type { NewScheduleContact, NewScheduleOccurrence, } from "@plotday/twister/schedule"; -import type { - Calendar, - CalendarSource, - SyncOptions, -} from "@plotday/twister/common/calendar"; import { AuthProvider, type AuthToken, @@ -27,6 +22,18 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +type Calendar = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type SyncOptions = { + timeMin?: Date | null; + timeMax?: Date | null; +}; + import { GraphApi, type SyncState, @@ -99,10 +106,7 @@ type WatchState = { * } * ``` */ -export class OutlookCalendar - extends Source - implements CalendarSource -{ +export class OutlookCalendar extends Source { static readonly PROVIDER = AuthProvider.Microsoft; static readonly SCOPES = ["https://graph.microsoft.com/calendars.readwrite"]; diff --git a/sources/slack/src/slack.ts b/sources/slack/src/slack.ts index 98d5b8f..0f15d96 100644 --- a/sources/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -2,11 +2,6 @@ import { Source, type ToolBuilder, } from "@plotday/twister"; -import { - type MessageChannel, - type MessageSyncOptions, - type MessagingSource, -} from "@plotday/twister/common/messaging"; import { AuthProvider, type AuthToken, @@ -16,6 +11,17 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +type MessageChannel = { + id: string; + name: string; + description: string | null; + primary: boolean; +}; + +type MessageSyncOptions = { + timeMin?: Date; +}; + import { SlackApi, type SlackChannel, @@ -52,7 +58,7 @@ import { * - `im:history` - Read direct messages with the bot * - `mpim:history` - Read group direct messages */ -export class Slack extends Source implements MessagingSource { +export class Slack extends Source { static readonly PROVIDER = AuthProvider.Slack; static readonly SCOPES = [ "channels:history", diff --git a/twister/package.json b/twister/package.json index 09ab7ab..a60b8e1 100644 --- a/twister/package.json +++ b/twister/package.json @@ -105,31 +105,6 @@ "types": "./dist/utils/uuid.d.ts", "default": "./dist/utils/uuid.js" }, - "./common/calendar": { - "@plotday/source": "./src/common/calendar.ts", - "types": "./dist/common/calendar.d.ts", - "default": "./dist/common/calendar.js" - }, - "./common/messaging": { - "@plotday/source": "./src/common/messaging.ts", - "types": "./dist/common/messaging.d.ts", - "default": "./dist/common/messaging.js" - }, - "./common/projects": { - "@plotday/source": "./src/common/projects.ts", - "types": "./dist/common/projects.d.ts", - "default": "./dist/common/projects.js" - }, - "./common/documents": { - "@plotday/source": "./src/common/documents.ts", - "types": "./dist/common/documents.d.ts", - "default": "./dist/common/documents.js" - }, - "./common/source-control": { - "@plotday/source": "./src/common/source-control.ts", - "types": "./dist/common/source-control.d.ts", - "default": "./dist/common/source-control.js" - }, "./twist-guide": { "@plotday/source": "./src/twist-guide.ts", "types": "./dist/twist-guide.d.ts", @@ -205,31 +180,6 @@ "types": "./dist/llm-docs/tools/store.d.ts", "default": "./dist/llm-docs/tools/store.js" }, - "./llm-docs/common/calendar": { - "@plotday/source": "./src/llm-docs/common/calendar.ts", - "types": "./dist/llm-docs/common/calendar.d.ts", - "default": "./dist/llm-docs/common/calendar.js" - }, - "./llm-docs/common/messaging": { - "@plotday/source": "./src/llm-docs/common/messaging.ts", - "types": "./dist/llm-docs/common/messaging.d.ts", - "default": "./dist/llm-docs/common/messaging.js" - }, - "./llm-docs/common/projects": { - "@plotday/source": "./src/llm-docs/common/projects.ts", - "types": "./dist/llm-docs/common/projects.d.ts", - "default": "./dist/llm-docs/common/projects.js" - }, - "./llm-docs/common/documents": { - "@plotday/source": "./src/llm-docs/common/documents.ts", - "types": "./dist/llm-docs/common/documents.d.ts", - "default": "./dist/llm-docs/common/documents.js" - }, - "./llm-docs/common/source-control": { - "@plotday/source": "./src/llm-docs/common/source-control.ts", - "types": "./dist/llm-docs/common/source-control.d.ts", - "default": "./dist/llm-docs/common/source-control.js" - }, "./tsconfig.base.json": "./tsconfig.base.json" }, "files": [ diff --git a/twister/src/common/calendar.ts b/twister/src/common/calendar.ts deleted file mode 100644 index 0d25445..0000000 --- a/twister/src/common/calendar.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Represents a calendar from an external calendar service. - * - * Contains metadata about a specific calendar that can be synced - * with Plot. Different calendar providers may have additional - * provider-specific properties. - */ -export type Calendar = { - /** Unique identifier for the calendar within the provider */ - id: string; - /** Human-readable name of the calendar */ - name: string; - /** Optional description or additional details about the calendar */ - description: string | null; - /** Whether this is the user's primary/default calendar */ - primary: boolean; -}; - -/** - * Configuration options for calendar synchronization. - * - * Controls the time range and other parameters for calendar sync operations. - * Used to limit sync scope and optimize performance. - */ -export type SyncOptions = { - /** - * Earliest date to sync events from (inclusive). - * - If undefined: defaults to 2 years in the past - * - If null: syncs all history from the beginning of time - * - If Date: syncs from the specified date - */ - timeMin?: Date | null; - /** - * Latest date to sync events to (exclusive). - * - If undefined: no limit (syncs all future events) - * - If null: no limit (syncs all future events) - * - If Date: syncs up to but not including the specified date - * - * Use cases: - * - Daily digest: Set to end of today - * - Week view: Set to end of current week - * - Performance: Limit initial sync range - */ - timeMax?: Date | null; -}; - -/** - * Base interface for calendar integration sources. - * - * Defines the standard operations that all calendar sources must implement - * to integrate with external calendar services like Google Calendar, - * Outlook Calendar, etc. - * - * Sources save threads directly via `integrations.saveThread()` rather than - * passing data through callbacks to a separate twist. - * - * **Implementation Pattern:** - * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Source declares providers and lifecycle callbacks in build() - * 3. getChannels returns available calendars - * 4. User enables calendars in the modal -> onChannelEnabled fires - * 5. Source fetches events and saves them directly via integrations.saveThread() - * - * **Recommended Data Sync Strategy:** - * Use Thread.source and Note.key for automatic upserts without manual ID tracking. - * See SYNC_STRATEGIES.md for detailed patterns and when to use alternative approaches. - * - * @example - * ```typescript - * class MyCalendarSource extends Source { - * build(build: ToolBuilder) { - * return { - * integrations: build(Integrations, { - * providers: [{ - * provider: AuthProvider.Google, - * scopes: MyCalendarSource.SCOPES, - * getChannels: this.getChannels, - * onChannelEnabled: this.onChannelEnabled, - * onChannelDisabled: this.onChannelDisabled, - * }] - * }), - * }; - * } - * } - * ``` - */ -export type CalendarSource = { - /** - * Retrieves the list of calendars accessible to the authenticated user. - * - * @param calendarId - A calendar ID to use for auth lookup - * @returns Promise resolving to array of available calendars - * @throws When no valid authorization is available - */ - getCalendars(calendarId: string): Promise; - - /** - * Begins synchronizing events from a specific calendar. - * - * Sets up real-time sync for the specified calendar, including initial - * event import and ongoing change notifications. Events are saved - * directly via integrations.saveThread(). - * - * Auth is obtained automatically via integrations.get(provider, calendarId). - * - * @param options - Sync configuration options - * @param options.calendarId - ID of the calendar to sync - * @param options.timeMin - Earliest date to sync events from (inclusive) - * @param options.timeMax - Latest date to sync events to (exclusive) - * @returns Promise that resolves when sync setup is complete - * @throws When no valid authorization or calendar doesn't exist - */ - startSync( - options: { - calendarId: string; - } & SyncOptions, - ): Promise; - - /** - * Stops synchronizing events from a specific calendar. - * - * @param calendarId - ID of the calendar to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(calendarId: string): Promise; -}; diff --git a/twister/src/common/documents.ts b/twister/src/common/documents.ts deleted file mode 100644 index da24702..0000000 --- a/twister/src/common/documents.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { - ThreadMeta, -} from "../index"; - -/** - * Represents a folder from an external document service. - * - * Contains metadata about a specific folder that can be synced - * with Plot. Different document providers may have additional - * provider-specific properties. - */ -export type DocumentFolder = { - /** Unique identifier for the folder within the provider */ - id: string; - /** Human-readable name of the folder */ - name: string; - /** Optional description or additional details about the folder */ - description: string | null; - /** Whether this is a root-level folder (e.g., "My Drive" in Google Drive) */ - root: boolean; -}; - -/** - * Configuration options for document synchronization. - * - * Controls the time range and other parameters for document sync operations. - * Used to limit sync scope and optimize performance. - */ -export type DocumentSyncOptions = { - /** Earliest date to sync documents from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for document service integration sources. - * - * All synced documents are converted to ThreadWithNotes objects. - * Each document becomes a Thread with Notes for the description and comments. - * - * Sources save threads directly via `integrations.saveThread()` rather than - * passing data through callbacks to a separate twist. - * - * **Implementation Pattern:** - * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Source declares providers and lifecycle callbacks in build() - * 3. getChannels returns available folders - * 4. User enables folders in the modal -> onChannelEnabled fires - * 5. Source fetches documents and saves them directly via integrations.saveThread() - * - * **Recommended Data Sync Strategy:** - * Use Thread.source and Note.key for automatic upserts. - * - * - Set `Thread.source` to `"{provider}:file:{fileId}"` (e.g., `"google-drive:file:abc123"`) - * - Use `Note.key` for document details: - * - key: `"summary"` for the document description or metadata summary - * - key: `"comment-{commentId}"` for individual comments (unique per comment) - * - key: `"reply-{commentId}-{replyId}"` for comment replies - * - No manual ID tracking needed - Plot handles deduplication automatically - * - Send NewThreadWithNotes for all documents (creates new or updates existing) - * - Set `thread.unread = false` for initial sync, omit for incremental updates - */ -export type DocumentSource = { - /** - * Retrieves the list of folders accessible to the user. - * - * @param folderId - A folder ID to use for auth lookup - * @returns Promise resolving to array of available folders - */ - getFolders(folderId: string): Promise; - - /** - * Begins synchronizing documents from a specific folder. - * - * Documents are converted to NewThreadWithNotes objects. - * - * Auth is obtained automatically via integrations.get(provider, folderId). - * - * @param options - Sync configuration options - * @param options.folderId - ID of the folder to sync - * @param options.timeMin - Earliest date to sync documents from (inclusive) - * @returns Promise that resolves when sync setup is complete - */ - startSync( - options: { - folderId: string; - } & DocumentSyncOptions, - ): Promise; - - /** - * Stops synchronizing documents from a specific folder. - * - * @param folderId - ID of the folder to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(folderId: string): Promise; - - /** - * Adds a comment to a document. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to threads back as comments on the external document. - * - * Auth is obtained automatically. The tool should extract its own ID - * from meta (e.g., fileId). - * - * @param meta - Thread metadata containing the tool's document identifier - * @param body - The comment text content - * @param noteId - Optional Plot note ID for deduplication - * @returns The external comment key (e.g. "comment-123") for dedup, or void - */ - addDocumentComment?( - meta: ThreadMeta, - body: string, - noteId?: string, - ): Promise; - - /** - * Adds a reply to an existing comment thread on a document. - * - * Auth is obtained automatically. The tool should extract its own ID - * from meta (e.g., fileId). - * - * @param meta - Thread metadata containing the tool's document identifier - * @param commentId - The external comment ID to reply to - * @param body - The reply text content - * @param noteId - Optional Plot note ID for deduplication - * @returns The external reply key (e.g. "reply-123-456") for dedup, or void - */ - addDocumentReply?( - meta: ThreadMeta, - commentId: string, - body: string, - noteId?: string, - ): Promise; -}; diff --git a/twister/src/common/messaging.ts b/twister/src/common/messaging.ts deleted file mode 100644 index 22b0208..0000000 --- a/twister/src/common/messaging.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Represents a channel from an external messaging service. - * - * Contains metadata about a specific channel that can be synced - * with Plot. Different messaging providers may have additional - * provider-specific properties. - */ -export type MessageChannel = { - /** Unique identifier for the channel within the provider */ - id: string; - /** Human-readable name of the channel (e.g., "Inbox", "#general", "My Team Thread") */ - name: string; - /** Optional description or additional details about the channel */ - description: string | null; - /** Whether this is the user's primary/default channel (e.g. email inbox) */ - primary: boolean; -}; - -/** - * Configuration options for messaging synchronization. - * - * Controls the time range and other parameters for messaging sync operations. - * Used to limit sync scope and optimize performance. - */ -export type MessageSyncOptions = { - /** Earliest date to sync events from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for email and chat integration sources. - * - * All synced messages/emails are converted to ThreadWithNotes objects. - * Each email thread or chat conversation becomes a Thread with Notes for each message. - * - * Sources save threads directly via `integrations.saveThread()` rather than - * passing data through callbacks to a separate twist. - * - * **Implementation Pattern:** - * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Source declares providers and lifecycle callbacks in build() - * 3. getChannels returns available messaging channels - * 4. User enables channels in the modal -> onChannelEnabled fires - * 5. Source fetches messages and saves them directly via integrations.saveThread() - * - * **Recommended Data Sync Strategy:** - * Use Thread.source (thread URL or ID) and Note.key (message ID) for automatic upserts. - * See SYNC_STRATEGIES.md for detailed patterns. - */ -export type MessagingSource = { - /** - * Retrieves the list of conversation channels (inboxes, channels) accessible to the user. - * - * @param channelId - A channel ID to use for auth lookup - * @returns Promise resolving to array of available conversation channels - */ - getChannels(channelId: string): Promise; - - /** - * Begins synchronizing messages from a specific channel. - * - * Auth is obtained automatically via integrations.get(provider, channelId). - * - * @param options - Sync configuration options - * @param options.channelId - ID of the channel (e.g., channel, inbox) to sync - * @param options.timeMin - Earliest date to sync events from (inclusive) - * @returns Promise that resolves when sync setup is complete - */ - startSync( - options: { - channelId: string; - } & MessageSyncOptions, - ): Promise; - - /** - * Stops synchronizing messages from a specific channel. - * - * @param channelId - ID of the channel to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(channelId: string): Promise; -}; diff --git a/twister/src/common/projects.ts b/twister/src/common/projects.ts deleted file mode 100644 index 2772d40..0000000 --- a/twister/src/common/projects.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { - Link, - ThreadMeta, -} from "../index"; - -/** - * Represents a project from an external project management service. - * - * Contains metadata about a specific project/board/workspace that can be synced - * with Plot. Different project providers may have additional - * provider-specific properties. - */ -export type Project = { - /** Unique identifier for the project within the provider */ - id: string; - /** Human-readable name of the project (e.g., "Q1 Roadmap", "Engineering") */ - name: string; - /** Optional description or additional details about the project */ - description: string | null; - /** Optional project key/abbreviation (e.g., "PROJ" in Jira, "ENG" in Linear) */ - key: string | null; -}; - -/** - * Configuration options for project synchronization. - * - * Controls the time range and other parameters for project sync operations. - * Used to limit sync scope and optimize performance. - */ -export type ProjectSyncOptions = { - /** Earliest date to sync issues from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for project management integration sources. - * - * All synced issues/tasks are converted to ThreadWithNotes objects. - * Each issue becomes a Thread with Notes for the description and comments. - * - * Sources save threads directly via `integrations.saveThread()` rather than - * passing data through callbacks to a separate twist. - * - * **Implementation Pattern:** - * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Source declares providers and lifecycle callbacks in build() - * 3. getChannels returns available projects - * 4. User enables projects in the modal -> onChannelEnabled fires - * 5. Source fetches issues and saves them directly via integrations.saveThread() - * - * **Recommended Data Sync Strategy:** - * Use Thread.source (issue URL) and Note.key for automatic upserts. - * See SYNC_STRATEGIES.md for detailed patterns. - */ -export type ProjectSource = { - /** - * Retrieves the list of projects accessible to the user. - * - * @param projectId - A project ID to use for auth lookup - * @returns Promise resolving to array of available projects - */ - getProjects(projectId: string): Promise; - - /** - * Begins synchronizing issues from a specific project. - * - * Auth is obtained automatically via integrations.get(provider, projectId). - * - * @param options - Sync configuration options - * @param options.projectId - ID of the project to sync - * @param options.timeMin - Earliest date to sync issues from (inclusive) - * @returns Promise that resolves when sync setup is complete - */ - startSync( - options: { - projectId: string; - } & ProjectSyncOptions, - ): Promise; - - /** - * Stops synchronizing issues from a specific project. - * - * @param projectId - ID of the project to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(projectId: string): Promise; - - /** - * Updates an issue/task in the external service based on link changes. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync link updates (status, assignee, title) back to the external service. - * - * @param link - The updated link with source metadata - * @returns Promise that resolves when the update is synced - */ - updateIssue?(link: Link): Promise; - - /** - * Adds a comment to an issue/task. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to threads back as comments on the external service. - * - * Auth is obtained automatically. The tool should extract its own ID - * from meta (e.g., linearId, taskGid, issueKey). - * - * @param meta - Thread metadata containing the tool's issue/task identifier - * @param body - The comment text content - * @param noteId - Optional Plot note ID, used by tools that support comment metadata (e.g. Jira) - * @returns The external comment key (e.g. "comment-123") for dedup, or void - */ - addIssueComment?( - meta: ThreadMeta, - body: string, - noteId?: string, - ): Promise; -}; diff --git a/twister/src/common/source-control.ts b/twister/src/common/source-control.ts deleted file mode 100644 index 86b6b9a..0000000 --- a/twister/src/common/source-control.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { - Link, - ThreadMeta, -} from "../index"; - -/** - * Represents a repository from an external source control service. - * - * Contains metadata about a specific repository that can be synced - * with Plot. Different source control providers may have additional - * provider-specific properties. - */ -export type Repository = { - /** Unique identifier for the repository within the provider */ - id: string; - /** Human-readable name of the repository (e.g., "my-app") */ - name: string; - /** Optional description or additional details about the repository */ - description: string | null; - /** URL to view the repository in the browser */ - url: string | null; - /** Owner of the repository (user or organization name) */ - owner: string | null; - /** Default branch name (e.g., "main", "master") */ - defaultBranch: string | null; - /** Whether the repository is private */ - private: boolean; -}; - -/** - * Configuration options for source control synchronization. - * - * Controls the time range and other parameters for source control sync operations. - * Used to limit sync scope and optimize performance. - */ -export type SourceControlSyncOptions = { - /** Earliest date to sync pull requests from (inclusive) */ - timeMin?: Date; -}; - -/** - * Base interface for source control integration sources. - * - * All synced pull requests are converted to ThreadWithNotes objects. - * Each PR becomes a Thread with Notes for the description, comments, - * and review summaries. - * - * Sources save threads directly via `integrations.saveThread()` rather than - * passing data through callbacks to a separate twist. - * - * **Implementation Pattern:** - * 1. Authorization is handled via the twist edit modal (Integrations provider config) - * 2. Source declares providers and lifecycle callbacks in build() - * 3. getChannels returns available repositories - * 4. User enables repositories in the modal -> onChannelEnabled fires - * 5. Source fetches PRs and saves them directly via integrations.saveThread() - * - * **Recommended Data Sync Strategy:** - * Use Thread.source (PR URL) and Note.key for automatic upserts. - * See SYNC_STRATEGIES.md for detailed patterns. - */ -export type SourceControlSource = { - /** - * Retrieves the list of repositories accessible to the user. - * - * @param repositoryId - A repository ID to use for auth lookup - * @returns Promise resolving to array of available repositories - */ - getRepositories(repositoryId: string): Promise; - - /** - * Begins synchronizing pull requests from a specific repository. - * - * Auth is obtained automatically via integrations.get(provider, repositoryId). - * - * @param options - Sync configuration options - * @param options.repositoryId - ID of the repository to sync (owner/repo format) - * @param options.timeMin - Earliest date to sync PRs from (inclusive) - * @returns Promise that resolves when sync setup is complete - */ - startSync( - options: { - repositoryId: string; - } & SourceControlSyncOptions, - ): Promise; - - /** - * Stops synchronizing pull requests from a specific repository. - * - * @param repositoryId - ID of the repository to stop syncing - * @returns Promise that resolves when sync is stopped - */ - stopSync(repositoryId: string): Promise; - - /** - * Adds a general comment to a pull request. - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync notes added to threads back as comments on the external service. - * - * Auth is obtained automatically. The tool should extract its own ID - * from meta (e.g., prNumber, owner, repo). - * - * @param meta - Thread metadata containing the tool's PR identifier - * @param body - The comment text content - * @param noteId - Optional Plot note ID for dedup - * @returns The external comment key (e.g. "comment-123") for dedup, or void - */ - addPRComment?( - meta: ThreadMeta, - body: string, - noteId?: string, - ): Promise; - - /** - * Updates a pull request's review status (approve, request changes). - * - * Optional method for bidirectional sync. When implemented, allows Plot to - * sync link status changes back to the external service. - * - * @param link - The updated link with review status - * @returns Promise that resolves when the update is synced - */ - updatePRStatus?(link: Link): Promise; - - /** - * Closes a pull request without merging. - * - * Optional method for bidirectional sync. - * - * @param meta - Thread metadata containing the tool's PR identifier - * @returns Promise that resolves when the PR is closed - */ - closePR?(meta: ThreadMeta): Promise; -}; diff --git a/twister/typedoc.json b/twister/typedoc.json index f93ac1e..bf8e958 100644 --- a/twister/typedoc.json +++ b/twister/typedoc.json @@ -14,7 +14,6 @@ "src/tools/plot.ts", "src/tools/store.ts", "src/tools/tasks.ts", - "src/common/calendar.ts", "src/utils/types.ts", "src/utils/hash.ts" ], From 83a8f629cc8a150e9d413740244b7eef86da49b9 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 28 Feb 2026 08:21:23 -0500 Subject: [PATCH 17/25] Redesign Source class to own identity and lifecycle Sources now declare provider, scopes, linkTypes, and channel callbacks as class members instead of burying them in Integrations provider config. Migrates all 11 sources to the new API and adds --source flag to CLI create. Co-Authored-By: Claude Opus 4.6 --- sources/asana/src/asana.ts | 37 ++--- sources/github-issues/src/github-issues.ts | 62 +++---- sources/github/src/github.ts | 44 ++--- sources/gmail/src/gmail.ts | 23 +-- .../google-calendar/src/google-calendar.ts | 25 +-- .../google-contacts/src/google-contacts.ts | 33 +--- sources/google-drive/src/google-drive.ts | 25 +-- sources/jira/src/jira.ts | 39 ++--- sources/linear/src/linear.ts | 40 ++--- .../outlook-calendar/src/outlook-calendar.ts | 22 +-- sources/slack/src/slack.ts | 23 +-- twister/cli/commands/create.ts | 46 +++++- twister/cli/index.ts | 1 + twister/src/source.ts | 153 +++++++++++++++--- twister/src/tools/integrations.ts | 47 +++--- 15 files changed, 334 insertions(+), 286 deletions(-) diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index 5fbd277..d390366 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -47,28 +47,23 @@ export class Asana extends Source { static readonly PROVIDER = AuthProvider.Asana; static readonly SCOPES = ["default"]; + readonly provider = AuthProvider.Asana; + readonly scopes = Asana.SCOPES; + readonly linkTypes = [ + { + type: "task", + label: "Task", + logo: "https://api.iconify.design/logos/asana.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [{ - provider: Asana.PROVIDER, - scopes: Asana.SCOPES, - linkTypes: [ - { - type: "task", - label: "Task", - logo: "https://api.iconify.design/logos/asana.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "done", label: "Done" }, - ], - }, - ], - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - }], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://app.asana.com/*"] }), tasks: build(Tasks), }; @@ -78,7 +73,7 @@ export class Asana extends Source { * Create Asana API client with auth token */ private async getClient(projectId: string): Promise { - const token = await this.tools.integrations.get(Asana.PROVIDER, projectId); + const token = await this.tools.integrations.get(projectId); if (!token) { throw new Error("No Asana authentication token available"); } diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index ec8b138..5d72f30 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -54,40 +54,33 @@ export class GitHubIssues extends Source { static readonly PROVIDER = AuthProvider.GitHub; static readonly SCOPES = ["repo"]; + readonly provider = AuthProvider.GitHub; + readonly scopes = GitHubIssues.SCOPES; + readonly linkTypes = [ + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/logos/github-icon.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + ], + }, + { + type: "pull_request", + label: "Pull Request", + logo: "https://api.iconify.design/logos/github-icon.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + { status: "merged", label: "Merged" }, + ], + }, + ]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: GitHubIssues.PROVIDER, - scopes: GitHubIssues.SCOPES, - linkTypes: [ - { - type: "issue", - label: "Issue", - logo: "https://api.iconify.design/logos/github-icon.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "closed", label: "Closed" }, - ], - }, - { - type: "pull_request", - label: "Pull Request", - logo: "https://api.iconify.design/logos/github-icon.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "closed", label: "Closed" }, - { status: "merged", label: "Merged" }, - ], - }, - ], - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://api.github.com/*"] }), tasks: build(Tasks), }; @@ -97,10 +90,7 @@ export class GitHubIssues extends Source { * Create GitHub API client using channel-based auth */ private async getClient(channelId: string): Promise { - const token = await this.tools.integrations.get( - GitHubIssues.PROVIDER, - channelId - ); + const token = await this.tools.integrations.get(channelId); if (!token) { throw new Error("No GitHub authentication token available"); } diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index 1328776..ee26c4f 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -107,31 +107,24 @@ export class GitHub extends Source { /** PRs per page for batch sync */ private static readonly PAGE_SIZE = 50; + readonly provider = AuthProvider.GitHub; + readonly scopes = GitHub.SCOPES; + readonly linkTypes = [ + { + type: "pull_request", + label: "Pull Request", + logo: "https://api.iconify.design/logos/github-icon.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + { status: "merged", label: "Merged" }, + ], + }, + ]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: GitHub.PROVIDER, - scopes: GitHub.SCOPES, - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - linkTypes: [ - { - type: "pull_request", - label: "Pull Request", - logo: "https://api.iconify.design/logos/github-icon.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "closed", label: "Closed" }, - { status: "merged", label: "Merged" }, - ], - }, - ], - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://api.github.com/*"] }), tasks: build(Tasks), }; @@ -160,10 +153,7 @@ export class GitHub extends Source { * Get an authenticated token for a channel repository */ private async getToken(channelId: string): Promise { - const authToken = await this.tools.integrations.get( - GitHub.PROVIDER, - channelId, - ); + const authToken = await this.tools.integrations.get(channelId); if (!authToken) { throw new Error("No GitHub authentication token available"); } diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index 45cb54e..4d9ef52 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -48,27 +48,20 @@ export class Gmail extends Source { "https://www.googleapis.com/auth/gmail.modify", ]; + readonly provider = AuthProvider.Google; + readonly scopes = Gmail.SCOPES; + readonly linkTypes = [{ type: "email", label: "Email", logo: "https://api.iconify.design/logos/google-gmail.svg" }]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: Gmail.PROVIDER, - scopes: Gmail.SCOPES, - getChannels: this.listSyncChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - linkTypes: [{ type: "email", label: "Email", logo: "https://api.iconify.design/logos/google-gmail.svg" }], - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://gmail.googleapis.com/gmail/v1/*"], }), }; } - async listSyncChannels( + async getChannels( _auth: Authorization, token: AuthToken ): Promise { @@ -110,14 +103,14 @@ export class Gmail extends Source { } private async getApi(channelId: string): Promise { - const token = await this.tools.integrations.get(Gmail.PROVIDER, channelId); + const token = await this.tools.integrations.get(channelId); if (!token) { throw new Error("No Google authentication token available"); } return new GmailApi(token.token); } - async getChannels(channelId: string): Promise { + async listLabels(channelId: string): Promise { const api = await this.getApi(channelId); const labels = await api.getLabels(); diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index 0b819ff..2f7b4a1 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -117,23 +117,13 @@ export class GoogleCalendar extends Source { "https://www.googleapis.com/auth/calendar.events", ]; + readonly provider = AuthProvider.Google; + readonly scopes = Integrations.MergeScopes(GoogleCalendar.SCOPES, GoogleContacts.SCOPES); + readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/google-calendar.svg" }]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: GoogleCalendar.PROVIDER, - scopes: Integrations.MergeScopes( - GoogleCalendar.SCOPES, - GoogleContacts.SCOPES - ), - linkTypes: [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/google-calendar.svg" }], - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://www.googleapis.com/calendar/*"], }), @@ -213,10 +203,7 @@ export class GoogleCalendar extends Source { private async getApi(calendarId: string): Promise { // Get token for the syncable (calendar) from integrations - const token = await this.tools.integrations.get( - GoogleCalendar.PROVIDER, - calendarId - ); + const token = await this.tools.integrations.get(calendarId); if (!token) { throw new Error("Authorization no longer available"); diff --git a/sources/google-contacts/src/google-contacts.ts b/sources/google-contacts/src/google-contacts.ts index 7501754..6037e99 100644 --- a/sources/google-contacts/src/google-contacts.ts +++ b/sources/google-contacts/src/google-contacts.ts @@ -258,17 +258,12 @@ export default class GoogleContacts "https://www.googleapis.com/auth/contacts.other.readonly", ]; + readonly provider = AuthProvider.Google; + readonly scopes = GoogleContacts.SCOPES; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [{ - provider: GoogleContacts.PROVIDER, - scopes: GoogleContacts.SCOPES, - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - }], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://people.googleapis.com/*"], }), @@ -280,10 +275,7 @@ export default class GoogleContacts } async onChannelEnabled(channel: Channel): Promise { - const token = await this.tools.integrations.get( - GoogleContacts.PROVIDER, - channel.id - ); + const token = await this.tools.integrations.get(channel.id); if (!token) { throw new Error("No Google authentication token available"); } @@ -303,10 +295,7 @@ export default class GoogleContacts } async getContacts(syncableId: string): Promise { - const token = await this.tools.integrations.get( - GoogleContacts.PROVIDER, - syncableId - ); + const token = await this.tools.integrations.get(syncableId); if (!token) { throw new Error( "No Google authentication token available for the provided syncableId" @@ -322,10 +311,7 @@ export default class GoogleContacts } async startSync(syncableId: string): Promise { - const token = await this.tools.integrations.get( - GoogleContacts.PROVIDER, - syncableId - ); + const token = await this.tools.integrations.get(syncableId); if (!token) { throw new Error( "No Google authentication token available for the provided syncableId" @@ -348,10 +334,7 @@ export default class GoogleContacts async syncBatch(batchNumber: number, syncableId: string): Promise { try { - const token = await this.tools.integrations.get( - GoogleContacts.PROVIDER, - syncableId - ); + const token = await this.tools.integrations.get(syncableId); if (!token) { throw new Error( "No authentication token available for the provided syncableId" diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index 3b18e9e..9d911e0 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -65,23 +65,13 @@ export class GoogleDrive extends Source { static readonly PROVIDER = AuthProvider.Google; static readonly SCOPES = ["https://www.googleapis.com/auth/drive"]; + readonly provider = AuthProvider.Google; + readonly scopes = Integrations.MergeScopes(GoogleDrive.SCOPES, GoogleContacts.SCOPES); + readonly linkTypes = [{ type: "document", label: "Document", logo: "https://api.iconify.design/logos/google-drive.svg" }]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: GoogleDrive.PROVIDER, - scopes: Integrations.MergeScopes( - GoogleDrive.SCOPES, - GoogleContacts.SCOPES - ), - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - linkTypes: [{ type: "document", label: "Document", logo: "https://api.iconify.design/logos/google-drive.svg" }], - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://www.googleapis.com/drive/*"], }), @@ -198,10 +188,7 @@ export class GoogleDrive extends Source { private async getApi(folderId: string): Promise { // Get token for the channel (folder) from integrations - const token = await this.tools.integrations.get( - GoogleDrive.PROVIDER, - folderId - ); + const token = await this.tools.integrations.get(folderId); if (!token) { throw new Error("Authorization no longer available"); diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index c7f9fa0..9c91e50 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -46,28 +46,23 @@ export class Jira extends Source { static readonly PROVIDER = AuthProvider.Atlassian; static readonly SCOPES = ["read:jira-work", "write:jira-work", "read:jira-user"]; + readonly provider = AuthProvider.Atlassian; + readonly scopes = Jira.SCOPES; + readonly linkTypes = [ + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/logos/jira.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [{ - provider: Jira.PROVIDER, - scopes: Jira.SCOPES, - linkTypes: [ - { - type: "issue", - label: "Issue", - logo: "https://api.iconify.design/logos/jira.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "done", label: "Done" }, - ], - }, - ], - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - }], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://*.atlassian.net/*"] }), tasks: build(Tasks), }; @@ -77,7 +72,7 @@ export class Jira extends Source { * Create Jira API client using channel-based auth */ private async getClient(projectId: string): Promise { - const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); + const token = await this.tools.integrations.get(projectId); if (!token) { throw new Error("No Jira authentication token available"); } @@ -313,7 +308,7 @@ export class Jira extends Source { * Get the cloud ID using channel-based auth */ private async getCloudId(projectId: string): Promise { - const token = await this.tools.integrations.get(Jira.PROVIDER, projectId); + const token = await this.tools.integrations.get(projectId); if (!token) throw new Error("No Jira token available"); const cloudId = token.provider?.cloud_id; if (!cloudId) throw new Error("Jira cloud ID not found"); diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index c16ade0..7d1097a 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -62,31 +62,23 @@ export class Linear extends Source { static readonly PROVIDER = AuthProvider.Linear; static readonly SCOPES = ["read", "write", "admin"]; + readonly provider = AuthProvider.Linear; + readonly scopes = Linear.SCOPES; + readonly linkTypes = [ + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/logos/linear-icon.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "done", label: "Done" }, + ], + }, + ]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: Linear.PROVIDER, - scopes: Linear.SCOPES, - linkTypes: [ - { - type: "issue", - label: "Issue", - logo: "https://api.iconify.design/logos/linear-icon.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "done", label: "Done" }, - ], - }, - ], - onLinkUpdated: this.onLinkUpdated, - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://api.linear.app/*"] }), tasks: build(Tasks), }; @@ -96,7 +88,7 @@ export class Linear extends Source { * Create Linear API client using channel-based auth */ private async getClient(projectId: string): Promise { - const token = await this.tools.integrations.get(Linear.PROVIDER, projectId); + const token = await this.tools.integrations.get(projectId); if (!token) { throw new Error("No Linear authentication token available"); } diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index e54ca4f..d86707d 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -110,20 +110,13 @@ export class OutlookCalendar extends Source { static readonly PROVIDER = AuthProvider.Microsoft; static readonly SCOPES = ["https://graph.microsoft.com/calendars.readwrite"]; + readonly provider = AuthProvider.Microsoft; + readonly scopes = OutlookCalendar.SCOPES; + readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/simple-icons/microsoftoutlook.svg" }]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: OutlookCalendar.PROVIDER, - scopes: OutlookCalendar.SCOPES, - linkTypes: [{ type: "event", label: "Event", logo: "https://api.iconify.design/simple-icons/microsoftoutlook.svg" }], - getChannels: this.getChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://graph.microsoft.com/*"] }), }; } @@ -179,10 +172,7 @@ export class OutlookCalendar extends Source { } private async getApi(calendarId: string): Promise { - const token = await this.tools.integrations.get( - OutlookCalendar.PROVIDER, - calendarId - ); + const token = await this.tools.integrations.get(calendarId); if (!token) { throw new Error("No Microsoft authentication token available"); } diff --git a/sources/slack/src/slack.ts b/sources/slack/src/slack.ts index 0f15d96..6677a04 100644 --- a/sources/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -72,25 +72,18 @@ export class Slack extends Source { "mpim:history", ]; + readonly provider = AuthProvider.Slack; + readonly scopes = Slack.SCOPES; + readonly linkTypes = [{ type: "message", label: "Message", logo: "https://api.iconify.design/logos/slack-icon.svg" }]; + build(build: ToolBuilder) { return { - integrations: build(Integrations, { - providers: [ - { - provider: Slack.PROVIDER, - scopes: Slack.SCOPES, - getChannels: this.listSyncChannels, - onChannelEnabled: this.onChannelEnabled, - onChannelDisabled: this.onChannelDisabled, - linkTypes: [{ type: "message", label: "Message", logo: "https://api.iconify.design/logos/slack-icon.svg" }], - }, - ], - }), + integrations: build(Integrations), network: build(Network, { urls: ["https://slack.com/api/*"] }), }; } - async listSyncChannels( + async getChannels( _auth: Authorization, token: AuthToken ): Promise { @@ -133,14 +126,14 @@ export class Slack extends Source { } private async getApi(channelId: string): Promise { - const token = await this.tools.integrations.get(Slack.PROVIDER, channelId); + const token = await this.tools.integrations.get(channelId); if (!token) { throw new Error("No Slack authentication token available"); } return new SlackApi(token.token); } - async getChannels(channelId: string): Promise { + async listWorkspaceChannels(channelId: string): Promise { const api = await this.getApi(channelId); const channels = await api.getChannels(); diff --git a/twister/cli/commands/create.ts b/twister/cli/commands/create.ts index 7e75ce7..38e48e7 100644 --- a/twister/cli/commands/create.ts +++ b/twister/cli/commands/create.ts @@ -10,10 +10,12 @@ interface CreateOptions { dir?: string; name?: string; displayName?: string; + source?: boolean; } export async function createCommand(options: CreateOptions) { - out.header("Create a new Plot twist"); + const isSource = !!options.source; + out.header(isSource ? "Create a new Plot source" : "Create a new Plot twist"); let response: { name: string; displayName: string }; @@ -94,8 +96,9 @@ export async function createCommand(options: CreateOptions) { const plotTwistId = crypto.randomUUID(); // Create package.json + const packageName = isSource ? `@plotday/source-${response.name}` : response.name; const packageJson: any = { - name: response.name, + name: packageName, displayName: response.displayName || response.name, main: "src/index.ts", types: "src/index.ts", @@ -127,6 +130,39 @@ export async function createCommand(options: CreateOptions) { JSON.stringify(tsconfigJson, null, 2) + "\n" ); + const sourceTemplate = `import { Source, type ToolBuilder } from "@plotday/twister"; +import { + AuthProvider, + type AuthToken, + type Authorization, + Integrations, + type Channel, +} from "@plotday/twister/tools/integrations"; + +export default class MySource extends Source { + readonly provider = AuthProvider.Google; // Change to your provider + readonly scopes = ["https://example.com/scope"]; + + build(build: ToolBuilder) { + return { + integrations: build(Integrations), + }; + } + + async getChannels(_auth: Authorization, _token: AuthToken): Promise { + return []; + } + + async onChannelEnabled(_channel: Channel): Promise { + // Start syncing this channel + } + + async onChannelDisabled(_channel: Channel): Promise { + // Stop syncing and clean up + } +} +`; + const twistTemplate = `import { type Activity, Twist, @@ -151,7 +187,11 @@ export default class MyTwist extends Twist { } } `; - fs.writeFileSync(path.join(twistPath, "src", "index.ts"), twistTemplate); + + fs.writeFileSync( + path.join(twistPath, "src", "index.ts"), + isSource ? sourceTemplate : twistTemplate + ); // Detect and use appropriate package manager const packageManager = detectPackageManager(); diff --git a/twister/cli/index.ts b/twister/cli/index.ts index b791c73..e785921 100644 --- a/twister/cli/index.ts +++ b/twister/cli/index.ts @@ -55,6 +55,7 @@ program .option("-d, --dir ", "Directory to create the twist in") .option("-n, --name ", "Package name (kebab-case)") .option("--display-name ", "Display name for the twist") + .option("--source", "Create a source instead of a twist") .action(createCommand); // Top-level lint command diff --git a/twister/src/source.ts b/twister/src/source.ts index a2bd366..0626cbd 100644 --- a/twister/src/source.ts +++ b/twister/src/source.ts @@ -1,40 +1,55 @@ +import { type Actor, type Link, type Note, type ThreadMeta } from "./plot"; +import { + type AuthProvider, + type AuthToken, + type Authorization, + type Channel, + type LinkTypeConfig, +} from "./tools/integrations"; import { Twist } from "./twist"; /** - * Base class for sources - twists that sync data from external services. + * Base class for sources — twists that sync data from external services. * - * Sources are a specialization of Twist that save threads directly via - * `integrations.saveThread()` instead of using the Plot tool. They cannot - * access the Plot tool directly. - * - * Sources replace the old Tool + Twist pass-through pattern where Tools - * built data and passed it via callbacks to Twists which simply called - * `plot.createThread()`. + * Sources declare a single OAuth provider and scopes, and implement channel + * lifecycle methods for discovering and syncing external resources. They save + * data directly via `integrations.saveLink()` instead of using the Plot tool. * * @example * ```typescript - * class GoogleCalendarSource extends Source { + * class LinearSource extends Source { + * readonly provider = AuthProvider.Linear; + * readonly scopes = ["read", "write"]; + * readonly linkTypes = [{ + * type: "issue", + * label: "Issue", + * statuses: [ + * { status: "open", label: "Open" }, + * { status: "done", label: "Done" }, + * ], + * }]; + * * build(build: ToolBuilder) { * return { - * integrations: build(Integrations, { - * providers: [{ - * provider: AuthProvider.Google, - * scopes: GoogleCalendarSource.SCOPES, - * getChannels: this.getChannels, - * onChannelEnabled: this.onChannelEnabled, - * onChannelDisabled: this.onChannelDisabled, - * }] - * }), + * integrations: build(Integrations), * }; * } * + * async getChannels(auth: Authorization, token: AuthToken): Promise { + * const teams = await this.listTeams(token); + * return teams.map(t => ({ id: t.id, title: t.name })); + * } + * * async onChannelEnabled(channel: Channel) { - * // Fetch and save events directly - * const events = await this.fetchEvents(channel.id); - * for (const event of events) { - * await this.tools.integrations.saveThread(event); + * const issues = await this.fetchIssues(channel.id); + * for (const issue of issues) { + * await this.tools.integrations.saveLink(issue); * } * } + * + * async onChannelDisabled(channel: Channel) { + * // Clean up webhooks, sync state, etc. + * } * } * ``` */ @@ -44,4 +59,98 @@ export abstract class Source extends Twist { * across worker boundaries. */ static readonly isSource = true; + + // ---- Identity (abstract — every source must declare) ---- + + /** The OAuth provider this source authenticates with. */ + abstract readonly provider: AuthProvider; + + /** OAuth scopes to request for this source. */ + abstract readonly scopes: string[]; + + // ---- Optional metadata ---- + + /** + * Registry of link types this source creates (e.g., issue, event, message). + * Used for display in the UI (icons, labels, statuses). + */ + readonly linkTypes?: LinkTypeConfig[]; + + // ---- Channel lifecycle (abstract — every source must implement) ---- + + /** + * Returns available channels for the authorized actor. + * Called after OAuth is complete, during the setup/edit modal. + * + * @param auth - The completed authorization with provider and actor info + * @param token - The access token for making API calls + * @returns Promise resolving to available channels for the user to select + */ + abstract getChannels( + auth: Authorization, + token: AuthToken + ): Promise; + + /** + * Called when a channel resource is enabled for syncing. + * Should set up webhooks and start initial sync. + * + * @param channel - The channel that was enabled + */ + abstract onChannelEnabled(channel: Channel): Promise; + + /** + * Called when a channel resource is disabled. + * Should stop sync, clean up webhooks, and remove state. + * + * @param channel - The channel that was disabled + */ + abstract onChannelDisabled(channel: Channel): Promise; + + // ---- Write-back hooks (optional, default no-ops) ---- + + /** + * Called when a link created by this source is updated by the user. + * Override to write back changes to the external service + * (e.g., changing issue status in Linear when marked done in Plot). + * + * @param link - The updated link + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkUpdated(link: Link): Promise { + return Promise.resolve(); + } + + /** + * Called when a note is created on a thread owned by this source. + * Override to write back comments to the external service + * (e.g., adding a comment to a Linear issue). + * + * @param note - The created note + * @param meta - Metadata from the thread's link + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onNoteCreated(note: Note, meta: ThreadMeta): Promise { + return Promise.resolve(); + } + + // ---- Activation ---- + + /** + * Called when the source is activated after OAuth is complete. + * + * Unlike Twist.activate() which receives a priority, Source.activate() + * receives the authorization and actor since sources are not installed + * in priorities. + * + * Default implementation does nothing. Override for custom setup. + * + * @param context - The activation context + * @param context.auth - The completed OAuth authorization + * @param context.actor - The actor who activated the source + */ + // @ts-ignore - Source.activate() intentionally has a different signature than Twist.activate() + activate(context: { auth: Authorization; actor: Actor }): Promise { + return Promise.resolve(); + } } diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index ff55365..7d0b1cd 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -26,10 +26,6 @@ export type Channel = { priorityId?: string; }; -/** - * Configuration for an OAuth provider in a source's build options. - * Declares the provider, scopes, and lifecycle callbacks. - */ /** * Describes a link type that a source creates. * Used for display in the UI (icons, labels). @@ -50,6 +46,13 @@ export type LinkTypeConfig = { }>; }; +/** + * Configuration for an OAuth provider. + * + * @deprecated Sources should declare `provider`, `scopes`, and `linkTypes` as class + * properties, and implement channel lifecycle methods directly on the Source class. + * This type is retained for backward compatibility. + */ export type IntegrationProviderConfig = { /** The OAuth provider */ provider: AuthProvider; @@ -88,32 +91,24 @@ export type IntegrationOptions = { * Built-in tool for managing OAuth authentication and channel resources. * * The Integrations tool: - * 1. Declares providers/scopes in build options with lifecycle callbacks - * 2. Manages channel resources (calendars, projects, etc.) per actor - * 3. Returns tokens for the user who enabled sync on a channel - * 4. Supports per-actor auth via actAs() for write-back operations - * 5. Provides saveThread/saveContacts/archiveThreads for Sources to save data directly + * 1. Manages channel resources (calendars, projects, etc.) per actor + * 2. Returns tokens for the user who enabled sync on a channel + * 3. Supports per-actor auth via actAs() for write-back operations + * 4. Provides saveLink/saveContacts for Sources to save data directly * - * Auth and channel management is handled in the twist edit modal in Flutter, - * removing the need for sources to create auth activities or selection UIs. + * Sources declare their provider, scopes, and channel lifecycle methods as + * class properties and methods. The Integrations tool reads these automatically. + * Auth and channel management is handled in the twist edit modal in Flutter. * * @example * ```typescript * class CalendarSource extends Source { - * static readonly PROVIDER = AuthProvider.Google; - * static readonly SCOPES = ["https://www.googleapis.com/auth/calendar"]; + * readonly provider = AuthProvider.Google; + * readonly scopes = ["https://www.googleapis.com/auth/calendar"]; * * build(build: ToolBuilder) { * return { - * integrations: build(Integrations, { - * providers: [{ - * provider: AuthProvider.Google, - * scopes: CalendarSource.SCOPES, - * getChannels: this.getChannels, - * onChannelEnabled: this.onChannelEnabled, - * onChannelDisabled: this.onChannelDisabled, - * }] - * }), + * integrations: build(Integrations), * }; * } * @@ -121,6 +116,14 @@ export type IntegrationOptions = { * const calendars = await this.listCalendars(token); * return calendars.map(c => ({ id: c.id, title: c.name })); * } + * + * async onChannelEnabled(channel: Channel) { + * // Start syncing + * } + * + * async onChannelDisabled(channel: Channel) { + * // Stop syncing + * } * } * ``` */ From 46fe8f1a3c86b73d71bac3b4b6119d43fd3481e1 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 28 Feb 2026 08:39:46 -0500 Subject: [PATCH 18/25] Replace outdated changesets with accurate ones for threads/links/schedules branch Co-Authored-By: Claude Opus 4.6 --- .changeset/all-knives-build.md | 5 ----- .changeset/bold-stars-glow.md | 16 ++++++++++++++++ .changeset/brave-foxes-dance.md | 5 +++++ .changeset/calm-waves-shine.md | 5 +++++ .changeset/fresh-plants-grow.md | 5 +++++ .changeset/full-ends-rescue.md | 13 ------------- .changeset/green-doors-open.md | 5 +++++ .changeset/quiet-rivers-flow.md | 5 +++++ .changeset/silver-moons-rise.md | 5 +++++ .changeset/swift-hawks-soar.md | 5 +++++ .changeset/tall-clouds-rest.md | 5 +++++ .changeset/warm-birds-sing.md | 5 +++++ 12 files changed, 61 insertions(+), 18 deletions(-) delete mode 100644 .changeset/all-knives-build.md create mode 100644 .changeset/bold-stars-glow.md create mode 100644 .changeset/brave-foxes-dance.md create mode 100644 .changeset/calm-waves-shine.md create mode 100644 .changeset/fresh-plants-grow.md delete mode 100644 .changeset/full-ends-rescue.md create mode 100644 .changeset/green-doors-open.md create mode 100644 .changeset/quiet-rivers-flow.md create mode 100644 .changeset/silver-moons-rise.md create mode 100644 .changeset/swift-hawks-soar.md create mode 100644 .changeset/tall-clouds-rest.md create mode 100644 .changeset/warm-birds-sing.md diff --git a/.changeset/all-knives-build.md b/.changeset/all-knives-build.md deleted file mode 100644 index aeb5ee2..0000000 --- a/.changeset/all-knives-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@plotday/twister": minor ---- - -Added: SourceControl interface for services that work with code pull requests diff --git a/.changeset/bold-stars-glow.md b/.changeset/bold-stars-glow.md new file mode 100644 index 0000000..579f29e --- /dev/null +++ b/.changeset/bold-stars-glow.md @@ -0,0 +1,16 @@ +--- +"@plotday/twister": minor +"@plotday/source-asana": minor +"@plotday/source-github": minor +"@plotday/source-github-issues": minor +"@plotday/source-gmail": minor +"@plotday/source-google-calendar": minor +"@plotday/source-google-contacts": minor +"@plotday/source-google-drive": minor +"@plotday/source-jira": minor +"@plotday/source-linear": minor +"@plotday/source-outlook-calendar": minor +"@plotday/source-slack": minor +--- + +Changed: BREAKING — Refactor all sources to extend Source base class with provider identity and channel lifecycle diff --git a/.changeset/brave-foxes-dance.md b/.changeset/brave-foxes-dance.md new file mode 100644 index 0000000..2bf9dd2 --- /dev/null +++ b/.changeset/brave-foxes-dance.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Rename Activity to Thread and Link to Action throughout the SDK (types, methods, filters) diff --git a/.changeset/calm-waves-shine.md b/.changeset/calm-waves-shine.md new file mode 100644 index 0000000..beae31b --- /dev/null +++ b/.changeset/calm-waves-shine.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Schedule type with ScheduleContact for event scheduling, recurring events, and per-user schedules diff --git a/.changeset/fresh-plants-grow.md b/.changeset/fresh-plants-grow.md new file mode 100644 index 0000000..5aadb13 --- /dev/null +++ b/.changeset/fresh-plants-grow.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Source base class for building service integrations with provider, scopes, and lifecycle management diff --git a/.changeset/full-ends-rescue.md b/.changeset/full-ends-rescue.md deleted file mode 100644 index f47eccb..0000000 --- a/.changeset/full-ends-rescue.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@plotday/source-outlook-calendar": minor -"@plotday/source-google-calendar": minor -"@plotday/tool-github-issues": minor -"@plotday/tool-google-drive": minor -"@plotday/tool-github": minor -"@plotday/tool-linear": minor -"@plotday/tool-asana": minor -"@plotday/tool-jira": minor -"@plotday/twister": minor ---- - -Added: Activity.links, better for activity-scoped links such as the link to the original item diff --git a/.changeset/green-doors-open.md b/.changeset/green-doors-open.md new file mode 100644 index 0000000..8f77e7b --- /dev/null +++ b/.changeset/green-doors-open.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: LinkType config for sources and channelId on Link for account-based priority routing diff --git a/.changeset/quiet-rivers-flow.md b/.changeset/quiet-rivers-flow.md new file mode 100644 index 0000000..03c026e --- /dev/null +++ b/.changeset/quiet-rivers-flow.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Rename Syncable to Channel in Integrations tool, add saveLink() and saveContacts() methods diff --git a/.changeset/silver-moons-rise.md b/.changeset/silver-moons-rise.md new file mode 100644 index 0000000..c812992 --- /dev/null +++ b/.changeset/silver-moons-rise.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Removed: BREAKING — Deprecated twister functions and types diff --git a/.changeset/swift-hawks-soar.md b/.changeset/swift-hawks-soar.md new file mode 100644 index 0000000..7a77cbc --- /dev/null +++ b/.changeset/swift-hawks-soar.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Package exports for ./source and ./schedule modules diff --git a/.changeset/tall-clouds-rest.md b/.changeset/tall-clouds-rest.md new file mode 100644 index 0000000..09d5515 --- /dev/null +++ b/.changeset/tall-clouds-rest.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Removed: BREAKING — Common interfaces (calendar, documents, messaging, projects, source-control) replaced by individual source implementations diff --git a/.changeset/warm-birds-sing.md b/.changeset/warm-birds-sing.md new file mode 100644 index 0000000..d802bd5 --- /dev/null +++ b/.changeset/warm-birds-sing.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Removed: BREAKING — RSVP tags (Attend, Skip, Undecided) from Tag enum, replaced by ScheduleContact From 7ad8e06ca7904d87b667e009fc71e9100906112d Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 28 Feb 2026 22:48:01 -0500 Subject: [PATCH 19/25] Add dark mode icons --- sources/asana/package.json | 3 +- sources/asana/src/asana.ts | 1 + sources/github-issues/package.json | 4 +- sources/github-issues/src/github-issues.ts | 4 ++ sources/github/package.json | 4 +- sources/github/src/github.ts | 2 + sources/gmail/package.json | 3 +- sources/gmail/src/gmail.ts | 2 +- sources/google-calendar/package.json | 3 +- .../google-calendar/src/google-calendar.ts | 48 +++++++++++++++++-- sources/google-contacts/package.json | 3 +- sources/google-drive/package.json | 3 +- sources/google-drive/src/google-drive.ts | 2 +- sources/jira/package.json | 3 +- sources/jira/src/jira.ts | 1 + sources/linear/package.json | 4 +- sources/linear/src/linear.ts | 2 + sources/outlook-calendar/package.json | 3 +- .../outlook-calendar/src/outlook-calendar.ts | 2 +- sources/slack/package.json | 3 +- sources/slack/src/slack.ts | 2 +- twister/cli/commands/deploy.ts | 8 ++++ twister/src/schedule.ts | 7 ++- twister/src/tools/integrations.ts | 6 ++- 24 files changed, 101 insertions(+), 22 deletions(-) diff --git a/sources/asana/package.json b/sources/asana/package.json index 9475281..4058939 100644 --- a/sources/asana/package.json +++ b/sources/asana/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-asana", "plotTwistId": "d8f4e839-c152-41a2-926c-700e23d4fc77", "displayName": "Asana", - "description": "Sync with Asana project management", + "description": "", + "logoUrl": "https://api.iconify.design/logos/asana.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.7.1", diff --git a/sources/asana/src/asana.ts b/sources/asana/src/asana.ts index d390366..4665767 100644 --- a/sources/asana/src/asana.ts +++ b/sources/asana/src/asana.ts @@ -54,6 +54,7 @@ export class Asana extends Source { type: "task", label: "Task", logo: "https://api.iconify.design/logos/asana.svg", + logoMono: "https://api.iconify.design/simple-icons/asana.svg", statuses: [ { status: "open", label: "Open" }, { status: "done", label: "Done" }, diff --git a/sources/github-issues/package.json b/sources/github-issues/package.json index b2492db..5a416f1 100644 --- a/sources/github-issues/package.json +++ b/sources/github-issues/package.json @@ -2,7 +2,9 @@ "name": "@plotday/source-github-issues", "plotTwistId": "465a9d14-f175-47f5-bbac-1b9d45d1d099", "displayName": "GitHub Issues", - "description": "Sync with GitHub Issues", + "description": "", + "logoUrl": "https://api.iconify.design/logos/github-icon.svg", + "logoUrlDark": "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.1.0", diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts index 5d72f30..3a404c6 100644 --- a/sources/github-issues/src/github-issues.ts +++ b/sources/github-issues/src/github-issues.ts @@ -61,6 +61,8 @@ export class GitHubIssues extends Source { type: "issue", label: "Issue", logo: "https://api.iconify.design/logos/github-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", + logoMono: "https://api.iconify.design/simple-icons/github.svg", statuses: [ { status: "open", label: "Open" }, { status: "closed", label: "Closed" }, @@ -70,6 +72,8 @@ export class GitHubIssues extends Source { type: "pull_request", label: "Pull Request", logo: "https://api.iconify.design/logos/github-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", + logoMono: "https://api.iconify.design/simple-icons/github.svg", statuses: [ { status: "open", label: "Open" }, { status: "closed", label: "Closed" }, diff --git a/sources/github/package.json b/sources/github/package.json index e32cb5a..cbc3954 100644 --- a/sources/github/package.json +++ b/sources/github/package.json @@ -2,7 +2,9 @@ "name": "@plotday/source-github", "plotTwistId": "ba3afb64-af6e-4c64-bcff-5fa8575d21f0", "displayName": "GitHub", - "description": "Sync with GitHub pull requests and code reviews", + "description": "Pull requests and code reviews", + "logoUrl": "https://api.iconify.design/logos/github-icon.svg", + "logoUrlDark": "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.1.0", diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index ee26c4f..6ce43a6 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -114,6 +114,8 @@ export class GitHub extends Source { type: "pull_request", label: "Pull Request", logo: "https://api.iconify.design/logos/github-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", + logoMono: "https://api.iconify.design/simple-icons/github.svg", statuses: [ { status: "open", label: "Open" }, { status: "closed", label: "Closed" }, diff --git a/sources/gmail/package.json b/sources/gmail/package.json index 05727ef..0734eed 100644 --- a/sources/gmail/package.json +++ b/sources/gmail/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-gmail", "plotTwistId": "7176b853-4495-4bba-82ee-645ae5d398d7", "displayName": "Gmail", - "description": "Sync with Gmail inbox and messages", + "description": "Emails that need your attention", + "logoUrl": "https://api.iconify.design/logos/google-gmail.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.9.1", diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index 4d9ef52..8fe50e4 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -50,7 +50,7 @@ export class Gmail extends Source { readonly provider = AuthProvider.Google; readonly scopes = Gmail.SCOPES; - readonly linkTypes = [{ type: "email", label: "Email", logo: "https://api.iconify.design/logos/google-gmail.svg" }]; + readonly linkTypes = [{ type: "email", label: "Email", logo: "https://api.iconify.design/logos/google-gmail.svg", logoMono: "https://api.iconify.design/simple-icons/gmail.svg" }]; build(build: ToolBuilder) { return { diff --git a/sources/google-calendar/package.json b/sources/google-calendar/package.json index dbdd783..2aad887 100644 --- a/sources/google-calendar/package.json +++ b/sources/google-calendar/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-google-calendar", "plotTwistId": "2ed4fcf8-6524-410f-b318-f9316e71c8b0", "displayName": "Google Calendar", - "description": "Sync with Google Calendar", + "description": "", + "logoUrl": "https://api.iconify.design/logos/google-calendar.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.14.1", diff --git a/sources/google-calendar/src/google-calendar.ts b/sources/google-calendar/src/google-calendar.ts index 2f7b4a1..2baea47 100644 --- a/sources/google-calendar/src/google-calendar.ts +++ b/sources/google-calendar/src/google-calendar.ts @@ -34,6 +34,11 @@ type SyncOptions = { timeMax?: Date | null; }; +type PendingOccurrence = { + occurrence: NewScheduleOccurrence; + cancelled: boolean; +}; + import { GoogleApi, type GoogleEvent, @@ -119,7 +124,7 @@ export class GoogleCalendar extends Source { readonly provider = AuthProvider.Google; readonly scopes = Integrations.MergeScopes(GoogleCalendar.SCOPES, GoogleContacts.SCOPES); - readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/google-calendar.svg" }]; + readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/google-calendar.svg", logoMono: "https://api.iconify.design/simple-icons/googlecalendar.svg" }]; build(build: ToolBuilder) { return { @@ -603,6 +608,13 @@ export class GoogleCalendar extends Source { } if (mode === "full") { + // Discard buffered occurrences whose masters never appeared + // (e.g. instances of cancelled/deleted recurring events) + const pendingKeys = await this.tools.store.list("pending_occ:"); + for (const key of pendingKeys) { + await this.clear(key); + } + await this.clear(`sync_state_${calendarId}`); } // Always clear lock when sync completes (no more batches) @@ -796,6 +808,17 @@ export class GoogleCalendar extends Source { link.channelId = calendarId; link.meta = { ...link.meta, syncProvider: "google", syncableId: calendarId }; + // Merge any buffered occurrences that arrived before this master event + const pendingKey = `pending_occ:google-calendar:${event.id}`; + const pendingOccurrences = await this.get(pendingKey); + if (pendingOccurrences) { + link.scheduleOccurrences = [ + ...(link.scheduleOccurrences || []), + ...pendingOccurrences.map(p => p.occurrence), + ]; + await this.clear(pendingKey); + } + // Send link - database handles upsert automatically await this.tools.integrations.saveLink(link); } @@ -829,7 +852,7 @@ export class GoogleCalendar extends Source { } // Canonical URL for the master recurring event - const masterCanonicalUrl = `google-calendar:${calendarId}:${event.recurringEventId}`; + const masterCanonicalUrl = `google-calendar:${event.recurringEventId}`; // Transform the instance data const instanceData = transformGoogleEvent(event, calendarId); @@ -856,6 +879,15 @@ export class GoogleCalendar extends Source { archived: true, }; + // During initial sync, buffer the occurrence for later merging with its master + if (initialSync) { + const pendingKey = `pending_occ:${masterCanonicalUrl}`; + const existing = await this.get(pendingKey) || []; + existing.push({ occurrence: cancelledOccurrence, cancelled: true }); + await this.set(pendingKey, existing); + return; + } + const occurrenceUpdate: NewLinkWithNotes = { type: "event", title: "", @@ -909,8 +941,16 @@ export class GoogleCalendar extends Source { occurrence.end = instanceSchedule.end; } - // Build a minimal link with source and scheduleOccurrences - // The source saves directly via integrations.saveLink + // During initial sync, buffer the occurrence for later merging with its master + if (initialSync) { + const pendingKey = `pending_occ:${masterCanonicalUrl}`; + const existing = await this.get(pendingKey) || []; + existing.push({ occurrence, cancelled: false }); + await this.set(pendingKey, existing); + return; + } + + // For incremental sync, save immediately (master should exist) const occurrenceUpdate: NewLinkWithNotes = { type: "event", title: "", diff --git a/sources/google-contacts/package.json b/sources/google-contacts/package.json index c20ad9c..2acde4e 100644 --- a/sources/google-contacts/package.json +++ b/sources/google-contacts/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-google-contacts", "plotTwistId": "748bb590-5da3-4782-aa6f-6a98fc2d3555", "displayName": "Google Contacts", - "description": "Sync with Google Contacts", + "description": "", + "logoUrl": "https://api.iconify.design/logos/google-contacts.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.6.1", diff --git a/sources/google-drive/package.json b/sources/google-drive/package.json index 9901260..e1d9fed 100644 --- a/sources/google-drive/package.json +++ b/sources/google-drive/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-google-drive", "plotTwistId": "c27741c2-0f26-444c-9e50-e1d70dab0dee", "displayName": "Google Drive", - "description": "Sync documents comments from Google Drive", + "description": "Comment threads from your Google Docs", + "logoUrl": "https://api.iconify.design/logos/google-drive.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.2.2", diff --git a/sources/google-drive/src/google-drive.ts b/sources/google-drive/src/google-drive.ts index 9d911e0..a033592 100644 --- a/sources/google-drive/src/google-drive.ts +++ b/sources/google-drive/src/google-drive.ts @@ -67,7 +67,7 @@ export class GoogleDrive extends Source { readonly provider = AuthProvider.Google; readonly scopes = Integrations.MergeScopes(GoogleDrive.SCOPES, GoogleContacts.SCOPES); - readonly linkTypes = [{ type: "document", label: "Document", logo: "https://api.iconify.design/logos/google-drive.svg" }]; + readonly linkTypes = [{ type: "document", label: "Document", logo: "https://api.iconify.design/logos/google-drive.svg", logoMono: "https://api.iconify.design/simple-icons/googledrive.svg" }]; build(build: ToolBuilder) { return { diff --git a/sources/jira/package.json b/sources/jira/package.json index 4fe4968..2303a91 100644 --- a/sources/jira/package.json +++ b/sources/jira/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-jira", "plotTwistId": "07f11ed7-9555-431d-b01a-99aff550d778", "displayName": "Jira", - "description": "Sync with Jira project management", + "description": "", + "logoUrl": "https://api.iconify.design/logos/jira.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.8.1", diff --git a/sources/jira/src/jira.ts b/sources/jira/src/jira.ts index 9c91e50..55d862f 100644 --- a/sources/jira/src/jira.ts +++ b/sources/jira/src/jira.ts @@ -53,6 +53,7 @@ export class Jira extends Source { type: "issue", label: "Issue", logo: "https://api.iconify.design/logos/jira.svg", + logoMono: "https://api.iconify.design/simple-icons/jira.svg", statuses: [ { status: "open", label: "Open" }, { status: "done", label: "Done" }, diff --git a/sources/linear/package.json b/sources/linear/package.json index 87e6bf8..7677736 100644 --- a/sources/linear/package.json +++ b/sources/linear/package.json @@ -2,7 +2,9 @@ "name": "@plotday/source-linear", "plotTwistId": "d23218d6-1e26-4a4d-9a24-63898a494c51", "displayName": "Linear", - "description": "Sync with Linear project management", + "description": "", + "logoUrl": "https://api.iconify.design/logos/linear-icon.svg", + "logoUrlDark": "https://api.iconify.design/simple-icons/linear.svg?color=%235E6AD2", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.7.1", diff --git a/sources/linear/src/linear.ts b/sources/linear/src/linear.ts index 7d1097a..80d431b 100644 --- a/sources/linear/src/linear.ts +++ b/sources/linear/src/linear.ts @@ -69,6 +69,8 @@ export class Linear extends Source { type: "issue", label: "Issue", logo: "https://api.iconify.design/logos/linear-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/linear.svg?color=%235E6AD2", + logoMono: "https://api.iconify.design/simple-icons/linear.svg", statuses: [ { status: "open", label: "Open" }, { status: "done", label: "Done" }, diff --git a/sources/outlook-calendar/package.json b/sources/outlook-calendar/package.json index faa0d60..00713aa 100644 --- a/sources/outlook-calendar/package.json +++ b/sources/outlook-calendar/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-outlook-calendar", "plotTwistId": "cf518010-30c1-4594-b3df-295a19d65459", "displayName": "Outlook Calendar", - "description": "Sync with Microsoft Outlook Calendar", + "description": "", + "logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.14.1", diff --git a/sources/outlook-calendar/src/outlook-calendar.ts b/sources/outlook-calendar/src/outlook-calendar.ts index d86707d..0ec886e 100644 --- a/sources/outlook-calendar/src/outlook-calendar.ts +++ b/sources/outlook-calendar/src/outlook-calendar.ts @@ -112,7 +112,7 @@ export class OutlookCalendar extends Source { readonly provider = AuthProvider.Microsoft; readonly scopes = OutlookCalendar.SCOPES; - readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/simple-icons/microsoftoutlook.svg" }]; + readonly linkTypes = [{ type: "event", label: "Event", logo: "https://api.iconify.design/logos/microsoft-icon.svg", logoDark: "https://api.iconify.design/simple-icons/microsoftoutlook.svg?color=%230078D4", logoMono: "https://api.iconify.design/simple-icons/microsoftoutlook.svg" }]; build(build: ToolBuilder) { return { diff --git a/sources/slack/package.json b/sources/slack/package.json index 8dbd228..d72f19c 100644 --- a/sources/slack/package.json +++ b/sources/slack/package.json @@ -2,7 +2,8 @@ "name": "@plotday/source-slack", "plotTwistId": "d8cbc41f-71f5-4cb6-a0bb-3ade462c4084", "displayName": "Slack", - "description": "Sync with Slack channels and messages", + "description": "Messages from your Slack channels", + "logoUrl": "https://api.iconify.design/logos/slack-icon.svg", "author": "Plot (https://plot.day)", "license": "MIT", "version": "0.9.1", diff --git a/sources/slack/src/slack.ts b/sources/slack/src/slack.ts index 6677a04..9ab2f6f 100644 --- a/sources/slack/src/slack.ts +++ b/sources/slack/src/slack.ts @@ -74,7 +74,7 @@ export class Slack extends Source { readonly provider = AuthProvider.Slack; readonly scopes = Slack.SCOPES; - readonly linkTypes = [{ type: "message", label: "Message", logo: "https://api.iconify.design/logos/slack-icon.svg" }]; + readonly linkTypes = [{ type: "message", label: "Message", logo: "https://api.iconify.design/logos/slack-icon.svg", logoMono: "https://api.iconify.design/simple-icons/slack.svg" }]; build(build: ToolBuilder) { return { diff --git a/twister/cli/commands/deploy.ts b/twister/cli/commands/deploy.ts index f127c77..23c996b 100644 --- a/twister/cli/commands/deploy.ts +++ b/twister/cli/commands/deploy.ts @@ -73,6 +73,8 @@ interface PackageJson { description?: string; author?: string; license?: string; + logoUrl?: string; + logoUrlDark?: string; plotTwistId?: string; plotTwist?: { id?: string; @@ -261,6 +263,8 @@ export async function deployCommand(options: DeployOptions) { let twistId = packageJson?.plotTwistId; const twistName = packageJson?.displayName; const twistDescription = packageJson?.description; + const twistLogoUrl = packageJson?.logoUrl; + const twistLogoUrlDark = packageJson?.logoUrlDark; const environment = options.environment || "personal"; @@ -510,6 +514,8 @@ export async function deployCommand(options: DeployOptions) { sourcemap?: string; name: string; description?: string; + logoUrl?: string; + logoUrlDark?: string; environment: string; publisherId?: number; dryRun?: boolean; @@ -545,6 +551,8 @@ export async function deployCommand(options: DeployOptions) { sourcemap: sourcemapContent, name: deploymentName!, description: deploymentDescription, + logoUrl: twistLogoUrl, + logoUrlDark: twistLogoUrlDark, environment: environment, publisherId, dryRun: options.dryRun, diff --git a/twister/src/schedule.ts b/twister/src/schedule.ts index 7299974..07250b8 100644 --- a/twister/src/schedule.ts +++ b/twister/src/schedule.ts @@ -17,8 +17,11 @@ export { Uuid } from "./utils/uuid"; * - Shared schedules (userId is null): visible to all members of the thread's priority * - Per-user schedules (userId set): private ordering/scheduling for a specific user * - * For recurring events, start/end represent the first occurrence, with recurrenceRule - * defining the pattern. + * For recurring events in the SDK, start/end represent the first occurrence's + * time. In the database, the `at`/`on` range is expanded to span from the first + * occurrence start to the last occurrence end (or open-ended if no fixed end). + * The `duration` column stores the per-occurrence duration, enabling range overlap + * queries to correctly find all recurring events with occurrences in a given window. */ export type Schedule = { /** When this schedule was created */ diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index 7d0b1cd..48c8f97 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -35,8 +35,12 @@ export type LinkTypeConfig = { type: string; /** Human-readable label (e.g., "Issue", "Pull Request") */ label: string; - /** URL to an icon for this link type. Prefer Iconify URLs (e.g., "https://api.iconify.design/simple-icons/linear.svg") */ + /** URL to an icon for this link type (light mode). Prefer Iconify `logos/*` URLs. */ logo?: string; + /** URL to an icon for dark mode. Use when the default logo is invisible on dark backgrounds (e.g., Iconify `simple-icons/*` with `?color=`). */ + logoDark?: string; + /** URL to a monochrome icon (uses `currentColor`). Prefer Iconify `simple-icons/*` URLs without a `?color=` param. */ + logoMono?: string; /** Possible status values for this type */ statuses?: Array<{ /** Machine-readable status (e.g., "open", "done") */ From 6e9dcf5317a713747ccced73021e6c14e27aab3b Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 3 Mar 2026 23:30:24 -0500 Subject: [PATCH 20/25] Stop publishing sources, update changesets for twister-only Sources are now deployed directly via the Plot CLI, not consumed as npm packages. Mark all source packages as private, remove publishConfig and files arrays, and ignore them in changeset config. Rewrite existing changeset to remove source references, add 4 new twister-only changesets. Update CI workflows to only check twister for changesets and releases. Co-Authored-By: Claude Opus 4.6 --- .changeset/bold-stars-glow.md | 13 +------------ .changeset/bright-trees-wave.md | 5 +++++ .changeset/config.json | 3 ++- .changeset/cool-lakes-hum.md | 5 +++++ .changeset/deep-pens-rest.md | 5 +++++ .changeset/kind-dogs-clap.md | 5 +++++ .github/workflows/changeset-check.yml | 11 +++++------ .github/workflows/release.yml | 5 +---- sources/asana/package.json | 13 +++---------- sources/github/package.json | 13 +++---------- sources/gmail/package.json | 13 +++---------- sources/google-calendar/package.json | 13 +++---------- sources/google-contacts/package.json | 13 +++---------- sources/google-drive/package.json | 11 ++--------- sources/jira/package.json | 13 +++---------- sources/linear/package.json | 13 +++---------- sources/outlook-calendar/package.json | 13 +++---------- sources/slack/package.json | 11 ++--------- 18 files changed, 57 insertions(+), 121 deletions(-) create mode 100644 .changeset/bright-trees-wave.md create mode 100644 .changeset/cool-lakes-hum.md create mode 100644 .changeset/deep-pens-rest.md create mode 100644 .changeset/kind-dogs-clap.md diff --git a/.changeset/bold-stars-glow.md b/.changeset/bold-stars-glow.md index 579f29e..a4ee135 100644 --- a/.changeset/bold-stars-glow.md +++ b/.changeset/bold-stars-glow.md @@ -1,16 +1,5 @@ --- "@plotday/twister": minor -"@plotday/source-asana": minor -"@plotday/source-github": minor -"@plotday/source-github-issues": minor -"@plotday/source-gmail": minor -"@plotday/source-google-calendar": minor -"@plotday/source-google-contacts": minor -"@plotday/source-google-drive": minor -"@plotday/source-jira": minor -"@plotday/source-linear": minor -"@plotday/source-outlook-calendar": minor -"@plotday/source-slack": minor --- -Changed: BREAKING — Refactor all sources to extend Source base class with provider identity and channel lifecycle +Changed: BREAKING — Refactor Source base class to own provider identity and channel lifecycle diff --git a/.changeset/bright-trees-wave.md b/.changeset/bright-trees-wave.md new file mode 100644 index 0000000..5ba44b4 --- /dev/null +++ b/.changeset/bright-trees-wave.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Twist lifecycle hooks onThreadUpdated, onNoteCreated moved from Plot options to Twist base class methods; added onLinkCreated, onLinkUpdated, onLinkNoteCreated, onOptionsChanged diff --git a/.changeset/config.json b/.changeset/config.json index a722d25..b929d3a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -13,6 +13,7 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@plotday/twist-*" + "@plotday/twist-*", + "@plotday/source-*" ] } diff --git a/.changeset/cool-lakes-hum.md b/.changeset/cool-lakes-hum.md new file mode 100644 index 0000000..228add4 --- /dev/null +++ b/.changeset/cool-lakes-hum.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Added: Source.onThreadRead() hook for writing back read/unread status to external services diff --git a/.changeset/deep-pens-rest.md b/.changeset/deep-pens-rest.md new file mode 100644 index 0000000..bd40f4c --- /dev/null +++ b/.changeset/deep-pens-rest.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Removed deprecated IntegrationProviderConfig and IntegrationOptions types; added archiveLinks(filter) for bulk-archiving links diff --git a/.changeset/kind-dogs-clap.md b/.changeset/kind-dogs-clap.md new file mode 100644 index 0000000..01b11df --- /dev/null +++ b/.changeset/kind-dogs-clap.md @@ -0,0 +1,5 @@ +--- +"@plotday/twister": minor +--- + +Changed: BREAKING — Removed thread.updated and note.created callbacks from Plot options (use Twist.onThreadUpdated/onNoteCreated instead); added `link: true` option and `getLinks(filter?)` method for link processing diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index e8a7276..197419f 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -36,16 +36,15 @@ jobs: git fetch origin main CHANGED_FILES=$(git diff --name-only origin/main...HEAD) - # Check if Twister or tools directories were modified + # Check if Twister was modified (sources are private and don't need changesets) TWISTER_CHANGED=$(echo "$CHANGED_FILES" | grep -E '^twister/' || true) - TOOLS_CHANGED=$(echo "$CHANGED_FILES" | grep -E '^tools/' || true) - if [ -n "$TWISTER_CHANGED" ] || [ -n "$TOOLS_CHANGED" ]; then + if [ -n "$TWISTER_CHANGED" ]; then echo "needs-changeset=true" >> $GITHUB_OUTPUT - echo "Twister or tools packages were modified" + echo "Twister package was modified" else echo "needs-changeset=false" >> $GITHUB_OUTPUT - echo "No Twister or tools changes detected" + echo "No Twister changes detected" fi - name: Check for changesets @@ -55,7 +54,7 @@ jobs: CHANGESET_COUNT=$(ls -1 .changeset/*.md 2>/dev/null | grep -v README.md | wc -l | tr -d ' ') if [ "$CHANGESET_COUNT" -eq 0 ]; then - echo "❌ Error: Changes detected in Twister or tools packages, but no changeset found." + echo "❌ Error: Changes detected in Twister package, but no changeset found." echo "" echo "Please add a changeset by running:" echo " pnpm changeset" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46dac1f..c24846e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,11 +67,8 @@ jobs: # Determine package directory if [[ "$name" == "@plotday/twister" ]]; then pkg_dir="twister" - elif [[ "$name" == @plotday/tool-* ]]; then - tool_name="${name#@plotday/tool-}" - pkg_dir="tools/$tool_name" else - echo "Unknown package: $name" + echo "Skipping unknown package: $name" continue fi diff --git a/sources/asana/package.json b/sources/asana/package.json index 4058939..b2b2f5f 100644 --- a/sources/asana/package.json +++ b/sources/asana/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-asana", "plotTwistId": "d8f4e839-c152-41a2-926c-700e23d4fc77", "displayName": "Asana", - "description": "", + "description": "Tasks from Asana", "logoUrl": "https://api.iconify.design/logos/asana.svg", "author": "Plot (https://plot.day)", "license": "MIT", @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -51,8 +47,5 @@ "tool", "asana", "project-management" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/github/package.json b/sources/github/package.json index cbc3954..19fcb4e 100644 --- a/sources/github/package.json +++ b/sources/github/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-github", "plotTwistId": "ba3afb64-af6e-4c64-bcff-5fa8575d21f0", "displayName": "GitHub", - "description": "Pull requests and code reviews", + "description": "Pull requests, issues, and code reviews", "logoUrl": "https://api.iconify.design/logos/github-icon.svg", "logoUrlDark": "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", "author": "Plot (https://plot.day)", @@ -18,11 +18,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -50,8 +46,5 @@ "github", "source-control", "code-review" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/gmail/package.json b/sources/gmail/package.json index 0734eed..10720f8 100644 --- a/sources/gmail/package.json +++ b/sources/gmail/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-gmail", "plotTwistId": "7176b853-4495-4bba-82ee-645ae5d398d7", "displayName": "Gmail", - "description": "Emails that need your attention", + "description": "Email threads from Gmail", "logoUrl": "https://api.iconify.design/logos/google-gmail.svg", "author": "Plot (https://plot.day)", "license": "MIT", @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -50,8 +46,5 @@ "gmail", "messaging", "email" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/google-calendar/package.json b/sources/google-calendar/package.json index 2aad887..5b545e8 100644 --- a/sources/google-calendar/package.json +++ b/sources/google-calendar/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-google-calendar", "plotTwistId": "2ed4fcf8-6524-410f-b318-f9316e71c8b0", "displayName": "Google Calendar", - "description": "", + "description": "Events from Google Calendar", "logoUrl": "https://api.iconify.design/logos/google-calendar.svg", "author": "Plot (https://plot.day)", "license": "MIT", @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -50,8 +46,5 @@ "tool", "google-calendar", "calendar" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/google-contacts/package.json b/sources/google-contacts/package.json index 2acde4e..f8962ac 100644 --- a/sources/google-contacts/package.json +++ b/sources/google-contacts/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-google-contacts", "plotTwistId": "748bb590-5da3-4782-aa6f-6a98fc2d3555", "displayName": "Google Contacts", - "description": "", + "description": "Contacts from Google", "logoUrl": "https://api.iconify.design/logos/google-contacts.svg", "author": "Plot (https://plot.day)", "license": "MIT", @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -49,8 +45,5 @@ "tool", "google-contacts", "contacts" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/google-drive/package.json b/sources/google-drive/package.json index e1d9fed..054b4c1 100644 --- a/sources/google-drive/package.json +++ b/sources/google-drive/package.json @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -50,8 +46,5 @@ "tool", "google-drive", "documents" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/jira/package.json b/sources/jira/package.json index 2303a91..79b6358 100644 --- a/sources/jira/package.json +++ b/sources/jira/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-jira", "plotTwistId": "07f11ed7-9555-431d-b01a-99aff550d778", "displayName": "Jira", - "description": "", + "description": "Issues from Jira", "logoUrl": "https://api.iconify.design/logos/jira.svg", "author": "Plot (https://plot.day)", "license": "MIT", @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -50,8 +46,5 @@ "jira", "atlassian", "project-management" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/linear/package.json b/sources/linear/package.json index 7677736..e00e20a 100644 --- a/sources/linear/package.json +++ b/sources/linear/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-linear", "plotTwistId": "d23218d6-1e26-4a4d-9a24-63898a494c51", "displayName": "Linear", - "description": "", + "description": "Issues from Linear", "logoUrl": "https://api.iconify.design/logos/linear-icon.svg", "logoUrlDark": "https://api.iconify.design/simple-icons/linear.svg?color=%235E6AD2", "author": "Plot (https://plot.day)", @@ -18,11 +18,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -50,8 +46,5 @@ "tool", "linear", "project-management" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/outlook-calendar/package.json b/sources/outlook-calendar/package.json index 00713aa..58c1557 100644 --- a/sources/outlook-calendar/package.json +++ b/sources/outlook-calendar/package.json @@ -2,7 +2,7 @@ "name": "@plotday/source-outlook-calendar", "plotTwistId": "cf518010-30c1-4594-b3df-295a19d65459", "displayName": "Outlook Calendar", - "description": "", + "description": "Events from Outlook Calendar", "logoUrl": "https://api.iconify.design/simple-icons/microsoftoutlook.svg", "author": "Plot (https://plot.day)", "license": "MIT", @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -51,8 +47,5 @@ "outlook", "microsoft", "calendar" - ], - "publishConfig": { - "access": "public" - } + ] } diff --git a/sources/slack/package.json b/sources/slack/package.json index d72f19c..11ec707 100644 --- a/sources/slack/package.json +++ b/sources/slack/package.json @@ -17,11 +17,7 @@ "default": "./dist/index.js" } }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], + "private": true, "scripts": { "build": "tsc", "clean": "rm -rf dist", @@ -49,8 +45,5 @@ "tool", "slack", "messaging" - ], - "publishConfig": { - "access": "public" - } + ] } From a70e6f3b34ba3480228bb27cf172650cc68e76a5 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 3 Mar 2026 23:49:34 -0500 Subject: [PATCH 21/25] Add twist lifecycle hooks and link support to SDK Move onThreadUpdated and onNoteCreated from Plot options to Twist base class methods. Add onLinkCreated, onLinkUpdated, onLinkNoteCreated, onOptionsChanged to Twist. Add onThreadRead to Source for read status write-back. Add link option and getLinks to Plot tool. Add archiveLinks to Integrations. Remove deprecated IntegrationProviderConfig and IntegrationOptions types. Co-Authored-By: Claude Opus 4.6 --- twister/src/plot.ts | 57 ++++++++++---------- twister/src/source.ts | 17 +++++- twister/src/tools/integrations.ts | 72 ++++++++++--------------- twister/src/tools/plot.ts | 57 ++++++++++---------- twister/src/twist.ts | 88 ++++++++++++++++++++++++++++++- 5 files changed, 189 insertions(+), 102 deletions(-) diff --git a/twister/src/plot.ts b/twister/src/plot.ts index 2fee293..47a0f00 100644 --- a/twister/src/plot.ts +++ b/twister/src/plot.ts @@ -345,7 +345,11 @@ export type ThreadCommon = { /** Unique identifier for the thread */ id: Uuid; /** - * When this thread was created. + * When this item was created. + * + * **For sources:** Set this to the external system's timestamp (e.g., email + * sent date, comment creation date), NOT the sync time. If omitted, defaults + * to the current time, which is almost never correct for synced data. */ created: Date; /** Whether this thread is private (only visible to creator) */ @@ -506,31 +510,31 @@ type ThreadBulkUpdateFields = Partial< * Includes all bulk fields plus tags and preview. */ type ThreadSingleUpdateFields = ThreadBulkUpdateFields & { - /** - * Tags to change on the thread. Use an empty array of NewActor to remove a tag. - * Use twistTags to add/remove the twist from tags to avoid clearing other actors' tags. - */ - tags?: NewTags; + /** + * Tags to change on the thread. Use an empty array of NewActor to remove a tag. + * Use twistTags to add/remove the twist from tags to avoid clearing other actors' tags. + */ + tags?: NewTags; - /** - * Add or remove the twist's tags. - * Maps tag ID to boolean: true = add tag, false = remove tag. - * This is allowed on all threads the twist has access to. - */ - twistTags?: Partial>; + /** + * Add or remove the twist's tags. + * Maps tag ID to boolean: true = add tag, false = remove tag. + * This is allowed on all threads the twist has access to. + */ + twistTags?: Partial>; - /** - * Optional preview content for the thread. Can be Markdown formatted. - * The preview will be automatically generated from this content (truncated to 100 chars). - * - * - string: Use this content for preview generation - * - null: Explicitly disable preview (no preview will be shown) - * - undefined (omitted): Preserve current preview value - * - * This field is write-only and won't be returned when reading threads. - */ - preview?: string | null; - }; + /** + * Optional preview content for the thread. Can be Markdown formatted. + * The preview will be automatically generated from this content (truncated to 100 chars). + * + * - string: Use this content for preview generation + * - null: Explicitly disable preview (no preview will be shown) + * - undefined (omitted): Preserve current preview value + * + * This field is write-only and won't be returned when reading threads. + */ + preview?: string | null; +}; export type ThreadUpdate = | (({ id: Uuid } | { source: string }) & ThreadSingleUpdateFields) @@ -852,9 +856,7 @@ export type NewLink = ( } | {} ) & - Partial< - Omit - > & { + Partial> & { /** The person that created the item. By default, it will be the twist itself. */ author?: NewActor; /** The person assigned to the item. */ @@ -899,4 +901,3 @@ export type NewLinkWithNotes = NewLink & { /** Schedule occurrence overrides */ scheduleOccurrences?: NewScheduleOccurrence[]; }; - diff --git a/twister/src/source.ts b/twister/src/source.ts index 0626cbd..af52b4e 100644 --- a/twister/src/source.ts +++ b/twister/src/source.ts @@ -1,4 +1,4 @@ -import { type Actor, type Link, type Note, type ThreadMeta } from "./plot"; +import { type Actor, type Link, type Note, type Thread, type ThreadMeta } from "./plot"; import { type AuthProvider, type AuthToken, @@ -134,6 +134,21 @@ export abstract class Source extends Twist { return Promise.resolve(); } + /** + * Called when a user reads or unreads a thread owned by this source. + * Override to write back read status to the external service + * (e.g., marking an email as read in Gmail). + * + * @param thread - The thread that was read/unread + * @param actor - The user who performed the action + * @param unread - false when marked as read, true when marked as unread + * @param meta - Metadata from the thread's link (contains channelId, threadId, etc.) + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onThreadRead(thread: Thread, actor: Actor, unread: boolean, meta: ThreadMeta): Promise { + return Promise.resolve(); + } + // ---- Activation ---- /** diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index 48c8f97..0e07051 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -1,14 +1,12 @@ import { type Actor, type ActorId, - type Link, type NewContact, type NewLinkWithNotes, - type Note, - type ThreadMeta, ITool, Serializable, } from ".."; +import type { JSONValue } from "../utils/types"; import type { Uuid } from "../utils/uuid"; /** @@ -50,47 +48,6 @@ export type LinkTypeConfig = { }>; }; -/** - * Configuration for an OAuth provider. - * - * @deprecated Sources should declare `provider`, `scopes`, and `linkTypes` as class - * properties, and implement channel lifecycle methods directly on the Source class. - * This type is retained for backward compatibility. - */ -export type IntegrationProviderConfig = { - /** The OAuth provider */ - provider: AuthProvider; - /** OAuth scopes to request */ - scopes: string[]; - /** Registry of link types this source creates */ - linkTypes?: LinkTypeConfig[]; - /** Returns available channels for the authorized actor. Must not use Plot tool. */ - getChannels: (auth: Authorization, token: AuthToken) => Promise; - /** Called when a channel resource is enabled for syncing */ - onChannelEnabled: (channel: Channel) => Promise; - /** Called when a channel resource is disabled */ - onChannelDisabled: (channel: Channel) => Promise; - /** - * Called when a link created by this source is updated by the user. - * Used for write-back to external services (e.g., changing issue status). - */ - onLinkUpdated?: (link: Link) => Promise; - /** - * Called when a note is created on a thread owned by this source. - * Used for write-back to external services (e.g., adding a comment to an issue). - */ - onNoteCreated?: (note: Note, meta: ThreadMeta) => Promise; - -}; - -/** - * Options passed to Integrations in the build() method. - */ -export type IntegrationOptions = { - /** Provider configurations with lifecycle callbacks */ - providers: IntegrationProviderConfig[]; -}; - /** * Built-in tool for managing OAuth authentication and channel resources. * @@ -211,8 +168,35 @@ export abstract class Integrations extends ITool { // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract saveContacts(contacts: NewContact[]): Promise; + /** + * Archives links matching the given filter that were created by this source. + * + * For each archived link's thread, if no other non-archived links remain, + * the thread is also archived. + * + * @param filter - Filter criteria for which links to archive + * @returns Promise that resolves when archiving is complete + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract archiveLinks(filter: ArchiveLinkFilter): Promise; + } +/** + * Filter criteria for archiving links. + * All fields are optional; only provided fields are used for matching. + */ +export type ArchiveLinkFilter = { + /** Filter by channel ID */ + channelId?: string; + /** Filter by link type (e.g., "issue", "pull_request") */ + type?: string; + /** Filter by link status (e.g., "open", "closed") */ + status?: string; + /** Filter by metadata fields (uses containment matching) */ + meta?: Record; +}; + /** * Enumeration of supported OAuth providers. * diff --git a/twister/src/tools/plot.ts b/twister/src/tools/plot.ts index c8a606f..48f8f31 100644 --- a/twister/src/tools/plot.ts +++ b/twister/src/tools/plot.ts @@ -4,6 +4,7 @@ import { type Actor, type ActorId, ITool, + type Link, type NewThread, type NewThreadWithNotes, type NewContact, @@ -13,7 +14,6 @@ import { type NoteUpdate, type Priority, type PriorityUpdate, - type Tag, Uuid, } from ".."; import { @@ -69,6 +69,20 @@ export type NoteIntentHandler = { handler: (note: Note) => Promise; }; +/** + * Filter for querying links from connected source channels. + */ +export type LinkFilter = { + /** Only return links from these channel IDs. */ + channelIds?: string[]; + /** Only return links created/updated after this date. */ + since?: Date; + /** Only return links of this type. */ + type?: string; + /** Maximum number of links to return. */ + limit?: number; +}; + /** * Built-in tool for interacting with the core Plot data layer. * @@ -125,7 +139,6 @@ export abstract class Plot extends ITool { * plot: build(Plot, { * thread: { * access: ThreadAccess.Create, - * updated: this.onThreadUpdated * }, * note: { * intents: [{ @@ -133,8 +146,8 @@ export abstract class Plot extends ITool { * examples: ["Schedule a meeting tomorrow"], * handler: this.onSchedulingIntent * }], - * created: this.onNoteCreated * }, + * link: true, * priority: { * access: PriorityAccess.Full * }, @@ -153,20 +166,6 @@ export abstract class Plot extends ITool { * Must be explicitly set to grant permissions. */ access?: ThreadAccess; - /** - * Called when a thread created by this twist is updated. - * This is often used to implement two-way sync with an external system. - * - * @param thread - The updated thread - * @param changes - Changes to the thread and the previous version - */ - updated?: ( - thread: Thread, - changes: { - tagsAdded: Record; - tagsRemoved: Record; - } - ) => Promise; }; note?: { /** @@ -189,18 +188,9 @@ export abstract class Plot extends ITool { * ``` */ intents?: NoteIntentHandler[]; - /** - * Called when a note is created on a thread created by this twist. - * This is often used to implement two-way sync with an external system, - * such as syncing notes as comments back to the source system. - * - * Notes created by the twist itself are automatically filtered out to prevent loops. - * The parent thread is available via note.thread. - * - * @param note - The newly created note - */ - created?: (note: Note) => Promise; }; + /** Enable link processing from connected source channels. */ + link?: true; priority?: { access?: PriorityAccess; }; @@ -517,4 +507,15 @@ export abstract class Plot extends ITool { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars abstract getSchedules(threadId: Uuid): Promise; + + /** + * Retrieves links from connected source channels. + * + * Requires `link: true` in Plot options. + * + * @param filter - Optional filter criteria for links + * @returns Promise resolving to array of links with their notes + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + abstract getLinks(filter?: LinkFilter): Promise>; } diff --git a/twister/src/twist.ts b/twister/src/twist.ts index a33759d..f174985 100644 --- a/twister/src/twist.ts +++ b/twister/src/twist.ts @@ -1,4 +1,5 @@ -import { type Action, type Actor, type Priority, Uuid } from "./plot"; +import { type Action, type Actor, type ActorId, type Link, type Note, type Priority, type Thread, Uuid } from "./plot"; +import type { Tag } from "./tag"; import { type ITool } from "./tool"; import type { Callback } from "./tools/callbacks"; import type { Serializable } from "./utils/serializable"; @@ -286,6 +287,24 @@ export abstract class Twist { return Promise.resolve(); } + /** + * Called when the twist's options configuration changes. + * + * Override to react to option changes, e.g. archiving items when a sync + * type is toggled off, or starting sync when a type is toggled on. + * + * @param oldOptions - The previously resolved options + * @param newOptions - The newly resolved options + * @returns Promise that resolves when the change is handled + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onOptionsChanged( + oldOptions: Record, + newOptions: Record + ): Promise { + return Promise.resolve(); + } + /** * Called when the twist is removed from a priority. * @@ -298,6 +317,73 @@ export abstract class Twist { return Promise.resolve(); } + /** + * Called when a thread created by this twist is updated. + * Override to implement two-way sync with an external system. + * + * @param thread - The updated thread + * @param changes - Tag additions and removals on the thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onThreadUpdated( + thread: Thread, + changes: { + tagsAdded: Record; + tagsRemoved: Record; + } + ): Promise { + return Promise.resolve(); + } + + /** + * Called when a note is created on a thread created by this twist. + * Override to implement two-way sync (e.g. syncing notes as comments). + * + * Notes created by the twist itself are filtered out to prevent loops. + * + * @param note - The newly created note + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onNoteCreated(note: Note, ...args: any[]): Promise { + return Promise.resolve(); + } + + /** + * Called when a link is created in a connected source channel. + * Requires `link: true` in Plot options. + * + * @param link - The newly created link + * @param notes - Notes on the link's thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkCreated(link: Link, notes: Note[]): Promise { + return Promise.resolve(); + } + + /** + * Called when a link in a connected source channel is updated. + * Requires `link: true` in Plot options. + * + * @param link - The updated link + * @param notes - Notes on the link's thread (optional) + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkUpdated(link: Link, notes?: Note[]): Promise { + return Promise.resolve(); + } + + /** + * Called when a note is created on a thread with a link from a connected channel. + * Requires `link: true` in Plot options. + * + * @param note - The newly created note + * @param link - The link associated with the thread + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLinkNoteCreated(note: Note, link: Link): Promise { + return Promise.resolve(); + } + /** * Waits for tool initialization to complete. * Called automatically by the entrypoint before lifecycle methods. From 086acdd73f92fa75eea04b8cd314d0b792e77409 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 3 Mar 2026 23:49:43 -0500 Subject: [PATCH 22/25] Merge github-issues into github source, add Gmail thread read support Consolidate github-issues source into github source with separate issue-sync and pr-sync modules. Add onThreadRead to Gmail for marking emails as read/unread in Gmail when read status changes in Plot. Co-Authored-By: Claude Opus 4.6 --- sources/github-issues/package.json | 58 -- sources/github-issues/src/github-issues.ts | 798 -------------------- sources/github-issues/src/index.ts | 1 - sources/github-issues/tsconfig.json | 8 - sources/github/src/github.ts | 817 +++++---------------- sources/github/src/issue-sync.ts | 405 ++++++++++ sources/github/src/pr-sync.ts | 515 +++++++++++++ sources/gmail/src/gmail-api.ts | 144 +++- sources/gmail/src/gmail.ts | 211 +++++- 9 files changed, 1421 insertions(+), 1536 deletions(-) delete mode 100644 sources/github-issues/package.json delete mode 100644 sources/github-issues/src/github-issues.ts delete mode 100644 sources/github-issues/src/index.ts delete mode 100644 sources/github-issues/tsconfig.json create mode 100644 sources/github/src/issue-sync.ts create mode 100644 sources/github/src/pr-sync.ts diff --git a/sources/github-issues/package.json b/sources/github-issues/package.json deleted file mode 100644 index 5a416f1..0000000 --- a/sources/github-issues/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@plotday/source-github-issues", - "plotTwistId": "465a9d14-f175-47f5-bbac-1b9d45d1d099", - "displayName": "GitHub Issues", - "description": "", - "logoUrl": "https://api.iconify.design/logos/github-icon.svg", - "logoUrlDark": "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", - "author": "Plot (https://plot.day)", - "license": "MIT", - "version": "0.1.0", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "@plotday/source": "./src/index.ts", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "deploy": "plot deploy", - "lint": "plot lint" - }, - "dependencies": { - "@plotday/twister": "workspace:^", - "@octokit/rest": "^21.1.1" - }, - "devDependencies": { - "typescript": "^5.9.3" - }, - "repository": { - "type": "git", - "url": "https://github.com/plotday/plot.git", - "directory": "tools/github-issues" - }, - "homepage": "https://plot.day", - "bugs": { - "url": "https://github.com/plotday/plot/issues" - }, - "keywords": [ - "plot", - "tool", - "github", - "issues", - "project-management" - ], - "publishConfig": { - "access": "public" - } -} diff --git a/sources/github-issues/src/github-issues.ts b/sources/github-issues/src/github-issues.ts deleted file mode 100644 index 3a404c6..0000000 --- a/sources/github-issues/src/github-issues.ts +++ /dev/null @@ -1,798 +0,0 @@ -import { Octokit } from "@octokit/rest"; - -import { - type Action, - ActionType, - type ThreadMeta, - type NewLinkWithNotes, -} from "@plotday/twister"; -import type { NewContact } from "@plotday/twister/plot"; -import { Source } from "@plotday/twister/source"; -import type { ToolBuilder } from "@plotday/twister/tool"; -import { - AuthProvider, - type AuthToken, - type Authorization, - Integrations, - type Channel, -} from "@plotday/twister/tools/integrations"; -import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { Tasks } from "@plotday/twister/tools/tasks"; - -type Project = { - id: string; - name: string; - description: string | null; - key: string | null; -}; - -type ProjectSyncOptions = { - timeMin?: Date; -}; - -type SyncState = { - page: number; - batchNumber: number; - issuesProcessed: number; - initialSync: boolean; - phase: "open" | "closed"; -}; - -type RepoInfo = { - owner: string; - repo: string; - fullName: string; -}; - -/** - * GitHub Issues source - * - * Implements the ProjectSource interface for syncing GitHub Issues - * with Plot threads. Explicitly filters out pull requests. - */ -export class GitHubIssues extends Source { - static readonly PROVIDER = AuthProvider.GitHub; - static readonly SCOPES = ["repo"]; - - readonly provider = AuthProvider.GitHub; - readonly scopes = GitHubIssues.SCOPES; - readonly linkTypes = [ - { - type: "issue", - label: "Issue", - logo: "https://api.iconify.design/logos/github-icon.svg", - logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", - logoMono: "https://api.iconify.design/simple-icons/github.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "closed", label: "Closed" }, - ], - }, - { - type: "pull_request", - label: "Pull Request", - logo: "https://api.iconify.design/logos/github-icon.svg", - logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", - logoMono: "https://api.iconify.design/simple-icons/github.svg", - statuses: [ - { status: "open", label: "Open" }, - { status: "closed", label: "Closed" }, - { status: "merged", label: "Merged" }, - ], - }, - ]; - - build(build: ToolBuilder) { - return { - integrations: build(Integrations), - network: build(Network, { urls: ["https://api.github.com/*"] }), - tasks: build(Tasks), - }; - } - - /** - * Create GitHub API client using channel-based auth - */ - private async getClient(channelId: string): Promise { - const token = await this.tools.integrations.get(channelId); - if (!token) { - throw new Error("No GitHub authentication token available"); - } - return new Octokit({ auth: token.token }); - } - - /** - * Parse owner and repo from stored repo info - */ - private async getRepoInfo(repoId: string): Promise { - const info = await this.get(`repo_info_${repoId}`); - if (!info) { - throw new Error(`Repo info not found for ${repoId}`); - } - return info; - } - - /** - * Returns available GitHub repos as channel resources. - */ - async getChannels( - _auth: Authorization, - token: AuthToken - ): Promise { - const octokit = new Octokit({ auth: token.token }); - const repos = await octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 100, - }); - return repos.data.map((repo) => ({ - id: repo.id.toString(), - title: repo.full_name, - })); - } - - /** - * Called when a channel resource is enabled for syncing. - */ - async onChannelEnabled(channel: Channel): Promise { - await this.set(`sync_enabled_${channel.id}`, true); - - // Store repo info (owner/repo) for API calls - // channel.title is "owner/repo" (full_name) - const [owner, repo] = (channel.title ?? "").split("/"); - if (owner && repo) { - await this.set(`repo_info_${channel.id}`, { - owner, - repo, - fullName: channel.title ?? "", - }); - } - - // Auto-start sync: setup webhook and begin batch sync - await this.setupGitHubWebhook(channel.id); - await this.startBatchSync(channel.id); - } - - /** - * Called when a channel resource is disabled. - */ - async onChannelDisabled(channel: Channel): Promise { - await this.stopSync(channel.id); - await this.clear(`sync_enabled_${channel.id}`); - await this.clear(`repo_info_${channel.id}`); - } - - /** - * Get list of GitHub repos (projects) - */ - async getProjects(projectId: string): Promise { - const octokit = await this.getClient(projectId); - const repos = await octokit.rest.repos.listForAuthenticatedUser({ - sort: "updated", - per_page: 100, - }); - - return repos.data.map((repo) => ({ - id: repo.id.toString(), - name: repo.full_name, - description: repo.description || null, - key: null, - })); - } - - /** - * Start syncing issues from a GitHub repo - */ - async startSync( - options: { - projectId: string; - } & ProjectSyncOptions - ): Promise { - const { projectId } = options; - - // Setup webhook for real-time updates - await this.setupGitHubWebhook(projectId); - - // Start initial batch sync - await this.startBatchSync(projectId, options); - } - - /** - * Setup GitHub webhook for real-time updates - */ - private async setupGitHubWebhook(repoId: string): Promise { - try { - const webhookUrl = await this.tools.network.createWebhook( - {}, - this.onWebhook, - repoId - ); - - // Skip webhook setup for localhost (development mode) - if ( - webhookUrl.includes("localhost") || - webhookUrl.includes("127.0.0.1") - ) { - return; - } - - const octokit = await this.getClient(repoId); - const { owner, repo } = await this.getRepoInfo(repoId); - - // Generate webhook secret for signature verification - const webhookSecret = crypto.randomUUID(); - await this.set(`webhook_secret_${repoId}`, webhookSecret); - - const response = await octokit.rest.repos.createWebhook({ - owner, - repo, - config: { - url: webhookUrl, - content_type: "json", - secret: webhookSecret, - }, - events: ["issues", "issue_comment"], - }); - - if (response.data.id) { - await this.set(`webhook_id_${repoId}`, response.data.id); - } - } catch (error) { - console.error( - "Failed to set up GitHub webhook - real-time updates will not work:", - error - ); - } - } - - /** - * Initialize batch sync process - */ - private async startBatchSync( - repoId: string, - options?: ProjectSyncOptions - ): Promise { - await this.set(`sync_state_${repoId}`, { - page: 1, - batchNumber: 1, - issuesProcessed: 0, - initialSync: true, - phase: "open", - }); - - const batchCallback = await this.callback( - this.syncBatch, - repoId, - options ?? null - ); - await this.tools.tasks.runTask(batchCallback); - } - - /** - * Process a batch of issues - */ - private async syncBatch( - repoId: string, - options?: ProjectSyncOptions | null - ): Promise { - const state = await this.get(`sync_state_${repoId}`); - if (!state) { - throw new Error(`Sync state not found for repo ${repoId}`); - } - - const octokit = await this.getClient(repoId); - const { owner, repo, fullName } = await this.getRepoInfo(repoId); - - // Build request params based on phase - const params: Parameters[0] = { - owner, - repo, - state: state.phase, - per_page: 50, - page: state.page, - sort: "updated", - direction: "desc", - }; - - // For closed phase, only fetch recently closed (last 30 days) - if (state.phase === "closed") { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - params.since = thirtyDaysAgo.toISOString(); - } - - if (options?.timeMin) { - params.since = new Date(options.timeMin).toISOString(); - } - - const response = await octokit.rest.issues.listForRepo(params); - const issues = response.data; - - // Process each issue (filter out PRs) - let processedInBatch = 0; - for (const issue of issues) { - // Skip pull requests (GitHub returns PRs in issues endpoint) - if (issue.pull_request) continue; - - const link = await this.convertIssueToLink( - octokit, - issue, - repoId, - fullName, - state.initialSync - ); - - if (link) { - link.channelId = repoId; - link.meta = { - ...link.meta, - syncProvider: "github-issues", - syncableId: repoId, - }; - await this.tools.integrations.saveLink(link); - processedInBatch++; - } - } - - // Check if there are more pages (GitHub returns less than per_page when done) - const hasMorePages = issues.length === 50; - - if (hasMorePages) { - await this.set(`sync_state_${repoId}`, { - page: state.page + 1, - batchNumber: state.batchNumber + 1, - issuesProcessed: state.issuesProcessed + processedInBatch, - initialSync: state.initialSync, - phase: state.phase, - }); - - const nextBatch = await this.callback( - this.syncBatch, - repoId, - options ?? null - ); - await this.tools.tasks.runTask(nextBatch); - } else if (state.phase === "open") { - // Move to closed phase - await this.set(`sync_state_${repoId}`, { - page: 1, - batchNumber: state.batchNumber + 1, - issuesProcessed: state.issuesProcessed + processedInBatch, - initialSync: state.initialSync, - phase: "closed", - }); - - const closedBatch = await this.callback( - this.syncBatch, - repoId, - options ?? null - ); - await this.tools.tasks.runTask(closedBatch); - } else { - // Both phases complete - await this.clear(`sync_state_${repoId}`); - } - } - - /** - * Convert a GitHub issue to a NewLinkWithNotes - */ - private async convertIssueToLink( - octokit: Octokit, - issue: any, - repoId: string, - repoFullName: string, - initialSync: boolean - ): Promise { - // Build author contact (GitHub users may not have email) - let authorContact: NewContact | undefined; - if (issue.user) { - authorContact = { - email: issue.user.email || `${issue.user.login}@users.noreply.github.com`, - name: issue.user.login, - avatar: issue.user.avatar_url ?? undefined, - }; - } - - // Build assignee contact - let assigneeContact: NewContact | undefined; - const assignee = issue.assignees?.[0] || issue.assignee; - if (assignee) { - assigneeContact = { - email: assignee.email || `${assignee.login}@users.noreply.github.com`, - name: assignee.login, - avatar: assignee.avatar_url ?? undefined, - }; - } - - // Prepare description - const description = issue.body || ""; - const hasDescription = description.trim().length > 0; - - // Build thread-level actions - const threadActions: Action[] = []; - if (issue.html_url) { - threadActions.push({ - type: ActionType.external, - title: "Open in GitHub", - url: issue.html_url, - }); - } - - // Build notes array (inline notes don't require the `thread` field) - const notes: any[] = []; - - notes.push({ - key: "description", - content: hasDescription ? description : null, - created: issue.created_at, - author: authorContact, - }); - - // Fetch comments - const [owner, repo] = repoFullName.split("/"); - try { - let commentPage = 1; - let hasMoreComments = true; - - while (hasMoreComments) { - const commentsResponse = await octokit.rest.issues.listComments({ - owner, - repo, - issue_number: issue.number, - per_page: 100, - page: commentPage, - }); - - for (const comment of commentsResponse.data) { - let commentAuthor: NewContact | undefined; - if (comment.user) { - commentAuthor = { - email: - comment.user.email || - `${comment.user.login}@users.noreply.github.com`, - name: comment.user.login, - avatar: comment.user.avatar_url ?? undefined, - }; - } - - notes.push({ - key: `comment-${comment.id}`, - content: comment.body ?? null, - created: new Date(comment.created_at), - author: commentAuthor, - }); - } - - hasMoreComments = commentsResponse.data.length === 100; - commentPage++; - } - } catch (error) { - console.error( - "Error fetching comments:", - error instanceof Error ? error.message : String(error) - ); - } - - const link: NewLinkWithNotes = { - source: `github:issue:${repoId}:${issue.number}`, - type: "issue", - title: issue.title, - created: issue.created_at, - author: authorContact, - assignee: assigneeContact ?? null, - status: issue.closed_at ? "closed" : "open", - meta: { - githubIssueNumber: issue.number, - githubRepoId: repoId, - githubRepoFullName: repoFullName, - projectId: repoId, - }, - actions: threadActions.length > 0 ? threadActions : undefined, - sourceUrl: issue.html_url ?? null, - notes, - preview: hasDescription ? description : null, - ...(initialSync ? { unread: false } : {}), - ...(initialSync ? { archived: false } : {}), - }; - - return link; - } - - /** - * Update issue with new values from the app - */ - async updateIssue( - link: import("@plotday/twister").Link - ): Promise { - const issueNumber = link.meta?.githubIssueNumber as number | undefined; - if (!issueNumber) { - throw new Error("GitHub issue number not found in link meta"); - } - - const repoFullName = link.meta?.githubRepoFullName as string | undefined; - if (!repoFullName) { - throw new Error("GitHub repo name not found in link meta"); - } - - const projectId = link.meta?.projectId as string | undefined; - if (!projectId) { - throw new Error("Project ID not found in link meta"); - } - - const octokit = await this.getClient(projectId); - const [owner, repo] = repoFullName.split("/"); - - const updateFields: { - state?: "open" | "closed"; - assignees?: string[]; - } = {}; - - // Handle open/close status based on link status - const isDone = link.status === "done" || link.status === "closed" || link.status === "completed"; - updateFields.state = isDone ? "closed" : "open"; - - // Handle assignee - use actor name as GitHub login - if (link.assignee) { - if (link.assignee.name) { - updateFields.assignees = [link.assignee.name]; - } - } else { - updateFields.assignees = []; - } - - if (Object.keys(updateFields).length > 0) { - await octokit.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - ...updateFields, - }); - } - } - - /** - * Add a comment to a GitHub issue - */ - async addIssueComment( - meta: ThreadMeta, - body: string - ): Promise { - const issueNumber = meta.githubIssueNumber as number | undefined; - if (!issueNumber) { - throw new Error("GitHub issue number not found in thread meta"); - } - - const repoFullName = meta.githubRepoFullName as string | undefined; - if (!repoFullName) { - throw new Error("GitHub repo name not found in thread meta"); - } - - const projectId = meta.projectId as string | undefined; - if (!projectId) { - throw new Error("Project ID not found in thread meta"); - } - - const octokit = await this.getClient(projectId); - const [owner, repo] = repoFullName.split("/"); - - const response = await octokit.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body, - }); - - if (response.data.id) { - return `comment-${response.data.id}`; - } - } - - /** - * Handle incoming webhook events from GitHub - */ - private async onWebhook( - request: WebhookRequest, - repoId: string - ): Promise { - // Verify signature - const secret = await this.get(`webhook_secret_${repoId}`); - if (!secret) { - console.warn("GitHub webhook secret not found, skipping verification"); - return; - } - - if (!request.rawBody) { - console.warn("GitHub webhook missing raw body"); - return; - } - - const signature = request.headers["x-hub-signature-256"]; - if (!signature) { - console.warn("GitHub webhook missing signature header"); - return; - } - - // Verify HMAC-SHA256 signature - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign( - "HMAC", - key, - encoder.encode(request.rawBody) - ); - const expectedSignature = - "sha256=" + - Array.from(new Uint8Array(sig)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - if (signature !== expectedSignature) { - console.warn("GitHub webhook signature verification failed"); - return; - } - - const event = request.headers["x-github-event"]; - const payload = request.body as any; - - if (event === "issues") { - await this.handleIssueWebhook(payload, repoId); - } else if (event === "issue_comment") { - await this.handleCommentWebhook(payload, repoId); - } - } - - /** - * Handle Issue webhook events - */ - private async handleIssueWebhook( - payload: any, - repoId: string - ): Promise { - const issue = payload.issue; - if (!issue) return; - - // Skip pull requests - if (issue.pull_request) return; - - const repoFullName = payload.repository?.full_name; - if (!repoFullName) return; - - let authorContact: NewContact | undefined; - if (issue.user) { - authorContact = { - email: - issue.user.email || - `${issue.user.login}@users.noreply.github.com`, - name: issue.user.login, - avatar: issue.user.avatar_url ?? undefined, - }; - } - - let assigneeContact: NewContact | undefined; - const assignee = issue.assignees?.[0] || issue.assignee; - if (assignee) { - assigneeContact = { - email: - assignee.email || - `${assignee.login}@users.noreply.github.com`, - name: assignee.login, - avatar: assignee.avatar_url ?? undefined, - }; - } - - const link: NewLinkWithNotes = { - source: `github:issue:${repoId}:${issue.number}`, - type: "issue", - title: issue.title, - created: issue.created_at, - author: authorContact, - assignee: assigneeContact ?? null, - status: issue.closed_at ? "closed" : "open", - channelId: repoId, - meta: { - githubIssueNumber: issue.number, - githubRepoId: repoId, - githubRepoFullName: repoFullName, - projectId: repoId, - syncProvider: "github-issues", - syncableId: repoId, - }, - preview: issue.body || null, - notes: [], - }; - - await this.tools.integrations.saveLink(link); - } - - /** - * Handle Comment webhook events - */ - private async handleCommentWebhook( - payload: any, - repoId: string - ): Promise { - const comment = payload.comment; - const issue = payload.issue; - if (!comment || !issue) return; - - // Skip comments on pull requests - if (issue.pull_request) return; - - const repoFullName = payload.repository?.full_name; - if (!repoFullName) return; - - let commentAuthor: NewContact | undefined; - if (comment.user) { - commentAuthor = { - email: - comment.user.email || - `${comment.user.login}@users.noreply.github.com`, - name: comment.user.login, - avatar: comment.user.avatar_url ?? undefined, - }; - } - - const linkSource = `github:issue:${repoId}:${issue.number}`; - - const link: NewLinkWithNotes = { - source: linkSource, - type: "issue", - title: issue.title || `#${issue.number}`, // Placeholder; upsert by source will preserve existing title - notes: [ - { - key: `comment-${comment.id}`, - content: comment.body ?? null, - created: comment.created_at, - author: commentAuthor, - } as any, - ], - channelId: repoId, - meta: { - githubIssueNumber: issue.number, - githubRepoId: repoId, - githubRepoFullName: repoFullName, - projectId: repoId, - syncProvider: "github-issues", - syncableId: repoId, - }, - }; - - await this.tools.integrations.saveLink(link); - } - - /** - * Stop syncing a GitHub repo - */ - async stopSync(projectId: string): Promise { - // Remove webhook - const webhookId = await this.get(`webhook_id_${projectId}`); - if (webhookId) { - try { - const octokit = await this.getClient(projectId); - const { owner, repo } = await this.getRepoInfo(projectId); - await octokit.rest.repos.deleteWebhook({ - owner, - repo, - hook_id: webhookId, - }); - } catch (error) { - console.warn("Failed to delete GitHub webhook:", error); - } - await this.clear(`webhook_id_${projectId}`); - } - - // Cleanup webhook secret - await this.clear(`webhook_secret_${projectId}`); - - // Cleanup sync state - await this.clear(`sync_state_${projectId}`); - } -} - -export default GitHubIssues; diff --git a/sources/github-issues/src/index.ts b/sources/github-issues/src/index.ts deleted file mode 100644 index f704d26..0000000 --- a/sources/github-issues/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, GitHubIssues } from "./github-issues"; diff --git a/sources/github-issues/tsconfig.json b/sources/github-issues/tsconfig.json deleted file mode 100644 index b98a116..0000000 --- a/sources/github-issues/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@plotday/twister/tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["src/**/*.ts"] -} diff --git a/sources/github/src/github.ts b/sources/github/src/github.ts index 6ce43a6..814677e 100644 --- a/sources/github/src/github.ts +++ b/sources/github/src/github.ts @@ -1,12 +1,13 @@ import { - type Action, - ActionType, - type ThreadMeta, + type Link, type NewLinkWithNotes, + type Note, Source, + type ThreadMeta, type ToolBuilder, } from "@plotday/twister"; import type { NewContact } from "@plotday/twister/plot"; +import { Options } from "@plotday/twister/options"; import { AuthProvider, type AuthToken, @@ -16,29 +17,27 @@ import { } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; import { Tasks } from "@plotday/twister/tools/tasks"; +import { + startPRBatchSync, + syncPRBatch, + handlePRWebhook, + handleReviewWebhook, + handlePRCommentWebhook, + addPRComment, + updatePRStatus, +} from "./pr-sync"; +import { + startIssueBatchSync, + syncIssueBatch, + handleIssueWebhook, + handleIssueCommentWebhook, + updateIssue, + addIssueComment, +} from "./issue-sync"; -type Repository = { - id: string; - name: string; - description: string | null; - url: string | null; - owner: string | null; - defaultBranch: string | null; - private: boolean; -}; - -type SourceControlSyncOptions = { - timeMin?: Date; -}; - -type SyncState = { - page: number; - batchNumber: number; - prsProcessed: number; - initialSync: boolean; -}; +// ---------- Exported types (used by pr-sync.ts and issue-sync.ts) ---------- -type GitHubUser = { +export type GitHubUser = { id: number; login: string; avatar_url?: string; @@ -46,7 +45,7 @@ type GitHubUser = { email?: string; }; -type GitHubPullRequest = { +export type GitHubPullRequest = { id: number; number: number; title: string; @@ -63,7 +62,7 @@ type GitHubPullRequest = { base: { repo: { full_name: string; owner: { login: string }; name: string } }; }; -type GitHubIssueComment = { +export type GitHubIssueComment = { id: number; body: string; created_at: string; @@ -72,7 +71,7 @@ type GitHubIssueComment = { html_url: string; }; -type GitHubReview = { +export type GitHubReview = { id: number; body: string; state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING"; @@ -93,20 +92,16 @@ type GitHubRepo = { }; /** - * GitHub source control source + * GitHub source — syncs pull requests and issues from GitHub repositories. * - * Implements the SourceControlSource interface for syncing GitHub repositories - * and pull requests with Plot threads. + * Options: + * - syncPullRequests: boolean (default: true) — sync PRs, reviews, and PR comments + * - syncIssues: boolean (default: true) — sync issues and issue comments */ export class GitHub extends Source { static readonly PROVIDER = AuthProvider.GitHub; static readonly SCOPES = ["repo"]; - /** Days of recently closed/merged PRs to include in sync */ - private static readonly RECENT_DAYS = 30; - /** PRs per page for batch sync */ - private static readonly PAGE_SIZE = 50; - readonly provider = AuthProvider.GitHub; readonly scopes = GitHub.SCOPES; readonly linkTypes = [ @@ -122,20 +117,47 @@ export class GitHub extends Source { { status: "merged", label: "Merged" }, ], }, + { + type: "issue", + label: "Issue", + logo: "https://api.iconify.design/logos/github-icon.svg", + logoDark: "https://api.iconify.design/simple-icons/github.svg?color=%23ffffff", + logoMono: "https://api.iconify.design/simple-icons/github.svg", + statuses: [ + { status: "open", label: "Open" }, + { status: "closed", label: "Closed" }, + ], + }, ]; build(build: ToolBuilder) { return { + options: build(Options, { + syncPullRequests: { + type: "boolean" as const, + label: "Sync Pull Requests", + description: "Sync pull requests, reviews, and PR comments", + default: true, + }, + syncIssues: { + type: "boolean" as const, + label: "Sync Issues", + description: "Sync issues and issue comments", + default: true, + }, + }), integrations: build(Integrations), network: build(Network, { urls: ["https://api.github.com/*"] }), tasks: build(Tasks), }; } + // ---------- Public helpers (used by pr-sync.ts / issue-sync.ts) ---------- + /** * Make an authenticated GitHub API request */ - private async githubFetch( + async githubFetch( token: string, path: string, options?: RequestInit, @@ -154,7 +176,7 @@ export class GitHub extends Source { /** * Get an authenticated token for a channel repository */ - private async getToken(channelId: string): Promise { + async getToken(channelId: string): Promise { const authToken = await this.tools.integrations.get(channelId); if (!authToken) { throw new Error("No GitHub authentication token available"); @@ -162,6 +184,64 @@ export class GitHub extends Source { return authToken.token; } + /** + * Convert a GitHub user to a NewContact using noreply email + */ + userToContact(user: GitHubUser): NewContact { + return { + email: `${user.id}+${user.login}@users.noreply.github.com`, + name: user.login, + avatar: user.avatar_url ?? undefined, + }; + } + + /** + * Save a link via integrations + */ + async saveLink(link: NewLinkWithNotes): Promise { + await this.tools.integrations.saveLink(link); + } + + /** + * Create a persistent callback (public wrapper for this.callback) + */ + // @ts-ignore - simplified signature for public access + async createCallback(fn: any, ...extraArgs: any[]): Promise { + return this.callback(fn, ...extraArgs); + } + + // Public wrappers for protected Twist methods (used by helper files) + override async get(key: string): Promise { + return super.get(key); + } + override async set(key: string, value: T): Promise { + return super.set(key, value); + } + override async clear(key: string): Promise { + return super.clear(key); + } + override async runTask(callback: any, options?: { runAt?: Date }): Promise { + return super.runTask(callback, options); + } + + // ---------- Public batch sync entry points (called by helper files via callback) ---------- + + /** + * Callback entry point for PR batch sync + */ + async syncPRBatch(repositoryId: string): Promise { + await syncPRBatch(this, repositoryId); + } + + /** + * Callback entry point for issue batch sync + */ + async syncIssueBatch(repositoryId: string): Promise { + await syncIssueBatch(this, repositoryId); + } + + // ---------- Channel lifecycle ---------- + /** * Returns available GitHub repositories as channel resources. */ @@ -172,7 +252,6 @@ export class GitHub extends Source { const repos: GitHubRepo[] = []; let page = 1; - // Paginate through all repos while (true) { const response = await fetch( `https://api.github.com/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=100&page=${page}`, @@ -207,9 +286,17 @@ export class GitHub extends Source { async onChannelEnabled(channel: Channel): Promise { await this.set(`sync_enabled_${channel.id}`, true); - // Setup webhook and start initial sync + // Setup webhook subscribing to all event types await this.setupWebhook(channel.id); - await this.startBatchSync(channel.id); + + // Start initial sync for enabled types + const options = this.tools.options as { syncPullRequests: boolean; syncIssues: boolean }; + if (options.syncPullRequests) { + await startPRBatchSync(this, channel.id, true); + } + if (options.syncIssues) { + await startIssueBatchSync(this, channel.id, true); + } } /** @@ -221,94 +308,65 @@ export class GitHub extends Source { } /** - * Get list of repositories + * Called when options are changed (e.g. toggling PR or issue sync). + * Starts sync for newly enabled types on all active channels. + * Disabled types simply stop receiving webhook events — existing items remain. */ - async getRepositories(repositoryId: string): Promise { - const token = await this.getToken(repositoryId); - - const repos: GitHubRepo[] = []; - let page = 1; - - while (true) { - const response = await this.githubFetch( - token, - `/user/repos?affiliation=owner,collaborator,organization_member&sort=pushed&per_page=100&page=${page}`, - ); + async onOptionsChanged( + oldOptions: Record, + newOptions: Record, + ): Promise { + // Find all enabled channels + const channelKeys = await this.tools.store.list("sync_enabled_"); - if (!response.ok) break; + for (const key of channelKeys) { + const channelId = key.replace("sync_enabled_", ""); - const batch: GitHubRepo[] = await response.json(); - if (batch.length === 0) break; + // PRs toggled on → start PR sync + if (!oldOptions.syncPullRequests && newOptions.syncPullRequests) { + await startPRBatchSync(this, channelId, true); + } - repos.push(...batch); - if (batch.length < 100) break; - page++; + // Issues toggled on → start issue sync + if (!oldOptions.syncIssues && newOptions.syncIssues) { + await startIssueBatchSync(this, channelId, true); + } } - - return repos.map((repo) => ({ - id: repo.full_name, - name: repo.name, - description: repo.description, - url: repo.html_url, - owner: repo.owner.login, - defaultBranch: repo.default_branch, - private: repo.private, - })); } + // ---------- Write-back hooks ---------- + /** - * Start syncing pull requests from a repository + * Called when a link created by this source is updated by the user. */ - async startSync( - options: { - repositoryId: string; - } & SourceControlSyncOptions, - ): Promise { - const { repositoryId } = options; - - // Setup webhook for real-time updates - await this.setupWebhook(repositoryId); - - // Start initial batch sync - await this.startBatchSync(repositoryId); + async onLinkUpdated(link: Link): Promise { + if (link.type === "pull_request") { + await updatePRStatus(this, link); + } else if (link.type === "issue") { + await updateIssue(this, link); + } } /** - * Stop syncing a repository + * Called when a note is created on a thread owned by this source. */ - async stopSync(repositoryId: string): Promise { - // Remove webhook - const webhookId = await this.get(`webhook_id_${repositoryId}`); - if (webhookId) { - try { - const token = await this.getToken(repositoryId); - const [owner, repo] = repositoryId.split("/"); - await this.githubFetch( - token, - `/repos/${owner}/${repo}/hooks/${webhookId}`, - { method: "DELETE" }, - ); - } catch (error) { - console.warn("Failed to delete GitHub webhook:", error); - } - await this.clear(`webhook_id_${repositoryId}`); + async onNoteCreated(note: Note, meta: ThreadMeta): Promise { + if (meta.prNumber) { + await addPRComment(this, meta, note.content ?? ""); + } else if (meta.issueNumber) { + await addIssueComment(this, meta, note.content ?? ""); } - - // Cleanup webhook secret - await this.clear(`webhook_secret_${repositoryId}`); - - // Cleanup sync state - await this.clear(`sync_state_${repositoryId}`); } - // ---------- Webhook setup ---------- + // ---------- Webhook ---------- /** - * Setup GitHub webhook for real-time PR updates + * Setup GitHub webhook for real-time updates. + * Subscribes to all event types regardless of options, + * so toggling on later works without re-creating the webhook. */ private async setupWebhook(repositoryId: string): Promise { try { - // Generate a webhook secret for signature verification const secret = crypto.randomUUID(); const webhookUrl = await this.tools.network.createWebhook( @@ -317,7 +375,6 @@ export class GitHub extends Source { repositoryId, ); - // Skip webhook setup for localhost (development mode) if ( webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1") @@ -342,6 +399,7 @@ export class GitHub extends Source { events: [ "pull_request", "pull_request_review", + "issues", "issue_comment", ], config: { @@ -403,7 +461,6 @@ export class GitHub extends Source { .map((b) => b.toString(16).padStart(2, "0")) .join(""); - // Constant-time comparison if (expected.length !== signature.length) return false; let result = 0; for (let i = 0; i < expected.length; i++) { @@ -413,13 +470,13 @@ export class GitHub extends Source { } /** - * Handle incoming webhook events from GitHub + * Handle incoming webhook events from GitHub. + * Routes to PR or issue handlers based on event type and current options. */ private async onWebhook( request: WebhookRequest, repositoryId: string, ): Promise { - // Verify webhook signature const secret = await this.get(`webhook_secret_${repositoryId}`); if (!secret) { console.warn("GitHub webhook secret not found, skipping verification"); @@ -453,525 +510,59 @@ export class GitHub extends Source { ? JSON.parse(request.body) : request.body; + const options = this.tools.options as { syncPullRequests: boolean; syncIssues: boolean }; + if (event === "pull_request") { - await this.handlePRWebhook(payload, repositoryId); + if (options.syncPullRequests) { + await handlePRWebhook(this, payload, repositoryId); + } } else if (event === "pull_request_review") { - await this.handleReviewWebhook(payload, repositoryId); - } else if (event === "issue_comment") { - // Only handle comments on PRs (issue_comment fires for both issues and PRs) - if (payload.issue?.pull_request) { - await this.handleCommentWebhook(payload, repositoryId); + if (options.syncPullRequests) { + await handleReviewWebhook(this, payload, repositoryId); } - } - } - - /** - * Handle pull_request webhook event - */ - private async handlePRWebhook( - payload: any, - repositoryId: string, - ): Promise { - const pr: GitHubPullRequest = payload.pull_request; - if (!pr) return; - - const [owner, repo] = repositoryId.split("/"); - - const authorContact = this.userToContact(pr.user); - const assigneeContact = pr.assignee - ? this.userToContact(pr.assignee) - : null; - - const thread: NewLinkWithNotes = { - source: `github:pr:${owner}/${repo}/${pr.number}`, - type: "pull_request", - title: pr.title, - created: new Date(pr.created_at), - author: authorContact, - assignee: assigneeContact, - status: pr.merged_at - ? "merged" - : pr.state === "closed" - ? "closed" - : "open", - ...(pr.state === "closed" && !pr.merged_at ? { archived: true } : {}), - channelId: repositoryId, - meta: { - provider: "github", - owner, - repo, - prNumber: pr.number, - prNodeId: pr.id, - syncProvider: "github", - syncableId: repositoryId, - }, - preview: pr.body || null, - notes: [], - }; - - await this.tools.integrations.saveLink(thread); - } - - /** - * Handle pull_request_review webhook event - */ - private async handleReviewWebhook( - payload: any, - repositoryId: string, - ): Promise { - const review: GitHubReview = payload.review; - const pr: GitHubPullRequest = payload.pull_request; - if (!review || !pr) return; - - // Skip empty COMMENTED reviews (just inline comments with no summary) - if (review.state === "COMMENTED" && !review.body) return; - - const [owner, repo] = repositoryId.split("/"); - const reviewAuthor = this.userToContact(review.user); - - const prefix = this.reviewStatePrefix(review.state); - const content = prefix - ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` - : review.body || null; - - const thread: NewLinkWithNotes = { - source: `github:pr:${owner}/${repo}/${pr.number}`, - type: "pull_request", - title: pr.title, - notes: [ - { - key: `review-${review.id}`, - content, - created: new Date(review.submitted_at), - author: reviewAuthor, - } as any, - ], - channelId: repositoryId, - meta: { - provider: "github", - owner, - repo, - prNumber: pr.number, - prNodeId: pr.id, - syncProvider: "github", - syncableId: repositoryId, - }, - }; - - await this.tools.integrations.saveLink(thread); - } - - /** - * Handle issue_comment webhook event (for PR comments) - */ - private async handleCommentWebhook( - payload: any, - repositoryId: string, - ): Promise { - const comment: GitHubIssueComment = payload.comment; - const issue = payload.issue; - if (!comment || !issue) return; - - const [owner, repo] = repositoryId.split("/"); - const prNumber = issue.number; - const commentAuthor = this.userToContact(comment.user); - - const thread: NewLinkWithNotes = { - source: `github:pr:${owner}/${repo}/${prNumber}`, - type: "pull_request", - title: issue.title, - notes: [ - { - key: `comment-${comment.id}`, - content: comment.body, - created: new Date(comment.created_at), - author: commentAuthor, - } as any, - ], - channelId: repositoryId, - meta: { - provider: "github", - owner, - repo, - prNumber, - syncProvider: "github", - syncableId: repositoryId, - }, - }; - - await this.tools.integrations.saveLink(thread); - } - - // ---------- Batch sync ---------- - - /** - * Initialize batch sync process - */ - private async startBatchSync(repositoryId: string): Promise { - await this.set(`sync_state_${repositoryId}`, { - page: 1, - batchNumber: 1, - prsProcessed: 0, - initialSync: true, - }); - - const batchCallback = await this.callback(this.syncBatch, repositoryId); - await this.runTask(batchCallback); - } - - /** - * Process a batch of pull requests - */ - private async syncBatch(repositoryId: string): Promise { - const state = await this.get(`sync_state_${repositoryId}`); - if (!state) { - throw new Error(`Sync state not found for repository ${repositoryId}`); - } - - const token = await this.getToken(repositoryId); - const [owner, repo] = repositoryId.split("/"); - - // Fetch batch of PRs (all states, sorted by updated) - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=${GitHub.PAGE_SIZE}&page=${state.page}`, - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch PRs: ${response.status} ${await response.text()}`, - ); - } - - const prs: GitHubPullRequest[] = await response.json(); - - // Filter: open PRs + recently closed/merged (within RECENT_DAYS) - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - GitHub.RECENT_DAYS); - - const relevantPRs = prs.filter((pr) => { - if (pr.state === "open") return true; - // Closed/merged: include if recently updated - const closedDate = pr.merged_at || pr.closed_at; - if (closedDate && new Date(closedDate) >= cutoff) return true; - return false; - }); - - // If all PRs in this page are beyond the cutoff, stop syncing - const allBeyondCutoff = - prs.length > 0 && - prs.every((pr) => { - if (pr.state === "open") return false; - const closedDate = pr.merged_at || pr.closed_at; - return closedDate && new Date(closedDate) < cutoff; - }); - - // Process each relevant PR - for (const pr of relevantPRs) { - const thread = await this.convertPRToThread( - token, - owner, - repo, - pr, - repositoryId, - state.initialSync, - ); - - if (thread) { - thread.channelId = repositoryId; - thread.meta = { - ...thread.meta, - syncProvider: "github", - syncableId: repositoryId, - }; - await this.tools.integrations.saveLink(thread); + } else if (event === "issues") { + if (options.syncIssues) { + await handleIssueWebhook(this, payload, repositoryId); } - } - - // Continue to next page if there are more PRs and not all beyond cutoff - if (prs.length === GitHub.PAGE_SIZE && !allBeyondCutoff) { - await this.set(`sync_state_${repositoryId}`, { - page: state.page + 1, - batchNumber: state.batchNumber + 1, - prsProcessed: state.prsProcessed + relevantPRs.length, - initialSync: state.initialSync, - }); - - const nextBatch = await this.callback(this.syncBatch, repositoryId); - await this.runTask(nextBatch); - } else { - // Sync complete - await this.clear(`sync_state_${repositoryId}`); - } - } - - /** - * Convert a GitHub PR to a NewLinkWithNotes - */ - private async convertPRToThread( - token: string, - owner: string, - repo: string, - pr: GitHubPullRequest, - repositoryId: string, - initialSync: boolean, - ): Promise { - const authorContact = this.userToContact(pr.user); - const assigneeContact = pr.assignee - ? this.userToContact(pr.assignee) - : null; - - // Build thread-level actions - const threadActions: Action[] = [ - { - type: ActionType.external, - title: `Open in GitHub`, - url: pr.html_url, - }, - ]; - - const notes: any[] = []; - - const hasDescription = pr.body && pr.body.trim().length > 0; - notes.push({ - key: "description", - content: hasDescription ? pr.body : null, - created: new Date(pr.created_at), - author: authorContact, - }); - - // Fetch general comments (issue comments API) - try { - const commentsResponse = await this.githubFetch( - token, - `/repos/${owner}/${repo}/issues/${pr.number}/comments?per_page=100`, - ); - if (commentsResponse.ok) { - const comments: GitHubIssueComment[] = await commentsResponse.json(); - for (const comment of comments) { - const commentAuthor = this.userToContact(comment.user); - notes.push({ - key: `comment-${comment.id}`, - content: comment.body, - created: new Date(comment.created_at), - author: commentAuthor, - }); + } else if (event === "issue_comment") { + // issue_comment fires for both issues and PRs + if (payload.issue?.pull_request) { + if (options.syncPullRequests) { + await handlePRCommentWebhook(this, payload, repositoryId); } - } - } catch (error) { - console.error("Error fetching PR comments:", error); - } - - // Fetch review summaries - try { - const reviewsResponse = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls/${pr.number}/reviews?per_page=100`, - ); - if (reviewsResponse.ok) { - const reviews: GitHubReview[] = await reviewsResponse.json(); - for (const review of reviews) { - // Skip empty COMMENTED reviews (just inline comments with no summary) - if (review.state === "COMMENTED" && !review.body) continue; - - const reviewAuthor = this.userToContact(review.user); - const prefix = this.reviewStatePrefix(review.state); - const content = prefix - ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` - : review.body || null; - - if (content) { - notes.push({ - key: `review-${review.id}`, - content, - created: new Date(review.submitted_at), - author: reviewAuthor, - }); - } + } else { + if (options.syncIssues) { + await handleIssueCommentWebhook(this, payload, repositoryId); } } - } catch (error) { - console.error("Error fetching PR reviews:", error); } - - const thread: NewLinkWithNotes = { - source: `github:pr:${owner}/${repo}/${pr.number}`, - type: "pull_request", - title: pr.title, - created: new Date(pr.created_at), - author: authorContact, - assignee: assigneeContact, - status: pr.merged_at - ? "merged" - : pr.state === "closed" - ? "closed" - : "open", - meta: { - provider: "github", - owner, - repo, - prNumber: pr.number, - prNodeId: pr.id, - }, - actions: threadActions, - sourceUrl: pr.html_url, - notes, - preview: hasDescription ? pr.body : null, - ...(initialSync ? { unread: false } : {}), - ...(initialSync ? { archived: false } : {}), - // Archive closed-without-merge PRs on incremental sync only - ...(!initialSync && pr.state === "closed" && !pr.merged_at - ? { archived: true } - : {}), - }; - - return thread; } - // ---------- Bidirectional methods ---------- + // ---------- Sync management ---------- /** - * Add a general comment to a pull request + * Stop syncing a repository (cleanup webhooks and state) */ - async addPRComment( - meta: ThreadMeta, - body: string, - noteId?: string, - ): Promise { - const owner = meta.owner as string; - const repo = meta.repo as string; - const prNumber = meta.prNumber as number; - const syncableId = `${owner}/${repo}`; - - if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in thread meta"); - } - - const token = await this.getToken(syncableId); - - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/issues/${prNumber}/comments`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ body }), - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to add PR comment: ${response.status} ${await response.text()}`, - ); - } - - const comment = await response.json(); - if (comment?.id) { - return `comment-${comment.id}`; - } - } - - /** - * Update a PR's review status (approve or request changes) - */ - async updatePRStatus(link: import("@plotday/twister").Link): Promise { - if (!link.meta) return; - - const owner = link.meta.owner as string; - const repo = link.meta.repo as string; - const prNumber = link.meta.prNumber as number; - const syncableId = `${owner}/${repo}`; - - if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in link meta"); - } - - const token = await this.getToken(syncableId); - - // Map link status to PR review event - const isDone = link.status === "done" || link.status === "closed" || link.status === "approved"; - if (isDone) { - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - event: "APPROVE", - }), - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to update PR status: ${response.status}`, + async stopSync(repositoryId: string): Promise { + const webhookId = await this.get(`webhook_id_${repositoryId}`); + if (webhookId) { + try { + const token = await this.getToken(repositoryId); + const [owner, repo] = repositoryId.split("/"); + await this.githubFetch( + token, + `/repos/${owner}/${repo}/hooks/${webhookId}`, + { method: "DELETE" }, ); + } catch (error) { + console.warn("Failed to delete GitHub webhook:", error); } + await this.clear(`webhook_id_${repositoryId}`); } - } - - /** - * Close a pull request without merging - */ - async closePR(meta: ThreadMeta): Promise { - const owner = meta.owner as string; - const repo = meta.repo as string; - const prNumber = meta.prNumber as number; - const syncableId = `${owner}/${repo}`; - - if (!owner || !repo || !prNumber) { - throw new Error("Owner, repo, and prNumber required in thread meta"); - } - - const token = await this.getToken(syncableId); - - const response = await this.githubFetch( - token, - `/repos/${owner}/${repo}/pulls/${prNumber}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: "closed" }), - }, - ); - - if (!response.ok) { - throw new Error( - `Failed to close PR: ${response.status} ${await response.text()}`, - ); - } - } - - // ---------- Helpers ---------- - /** - * Convert a GitHub user to a NewContact using noreply email - */ - private userToContact(user: GitHubUser): NewContact { - return { - email: `${user.id}+${user.login}@users.noreply.github.com`, - name: user.login, - avatar: user.avatar_url ?? undefined, - }; - } - - /** - * Get a prefix for review state - */ - private reviewStatePrefix( - state: GitHubReview["state"], - ): string | null { - switch (state) { - case "APPROVED": - return "**Approved**"; - case "CHANGES_REQUESTED": - return "**Changes Requested**"; - case "DISMISSED": - return "**Dismissed**"; - default: - return null; - } + await this.clear(`webhook_secret_${repositoryId}`); + await this.clear(`pr_sync_state_${repositoryId}`); + await this.clear(`issue_sync_state_${repositoryId}`); } } diff --git a/sources/github/src/issue-sync.ts b/sources/github/src/issue-sync.ts new file mode 100644 index 0000000..024b9f9 --- /dev/null +++ b/sources/github/src/issue-sync.ts @@ -0,0 +1,405 @@ +import { + type Action, + ActionType, + type NewLinkWithNotes, +} from "@plotday/twister"; +import type { GitHub, GitHubIssueComment } from "./github"; + +/** Issues per page for batch sync */ +const PAGE_SIZE = 50; + +type IssueSyncState = { + page: number; + batchNumber: number; + issuesProcessed: number; + initialSync: boolean; + phase: "open" | "closed"; +}; + +/** + * Initialize batch sync process for issues + */ +export async function startIssueBatchSync( + source: GitHub, + repositoryId: string, + initialSync: boolean, +): Promise { + await source.set(`issue_sync_state_${repositoryId}`, { + page: 1, + batchNumber: 1, + issuesProcessed: 0, + initialSync, + phase: "open", + } satisfies IssueSyncState); + + const batchCallback = await source.createCallback(source.syncIssueBatch, repositoryId); + await source.runTask(batchCallback); +} + +/** + * Process a batch of issues + */ +export async function syncIssueBatch( + source: GitHub, + repositoryId: string, +): Promise { + const state = await source.get(`issue_sync_state_${repositoryId}`); + if (!state) { + throw new Error(`Issue sync state not found for repository ${repositoryId}`); + } + + const token = await source.getToken(repositoryId); + const [owner, repo] = repositoryId.split("/"); + + // Build request URL based on phase + let url = `/repos/${owner}/${repo}/issues?state=${state.phase}&per_page=${PAGE_SIZE}&page=${state.page}&sort=updated&direction=desc`; + + // For closed phase, only fetch recently closed (last 30 days) + if (state.phase === "closed") { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + url += `&since=${thirtyDaysAgo.toISOString()}`; + } + + const response = await source.githubFetch(token, url); + + if (!response.ok) { + throw new Error( + `Failed to fetch issues: ${response.status} ${await response.text()}`, + ); + } + + const issues: any[] = await response.json(); + + // Process each issue (filter out PRs — GitHub returns PRs in issues endpoint) + let processedInBatch = 0; + for (const issue of issues) { + if (issue.pull_request) continue; + + const link = await convertIssueToLink( + source, + token, + owner, + repo, + issue, + repositoryId, + state.initialSync, + ); + + if (link) { + link.channelId = repositoryId; + link.meta = { + ...link.meta, + syncProvider: "github", + syncableId: repositoryId, + }; + await source.saveLink(link); + processedInBatch++; + } + } + + const hasMorePages = issues.length === PAGE_SIZE; + + if (hasMorePages) { + await source.set(`issue_sync_state_${repositoryId}`, { + page: state.page + 1, + batchNumber: state.batchNumber + 1, + issuesProcessed: state.issuesProcessed + processedInBatch, + initialSync: state.initialSync, + phase: state.phase, + } satisfies IssueSyncState); + + const nextBatch = await source.createCallback(source.syncIssueBatch, repositoryId); + await source.runTask(nextBatch); + } else if (state.phase === "open") { + // Move to closed phase + await source.set(`issue_sync_state_${repositoryId}`, { + page: 1, + batchNumber: state.batchNumber + 1, + issuesProcessed: state.issuesProcessed + processedInBatch, + initialSync: state.initialSync, + phase: "closed", + } satisfies IssueSyncState); + + const closedBatch = await source.createCallback(source.syncIssueBatch, repositoryId); + await source.runTask(closedBatch); + } else { + // Both phases complete + await source.clear(`issue_sync_state_${repositoryId}`); + } +} + +/** + * Convert a GitHub issue to a NewLinkWithNotes + */ +async function convertIssueToLink( + source: GitHub, + token: string, + owner: string, + repo: string, + issue: any, + repositoryId: string, + initialSync: boolean, +): Promise { + const authorContact = issue.user ? source.userToContact(issue.user) : undefined; + + const assignee = issue.assignees?.[0] || issue.assignee; + const assigneeContact = assignee ? source.userToContact(assignee) : undefined; + + const description = issue.body || ""; + const hasDescription = description.trim().length > 0; + + const threadActions: Action[] = []; + if (issue.html_url) { + threadActions.push({ + type: ActionType.external, + title: "Open in GitHub", + url: issue.html_url, + }); + } + + const notes: any[] = []; + + notes.push({ + key: "description", + content: hasDescription ? description : null, + created: issue.created_at, + author: authorContact, + }); + + // Fetch comments + try { + let commentPage = 1; + let hasMoreComments = true; + + while (hasMoreComments) { + const commentsResponse = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${issue.number}/comments?per_page=100&page=${commentPage}`, + ); + + if (!commentsResponse.ok) break; + + const comments: GitHubIssueComment[] = await commentsResponse.json(); + for (const comment of comments) { + const commentAuthor = source.userToContact(comment.user); + notes.push({ + key: `comment-${comment.id}`, + content: comment.body ?? null, + created: new Date(comment.created_at), + author: commentAuthor, + }); + } + + hasMoreComments = comments.length === 100; + commentPage++; + } + } catch (error) { + console.error("Error fetching issue comments:", error); + } + + const link: NewLinkWithNotes = { + source: `github:issue:${owner}/${repo}/${issue.number}`, + type: "issue", + title: issue.title, + created: issue.created_at, + author: authorContact, + assignee: assigneeContact ?? null, + status: issue.closed_at ? "closed" : "open", + meta: { + provider: "github", + owner, + repo, + issueNumber: issue.number, + }, + actions: threadActions.length > 0 ? threadActions : undefined, + sourceUrl: issue.html_url ?? null, + notes, + preview: hasDescription ? description : null, + ...(initialSync ? { unread: false } : {}), + ...(initialSync ? { archived: false } : {}), + }; + + return link; +} + +/** + * Handle issues webhook event + */ +export async function handleIssueWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const issue = payload.issue; + if (!issue) return; + + // Skip pull requests + if (issue.pull_request) return; + + const [owner, repo] = repositoryId.split("/"); + + const authorContact = issue.user ? source.userToContact(issue.user) : undefined; + const assignee = issue.assignees?.[0] || issue.assignee; + const assigneeContact = assignee ? source.userToContact(assignee) : undefined; + + const link: NewLinkWithNotes = { + source: `github:issue:${owner}/${repo}/${issue.number}`, + type: "issue", + title: issue.title, + created: issue.created_at, + author: authorContact, + assignee: assigneeContact ?? null, + status: issue.closed_at ? "closed" : "open", + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + issueNumber: issue.number, + syncProvider: "github", + syncableId: repositoryId, + }, + preview: issue.body || null, + notes: [], + }; + + await source.saveLink(link); +} + +/** + * Handle issue_comment webhook event (for issue comments, not PR comments) + */ +export async function handleIssueCommentWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const comment: GitHubIssueComment = payload.comment; + const issue = payload.issue; + if (!comment || !issue) return; + + // Skip comments on pull requests + if (issue.pull_request) return; + + const [owner, repo] = repositoryId.split("/"); + const commentAuthor = source.userToContact(comment.user); + + const link: NewLinkWithNotes = { + source: `github:issue:${owner}/${repo}/${issue.number}`, + type: "issue", + title: issue.title, + notes: [ + { + key: `comment-${comment.id}`, + content: comment.body ?? null, + created: comment.created_at, + author: commentAuthor, + } as any, + ], + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + issueNumber: issue.number, + syncProvider: "github", + syncableId: repositoryId, + }, + }; + + await source.saveLink(link); +} + +/** + * Update an issue's status and assignee + */ +export async function updateIssue( + source: GitHub, + link: import("@plotday/twister").Link, +): Promise { + if (!link.meta) return; + + const owner = link.meta.owner as string; + const repo = link.meta.repo as string; + const issueNumber = link.meta.issueNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !issueNumber) { + throw new Error("Owner, repo, and issueNumber required in link meta"); + } + + const token = await source.getToken(syncableId); + + const updateFields: Record = {}; + + const isDone = link.status === "done" || link.status === "closed" || link.status === "completed"; + updateFields.state = isDone ? "closed" : "open"; + + if (link.assignee) { + if (link.assignee.name) { + updateFields.assignees = [link.assignee.name]; + } + } else { + updateFields.assignees = []; + } + + if (Object.keys(updateFields).length > 0) { + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${issueNumber}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateFields), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to update issue: ${response.status} ${await response.text()}`, + ); + } + } +} + +/** + * Add a comment to a GitHub issue + */ +export async function addIssueComment( + source: GitHub, + meta: import("@plotday/twister").ThreadMeta, + body: string, +): Promise { + const owner = meta.owner as string; + const repo = meta.repo as string; + const issueNumber = meta.issueNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !issueNumber) { + throw new Error("Owner, repo, and issueNumber required in thread meta"); + } + + const token = await source.getToken(syncableId); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to add issue comment: ${response.status} ${await response.text()}`, + ); + } + + const comment = await response.json(); + if (comment?.id) { + return `comment-${comment.id}`; + } +} diff --git a/sources/github/src/pr-sync.ts b/sources/github/src/pr-sync.ts new file mode 100644 index 0000000..5e95783 --- /dev/null +++ b/sources/github/src/pr-sync.ts @@ -0,0 +1,515 @@ +import { + type Action, + ActionType, + type NewLinkWithNotes, +} from "@plotday/twister"; +import type { GitHub, GitHubPullRequest, GitHubReview, GitHubIssueComment } from "./github"; + +/** Days of recently closed/merged PRs to include in sync */ +const RECENT_DAYS = 30; +/** PRs per page for batch sync */ +const PAGE_SIZE = 50; + +type PRSyncState = { + page: number; + batchNumber: number; + prsProcessed: number; + initialSync: boolean; +}; + +/** + * Initialize batch sync process for pull requests + */ +export async function startPRBatchSync( + source: GitHub, + repositoryId: string, + initialSync: boolean, +): Promise { + await source.set(`pr_sync_state_${repositoryId}`, { + page: 1, + batchNumber: 1, + prsProcessed: 0, + initialSync, + } satisfies PRSyncState); + + const batchCallback = await source.createCallback(source.syncPRBatch, repositoryId); + await source.runTask(batchCallback); +} + +/** + * Process a batch of pull requests + */ +export async function syncPRBatch( + source: GitHub, + repositoryId: string, +): Promise { + const state = await source.get(`pr_sync_state_${repositoryId}`); + if (!state) { + throw new Error(`PR sync state not found for repository ${repositoryId}`); + } + + const token = await source.getToken(repositoryId); + const [owner, repo] = repositoryId.split("/"); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=${PAGE_SIZE}&page=${state.page}`, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch PRs: ${response.status} ${await response.text()}`, + ); + } + + const prs: GitHubPullRequest[] = await response.json(); + + // Filter: open PRs + recently closed/merged (within RECENT_DAYS) + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - RECENT_DAYS); + + const relevantPRs = prs.filter((pr) => { + if (pr.state === "open") return true; + const closedDate = pr.merged_at || pr.closed_at; + if (closedDate && new Date(closedDate) >= cutoff) return true; + return false; + }); + + const allBeyondCutoff = + prs.length > 0 && + prs.every((pr) => { + if (pr.state === "open") return false; + const closedDate = pr.merged_at || pr.closed_at; + return closedDate && new Date(closedDate) < cutoff; + }); + + for (const pr of relevantPRs) { + const thread = await convertPRToThread( + source, + token, + owner, + repo, + pr, + repositoryId, + state.initialSync, + ); + + if (thread) { + thread.channelId = repositoryId; + thread.meta = { + ...thread.meta, + syncProvider: "github", + syncableId: repositoryId, + }; + await source.saveLink(thread); + } + } + + if (prs.length === PAGE_SIZE && !allBeyondCutoff) { + await source.set(`pr_sync_state_${repositoryId}`, { + page: state.page + 1, + batchNumber: state.batchNumber + 1, + prsProcessed: state.prsProcessed + relevantPRs.length, + initialSync: state.initialSync, + } satisfies PRSyncState); + + const nextBatch = await source.createCallback(source.syncPRBatch, repositoryId); + await source.runTask(nextBatch); + } else { + await source.clear(`pr_sync_state_${repositoryId}`); + } +} + +/** + * Convert a GitHub PR to a NewLinkWithNotes + */ +async function convertPRToThread( + source: GitHub, + token: string, + owner: string, + repo: string, + pr: GitHubPullRequest, + repositoryId: string, + initialSync: boolean, +): Promise { + const authorContact = source.userToContact(pr.user); + const assigneeContact = pr.assignee + ? source.userToContact(pr.assignee) + : null; + + const threadActions: Action[] = [ + { + type: ActionType.external, + title: `Open in GitHub`, + url: pr.html_url, + }, + ]; + + const notes: any[] = []; + + const hasDescription = pr.body && pr.body.trim().length > 0; + notes.push({ + key: "description", + content: hasDescription ? pr.body : null, + created: new Date(pr.created_at), + author: authorContact, + }); + + // Fetch general comments + try { + const commentsResponse = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${pr.number}/comments?per_page=100`, + ); + if (commentsResponse.ok) { + const comments: GitHubIssueComment[] = await commentsResponse.json(); + for (const comment of comments) { + const commentAuthor = source.userToContact(comment.user); + notes.push({ + key: `comment-${comment.id}`, + content: comment.body, + created: new Date(comment.created_at), + author: commentAuthor, + }); + } + } + } catch (error) { + console.error("Error fetching PR comments:", error); + } + + // Fetch review summaries + try { + const reviewsResponse = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls/${pr.number}/reviews?per_page=100`, + ); + if (reviewsResponse.ok) { + const reviews: GitHubReview[] = await reviewsResponse.json(); + for (const review of reviews) { + if (review.state === "COMMENTED" && !review.body) continue; + + const reviewAuthor = source.userToContact(review.user); + const prefix = reviewStatePrefix(review.state); + const content = prefix + ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` + : review.body || null; + + if (content) { + notes.push({ + key: `review-${review.id}`, + content, + created: new Date(review.submitted_at), + author: reviewAuthor, + }); + } + } + } + } catch (error) { + console.error("Error fetching PR reviews:", error); + } + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${pr.number}`, + type: "pull_request", + title: pr.title, + created: new Date(pr.created_at), + author: authorContact, + assignee: assigneeContact, + status: pr.merged_at + ? "merged" + : pr.state === "closed" + ? "closed" + : "open", + meta: { + provider: "github", + owner, + repo, + prNumber: pr.number, + prNodeId: pr.id, + }, + actions: threadActions, + sourceUrl: pr.html_url, + notes, + preview: hasDescription ? pr.body : null, + ...(initialSync ? { unread: false } : {}), + ...(initialSync ? { archived: false } : {}), + ...(!initialSync && pr.state === "closed" && !pr.merged_at + ? { archived: true } + : {}), + }; + + return thread; +} + +/** + * Handle pull_request webhook event + */ +export async function handlePRWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const pr: GitHubPullRequest = payload.pull_request; + if (!pr) return; + + const [owner, repo] = repositoryId.split("/"); + + const authorContact = source.userToContact(pr.user); + const assigneeContact = pr.assignee + ? source.userToContact(pr.assignee) + : null; + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${pr.number}`, + type: "pull_request", + title: pr.title, + created: new Date(pr.created_at), + author: authorContact, + assignee: assigneeContact, + status: pr.merged_at + ? "merged" + : pr.state === "closed" + ? "closed" + : "open", + ...(pr.state === "closed" && !pr.merged_at ? { archived: true } : {}), + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + prNumber: pr.number, + prNodeId: pr.id, + syncProvider: "github", + syncableId: repositoryId, + }, + preview: pr.body || null, + notes: [], + }; + + await source.saveLink(thread); +} + +/** + * Handle pull_request_review webhook event + */ +export async function handleReviewWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const review: GitHubReview = payload.review; + const pr: GitHubPullRequest = payload.pull_request; + if (!review || !pr) return; + + if (review.state === "COMMENTED" && !review.body) return; + + const [owner, repo] = repositoryId.split("/"); + const reviewAuthor = source.userToContact(review.user); + + const prefix = reviewStatePrefix(review.state); + const content = prefix + ? `${prefix}${review.body ? `\n\n${review.body}` : ""}` + : review.body || null; + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${pr.number}`, + type: "pull_request", + title: pr.title, + notes: [ + { + key: `review-${review.id}`, + content, + created: new Date(review.submitted_at), + author: reviewAuthor, + } as any, + ], + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + prNumber: pr.number, + prNodeId: pr.id, + syncProvider: "github", + syncableId: repositoryId, + }, + }; + + await source.saveLink(thread); +} + +/** + * Handle issue_comment webhook event (for PR comments) + */ +export async function handlePRCommentWebhook( + source: GitHub, + payload: any, + repositoryId: string, +): Promise { + const comment: GitHubIssueComment = payload.comment; + const issue = payload.issue; + if (!comment || !issue) return; + + const [owner, repo] = repositoryId.split("/"); + const prNumber = issue.number; + const commentAuthor = source.userToContact(comment.user); + + const thread: NewLinkWithNotes = { + source: `github:pr:${owner}/${repo}/${prNumber}`, + type: "pull_request", + title: issue.title, + notes: [ + { + key: `comment-${comment.id}`, + content: comment.body, + created: new Date(comment.created_at), + author: commentAuthor, + } as any, + ], + channelId: repositoryId, + meta: { + provider: "github", + owner, + repo, + prNumber, + syncProvider: "github", + syncableId: repositoryId, + }, + }; + + await source.saveLink(thread); +} + +/** + * Add a general comment to a pull request + */ +export async function addPRComment( + source: GitHub, + meta: import("@plotday/twister").ThreadMeta, + body: string, + noteId?: string, +): Promise { + const owner = meta.owner as string; + const repo = meta.repo as string; + const prNumber = meta.prNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !prNumber) { + throw new Error("Owner, repo, and prNumber required in thread meta"); + } + + const token = await source.getToken(syncableId); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/issues/${prNumber}/comments`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to add PR comment: ${response.status} ${await response.text()}`, + ); + } + + const comment = await response.json(); + if (comment?.id) { + return `comment-${comment.id}`; + } +} + +/** + * Update a PR's review status (approve or request changes) + */ +export async function updatePRStatus( + source: GitHub, + link: import("@plotday/twister").Link, +): Promise { + if (!link.meta) return; + + const owner = link.meta.owner as string; + const repo = link.meta.repo as string; + const prNumber = link.meta.prNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !prNumber) { + throw new Error("Owner, repo, and prNumber required in link meta"); + } + + const token = await source.getToken(syncableId); + + const isDone = link.status === "done" || link.status === "closed" || link.status === "approved"; + if (isDone) { + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event: "APPROVE", + }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to update PR status: ${response.status}`, + ); + } + } +} + +/** + * Close a pull request without merging + */ +export async function closePR( + source: GitHub, + meta: import("@plotday/twister").ThreadMeta, +): Promise { + const owner = meta.owner as string; + const repo = meta.repo as string; + const prNumber = meta.prNumber as number; + const syncableId = `${owner}/${repo}`; + + if (!owner || !repo || !prNumber) { + throw new Error("Owner, repo, and prNumber required in thread meta"); + } + + const token = await source.getToken(syncableId); + + const response = await source.githubFetch( + token, + `/repos/${owner}/${repo}/pulls/${prNumber}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "closed" }), + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to close PR: ${response.status} ${await response.text()}`, + ); + } +} + +function reviewStatePrefix( + state: GitHubReview["state"], +): string | null { + switch (state) { + case "APPROVED": + return "**Approved**"; + case "CHANGES_REQUESTED": + return "**Changes Requested**"; + case "DISMISSED": + return "**Dismissed**"; + default: + return null; + } +} diff --git a/sources/gmail/src/gmail-api.ts b/sources/gmail/src/gmail-api.ts index d6612e3..002c42d 100644 --- a/sources/gmail/src/gmail-api.ts +++ b/sources/gmail/src/gmail-api.ts @@ -176,6 +176,34 @@ export class GmailApi { await this.call("/stop", { method: "POST" }); } + public async sendMessage( + raw: string, + threadId: string + ): Promise<{ id: string; threadId: string }> { + return await this.call("/messages/send", { + method: "POST", + body: { raw, threadId }, + }); + } + + public async getProfile(): Promise<{ emailAddress: string }> { + return await this.call("/profile"); + } + + public async modifyThread( + threadId: string, + addLabelIds?: string[], + removeLabelIds?: string[] + ): Promise { + const body: Record = {}; + if (addLabelIds?.length) body.addLabelIds = addLabelIds; + if (removeLabelIds?.length) body.removeLabelIds = removeLabelIds; + await this.call(`/threads/${threadId}/modify`, { + method: "POST", + body, + }); + } + public async getHistory( startHistoryId: string, labelId?: string, @@ -237,6 +265,20 @@ export function parseEmailAddress(headerValue: string): EmailAddress { } +/** + * Parses a comma-separated email header value into an array of email address strings. + */ +export function parseEmailAddresses(headerValue: string | null): string[] { + if (!headerValue) return []; + return headerValue + .split(",") + .map((addr) => { + const parsed = parseEmailAddress(addr.trim()); + return parsed.email; + }) + .filter((email) => email.length > 0); +} + /** * Parses email addresses and returns NewActor[] for mentions. */ @@ -259,7 +301,7 @@ function parseEmailAddressesToNewActors(headerValue: string | null): NewActor[] /** * Gets a specific header value from a message */ -function getHeader(message: GmailMessage, name: string): string | null { +export function getHeader(message: GmailMessage, name: string): string | null { const header = message.payload.headers.find( (h) => h.name.toLowerCase() === name.toLowerCase() ); @@ -267,51 +309,38 @@ function getHeader(message: GmailMessage, name: string): string | null { } /** - * Extracts the body from a Gmail message (handles multipart messages) + * Extracts the body from a Gmail message (handles multipart messages). + * Returns raw content with its type so HTML can be converted server-side. */ -function extractBody(part: GmailMessagePart): string { +function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } { // If this part has a body with data, return it if (part.body?.data) { // Gmail API returns base64url-encoded data const decoded = atob(part.body.data.replace(/-/g, "+").replace(/_/g, "/")); - return decoded; + const contentType = part.mimeType === "text/html" ? "html" as const : "text" as const; + return { content: decoded, contentType }; } // If multipart, recursively search parts if (part.parts) { - // Prefer plain text over HTML + // Prefer HTML over plain text — server-side conversion produces cleaner output + const htmlPart = part.parts.find((p) => p.mimeType === "text/html"); + if (htmlPart) { + return extractBody(htmlPart); + } + const textPart = part.parts.find((p) => p.mimeType === "text/plain"); if (textPart) { return extractBody(textPart); } - const htmlPart = part.parts.find((p) => p.mimeType === "text/html"); - if (htmlPart) { - // For HTML, strip tags for plain text representation - const html = extractBody(htmlPart); - return stripHtmlTags(html); - } - // Try first part as fallback if (part.parts.length > 0) { return extractBody(part.parts[0]); } } - return ""; -} - -/** - * Strips HTML tags for plain text representation - * This is a simple implementation - could be enhanced with a proper HTML parser - */ -function stripHtmlTags(html: string): string { - return html - .replace(/]*>.*?<\/style>/gi, "") - .replace(/]*>.*?<\/script>/gi, "") - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); + return { content: "", contentType: "text" }; } /** @@ -361,9 +390,8 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes { // Canonical URL for the thread const canonicalUrl = `https://mail.google.com/mail/u/0/#inbox/${thread.id}`; - // Extract preview from first message - const firstMessageBody = extractBody(parentMessage.payload); - const preview = firstMessageBody || parentMessage.snippet || null; + // Use Gmail's plain-text snippet for preview (avoids HTML in previews) + const preview = parentMessage.snippet || null; // Create link const plotThread: NewLinkWithNotes = { @@ -389,7 +417,7 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes { const sender = from ? parseEmailAddress(from) : null; if (!sender) continue; // Skip messages without sender - const body = extractBody(message.payload); + const { content: body, contentType } = extractBody(message.payload); // Combine to and cc for mentions - convert to NewActor[] const mentions: NewActor[] = [ @@ -405,7 +433,9 @@ export function transformGmailThread(thread: GmailThread): NewLinkWithNotes { name: sender.name || undefined, } as NewActor, content: body || message.snippet, + contentType, mentions: mentions.length > 0 ? mentions : undefined, + created: new Date(parseInt(message.internalDate)), }; plotThread.notes!.push(note); @@ -472,3 +502,57 @@ export async function syncGmailChannel( hasMore: !!nextPageToken, }; } + +/** + * Builds an RFC 2822 email message for replying to a Gmail thread. + * Returns the base64url-encoded raw message string for the Gmail API. + */ +export function buildReplyMessage(options: { + to: string[]; + cc: string[]; + from: string; + subject: string; + body: string; + messageId: string; + references: string; +}): string { + const { to, cc, from, subject, body, messageId, references } = options; + + // Ensure subject has "Re:" prefix + const reSubject = subject.startsWith("Re:") ? subject : `Re: ${subject}`; + + // Build RFC 2822 headers + const lines: string[] = [ + `From: ${from}`, + `To: ${to.join(", ")}`, + ]; + + if (cc.length > 0) { + lines.push(`Cc: ${cc.join(", ")}`); + } + + lines.push(`Subject: ${reSubject}`); + lines.push(`In-Reply-To: ${messageId}`); + + // Build References chain + const refChain = references + ? `${references} ${messageId}` + : messageId; + lines.push(`References: ${refChain}`); + + lines.push(`Content-Type: text/plain; charset="UTF-8"`); + lines.push(""); // Empty line separates headers from body + lines.push(body); + + const rawMessage = lines.join("\r\n"); + + // Base64url encode + const encoded = btoa( + String.fromCharCode(...new TextEncoder().encode(rawMessage)) + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + return encoded; +} diff --git a/sources/gmail/src/gmail.ts b/sources/gmail/src/gmail.ts index 8fe50e4..3b5d2b5 100644 --- a/sources/gmail/src/gmail.ts +++ b/sources/gmail/src/gmail.ts @@ -1,16 +1,25 @@ -import { - Source, - type ToolBuilder, -} from "@plotday/twister"; +import { Source, type ToolBuilder } from "@plotday/twister"; +import type { Actor, Note, Thread, ThreadMeta } from "@plotday/twister/plot"; import { AuthProvider, type AuthToken, type Authorization, - Integrations, type Channel, + Integrations, } from "@plotday/twister/tools/integrations"; import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +import { + GmailApi, + type GmailThread, + type SyncState, + buildReplyMessage, + getHeader, + parseEmailAddresses, + syncGmailChannel, + transformGmailThread, +} from "./gmail-api"; + type MessageChannel = { id: string; name: string; @@ -22,14 +31,6 @@ type MessageSyncOptions = { timeMin?: Date; }; -import { - GmailApi, - type GmailThread, - type SyncState, - syncGmailChannel, - transformGmailThread, -} from "./gmail-api"; - /** * Gmail integration source implementing the MessagingSource interface. * @@ -46,11 +47,19 @@ export class Gmail extends Source { static readonly SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.send", ]; readonly provider = AuthProvider.Google; readonly scopes = Gmail.SCOPES; - readonly linkTypes = [{ type: "email", label: "Email", logo: "https://api.iconify.design/logos/google-gmail.svg", logoMono: "https://api.iconify.design/simple-icons/gmail.svg" }]; + readonly linkTypes = [ + { + type: "email", + label: "Email", + logo: "https://api.iconify.design/logos/google-gmail.svg", + logoMono: "https://api.iconify.design/simple-icons/gmail.svg", + }, + ]; build(build: ToolBuilder) { return { @@ -92,7 +101,8 @@ export class Gmail extends Source { this.syncBatch, 1, "full", - channel.id + channel.id, + true ); await this.run(syncCallback); } @@ -150,7 +160,7 @@ export class Gmail extends Source { async startSync( options: { channelId: string; - } & MessageSyncOptions, + } & MessageSyncOptions ): Promise { const { channelId, timeMin } = options; @@ -173,7 +183,8 @@ export class Gmail extends Source { this.syncBatch, 1, "full", - channelId + channelId, + true ); await this.run(syncCallback); } @@ -226,8 +237,10 @@ export class Gmail extends Source { async syncBatch( batchNumber: number, mode: "full" | "incremental", - channelId: string + channelId: string, + initialSync?: boolean ): Promise { + const isInitial = initialSync ?? mode === "full"; try { const state = await this.get(`sync_state_${channelId}`); if (!state) { @@ -240,7 +253,7 @@ export class Gmail extends Source { const result = await syncGmailChannel(api, state, 20); if (result.threads.length > 0) { - await this.processEmailThreads(result.threads, channelId); + await this.processEmailThreads(result.threads, channelId, isInitial); } await this.set(`sync_state_${channelId}`, result.state); @@ -250,7 +263,8 @@ export class Gmail extends Source { this.syncBatch, batchNumber + 1, mode, - channelId + channelId, + isInitial ); await this.run(syncCallback); } else { @@ -270,25 +284,48 @@ export class Gmail extends Source { private async processEmailThreads( threads: GmailThread[], - channelId: string + channelId: string, + initialSync: boolean ): Promise { for (const thread of threads) { try { // Transform Gmail thread to NewLinkWithNotes - const activityThread = transformGmailThread(thread); + const plotThread = transformGmailThread(thread); + + if (!plotThread.notes || plotThread.notes.length === 0) continue; + + // Filter out notes for messages we sent (dedup) + const filtered = []; + for (const note of plotThread.notes) { + const noteKey = "key" in note ? (note as { key: string }).key : null; + if (noteKey) { + const wasSent = await this.get(`sent:${noteKey}`); + if (wasSent) { + await this.clear(`sent:${noteKey}`); + continue; + } + } + filtered.push(note); + } + plotThread.notes = filtered; - if (!activityThread.notes || activityThread.notes.length === 0) continue; + if (plotThread.notes.length === 0) continue; + + if (initialSync) { + plotThread.unread = false; + plotThread.archived = false; + } // Inject channel ID for priority routing and sync metadata - activityThread.channelId = channelId; - activityThread.meta = { - ...activityThread.meta, + plotThread.channelId = channelId; + plotThread.meta = { + ...plotThread.meta, syncProvider: "google", syncableId: channelId, }; // Save link directly via integrations - await this.tools.integrations.saveLink(activityThread); + await this.tools.integrations.saveLink(plotThread); } catch (error) { console.error(`Failed to process Gmail thread ${thread.id}:`, error); // Continue processing other threads @@ -296,6 +333,123 @@ export class Gmail extends Source { } } + async onNoteCreated(note: Note, meta: ThreadMeta): Promise { + const channelId = (meta.channelId ?? meta.syncableId) as string; + if (!channelId) { + console.error("No channelId in meta for Gmail reply"); + return; + } + + const threadId = meta.threadId as string; + if (!threadId) { + console.error("No threadId in meta for Gmail reply"); + return; + } + + const api = await this.getApi(channelId); + + // Fetch the full Gmail thread to get message headers + const gmailThread = await api.getThread(threadId); + if (!gmailThread.messages || gmailThread.messages.length === 0) { + console.error("Gmail thread has no messages"); + return; + } + + // Determine target message: specific replied-to note or last message in thread + let targetMessage = gmailThread.messages[gmailThread.messages.length - 1]; + if (meta.reNoteKey) { + const found = gmailThread.messages.find( + (m) => m.id === meta.reNoteKey + ); + if (found) { + targetMessage = found; + } + } + + // Extract headers from target message + const messageId = getHeader(targetMessage, "Message-ID"); + const references = getHeader(targetMessage, "References"); + const subject = getHeader(targetMessage, "Subject") ?? "Email"; + const fromHeader = getHeader(targetMessage, "From"); + const toHeader = getHeader(targetMessage, "To"); + const ccHeader = getHeader(targetMessage, "Cc"); + + if (!messageId) { + console.error("Target message has no Message-ID header"); + return; + } + + // Get sender's email to exclude from reply-all recipients + const profile = await api.getProfile(); + const senderEmail = profile.emailAddress.toLowerCase(); + + // Build reply-all recipients: all From + To + Cc minus sender, deduplicated + const allRecipients = new Set(); + for (const email of parseEmailAddresses(fromHeader)) { + allRecipients.add(email.toLowerCase()); + } + for (const email of parseEmailAddresses(toHeader)) { + allRecipients.add(email.toLowerCase()); + } + + const ccRecipients = new Set(); + for (const email of parseEmailAddresses(ccHeader)) { + ccRecipients.add(email.toLowerCase()); + } + + // Remove sender from all sets + allRecipients.delete(senderEmail); + ccRecipients.delete(senderEmail); + + // To = all direct recipients (From + To minus sender), Cc = remaining Cc + const to = Array.from(allRecipients).filter( + (email) => !ccRecipients.has(email) + ); + const cc = Array.from(ccRecipients); + + if (to.length === 0 && cc.length === 0) { + console.error("No recipients for Gmail reply"); + return; + } + + // Build and send the reply + const raw = buildReplyMessage({ + to, + cc, + from: senderEmail, + subject, + body: note.content ?? "", + messageId, + references: references ?? "", + }); + + const result = await api.sendMessage(raw, threadId); + + // Store sent message ID for dedup when synced back + await this.set(`sent:${result.id}`, true); + } + + async onThreadRead( + _thread: Thread, + _actor: Actor, + unread: boolean, + meta: ThreadMeta + ): Promise { + const channelId = (meta.channelId ?? meta.syncableId) as string; + if (!channelId) return; + + const threadId = meta.threadId as string; + if (!threadId) return; + + const api = await this.getApi(channelId); + + if (unread) { + await api.modifyThread(threadId, ["UNREAD"]); + } else { + await api.modifyThread(threadId, undefined, ["UNREAD"]); + } + } + async onGmailWebhook( request: WebhookRequest, channelId: string @@ -353,7 +507,8 @@ export class Gmail extends Source { this.syncBatch, 1, "incremental", - channelId + channelId, + false ); await this.run(syncCallback); } From 2602607a826e74638e8ff84f8a5aa650325c0163 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 3 Mar 2026 23:52:41 -0500 Subject: [PATCH 23/25] Update message-tasks twist to use new lifecycle hooks Migrate from Plot options callbacks to Twist base class methods (onThreadUpdated, onNoteCreated, onLinkCreated). Remove unused dependencies. Co-Authored-By: Claude Opus 4.6 --- twists/message-tasks/package.json | 2 - twists/message-tasks/src/index.ts | 193 ++++++++++++------------------ 2 files changed, 78 insertions(+), 117 deletions(-) diff --git a/twists/message-tasks/package.json b/twists/message-tasks/package.json index 71cfa6a..4223020 100644 --- a/twists/message-tasks/package.json +++ b/twists/message-tasks/package.json @@ -15,8 +15,6 @@ }, "dependencies": { "@plotday/twister": "workspace:^", - "@plotday/source-slack": "workspace:^", - "@plotday/source-gmail": "workspace:^", "typebox": "^1.0.35" }, "devDependencies": { diff --git a/twists/message-tasks/src/index.ts b/twists/message-tasks/src/index.ts index 1cc16d8..ac67581 100644 --- a/twists/message-tasks/src/index.ts +++ b/twists/message-tasks/src/index.ts @@ -1,11 +1,7 @@ import { Type } from "typebox"; -import { Gmail } from "@plotday/source-gmail"; -import { Slack } from "@plotday/source-slack"; import { - type ThreadFilter, - type NewThreadWithNotes, - type NewContact, + type Link, type Note, type Priority, type ToolBuilder, @@ -15,8 +11,6 @@ import { AI, type AIMessage } from "@plotday/twister/tools/ai"; import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; import { Uuid } from "@plotday/twister/utils/uuid"; -type MessageProvider = "slack" | "gmail"; - type Instruction = { id: string; text: string; @@ -35,16 +29,9 @@ type ThreadTask = { export default class MessageTasksTwist extends Twist { build(build: ToolBuilder) { return { - slack: build(Slack, { - onItem: this.onSlackThread, - onChannelDisabled: this.onChannelDisabled, - }), - gmail: build(Gmail, { - onItem: this.onGmailThread, - onChannelDisabled: this.onChannelDisabled, - }), ai: build(AI), plot: build(Plot, { + link: true, thread: { access: ThreadAccess.Create, }, @@ -90,20 +77,51 @@ export default class MessageTasksTwist extends Twist { // Auth and channel selection are now handled in the twist edit modal. } - async onSlackThread(thread: NewThreadWithNotes): Promise { - // TODO: meta was removed from threads; channelId may still be present at runtime from sources - const channelId = (thread as any).meta?.channelId as string; - return this.onMessageThread(thread, "slack", channelId); - } + // ============================================================================ + // Link Lifecycle + // ============================================================================ - async onGmailThread(thread: NewThreadWithNotes): Promise { - // TODO: meta was removed from threads; channelId may still be present at runtime from sources - const channelId = (thread as any).meta?.channelId as string; - return this.onMessageThread(thread, "gmail", channelId); + async onLinkCreated(link: Link, notes: Note[]): Promise { + if (!notes.length) return; + + const threadId = link.source; + if (!threadId) { + console.warn("Link has no source, skipping"); + return; + } + + // Check if we already have a task for this thread + const existingTask = await this.getThreadTask(threadId); + + if (existingTask) { + // Already has a task — check latest note for completion + const lastNote = notes[notes.length - 1]; + if (lastNote) { + await this.checkNoteForCompletion(lastNote, existingTask); + await this.updateThreadTaskCheck(threadId); + } + return; + } + + // Analyze link with AI to see if it needs a task + const analysis = await this.analyzeLink(link, notes); + + if (!analysis.needsTask || analysis.confidence < 0.6) { + return; + } + + await this.createTaskFromLink(link, notes, analysis); } - async onChannelDisabled(filter: ThreadFilter): Promise { - await this.tools.plot.updateThread({ match: filter, archived: true }); + async onLinkNoteCreated(note: Note, link: Link): Promise { + const threadId = link.source; + if (!threadId) return; + + const existingTask = await this.getThreadTask(threadId); + if (!existingTask) return; + + await this.checkNoteForCompletion(note, existingTask); + await this.updateThreadTaskCheck(threadId); } // ============================================================================ @@ -302,59 +320,22 @@ export default class MessageTasksTwist extends Twist { } // ============================================================================ - // Message Thread Processing + // Link Analysis & Task Creation // ============================================================================ - async onMessageThread( - thread: NewThreadWithNotes, - provider: MessageProvider, - channelId: string - ): Promise { - if (!thread.notes || thread.notes.length === 0) return; - - // TODO: source was removed from threads; may still be present at runtime from sources - const threadId = (thread as any).source as string | undefined; - if (!threadId) { - console.warn("Thread has no source, skipping"); - return; - } - - // Check if we already have a task for this thread - const existingTask = await this.getThreadTask(threadId); - - if (existingTask) { - // Thread already has a task - check if it needs updating - await this.checkThreadForCompletion(thread, existingTask); - await this.updateThreadTaskCheck(threadId); - return; - } - - // Analyze thread with AI to see if it needs a task - const analysis = await this.analyzeThread(thread); - - if (!analysis.needsTask || analysis.confidence < 0.6) { - return; - } - - // Create task from thread - await this.createTaskFromThread(thread, analysis, provider, channelId); - } - - private async analyzeThread(thread: NewThreadWithNotes): Promise<{ + private async analyzeLink(link: Link, notes: Note[]): Promise<{ needsTask: boolean; taskTitle: string | null; taskNote: string | null; confidence: number; isCompleted: boolean; }> { - // Load user instructions const instructions = await this.getInstructions(); const instructionBlock = instructions.length > 0 ? `\n\nUser instructions (follow these as rules):\n${instructions.map((i) => `- ${i.summary}`).join("\n")}` : ""; - // Build conversation for AI const messages: AIMessage[] = [ { role: "system", @@ -378,16 +359,12 @@ DO NOT create tasks for: If a task is needed, create a clear, actionable title that describes what the user needs to do.${instructionBlock}`, }, - ...thread.notes.map((note, idx) => { - const author: NewContact | null = - note.author && "email" in note.author ? note.author : null; - return { - role: "user" as const, - content: `[Message ${idx + 1}] From ${ - author?.name || author?.email || "someone" - }: ${note.content || "(empty message)"}`, - }; - }), + ...notes.map((note, idx) => ({ + role: "user" as const, + content: `[Message ${idx + 1}] From ${ + note.author?.name || note.author?.email || "someone" + }: ${note.content || "(empty message)"}`, + })), ]; const schema = Type.Object({ @@ -439,7 +416,7 @@ If a task is needed, create a clear, actionable title that describes what the us isCompleted: output.isCompleted, }; } catch (error) { - console.error("Failed to analyze thread with AI:", error); + console.error("Failed to analyze link with AI:", error); return { needsTask: false, taskTitle: null, @@ -450,50 +427,42 @@ If a task is needed, create a clear, actionable title that describes what the us } } - private formatSourceReference( - thread: NewThreadWithNotes, - provider: MessageProvider, - channelId: string - ): string { - if (provider === "gmail") { - const firstNote = thread.notes?.[0]; - const author: NewContact | null = - firstNote?.author && "email" in firstNote.author - ? firstNote.author - : null; - const senderName = author?.name || author?.email; - const subject = thread.title; + private formatSourceReference(link: Link, notes: Note[]): string { + if (link.type === "email") { + const firstNote = notes[0]; + const senderName = firstNote?.author?.name || firstNote?.author?.email; + const subject = link.title; if (senderName && subject) return `From ${senderName}: ${subject}`; if (senderName) return `From ${senderName}`; if (subject) return `Re: ${subject}`; - return `From Gmail`; + return `From email`; } - return `From #${channelId}`; + if (link.type === "message") { + return link.channelId ? `From #${link.channelId}` : "From message"; + } + return link.title || "From linked source"; } - private async createTaskFromThread( - thread: NewThreadWithNotes, + private async createTaskFromLink( + link: Link, + notes: Note[], analysis: { needsTask: boolean; taskTitle: string | null; taskNote: string | null; confidence: number; - }, - provider: MessageProvider, - channelId: string + } ): Promise { - // TODO: source was removed from threads; may still be present at runtime from sources - const threadId = (thread as any).source as string | undefined; + const threadId = link.source; if (!threadId) { - console.warn("Thread has no source, skipping task creation"); + console.warn("Link has no source, skipping task creation"); return; } - const sourceRef = this.formatSourceReference(thread, provider, channelId); + const sourceRef = this.formatSourceReference(link, notes); - // Create task thread const taskId = await this.tools.plot.createThread({ - title: analysis.taskTitle || thread.title || "Action needed from message", + title: analysis.taskTitle || link.title || "Action needed from message", notes: analysis.taskNote ? [ { @@ -508,26 +477,20 @@ If a task is needed, create a clear, actionable title that describes what the us preview: analysis.taskNote ? `${analysis.taskNote}\n\n---\n${sourceRef}` : sourceRef, - // Use pickPriority for automatic priority matching pickPriority: { content: 50, mentions: 50 }, }); - // Store mapping await this.storeThreadTask(threadId, taskId); } - private async checkThreadForCompletion( - thread: NewThreadWithNotes, + private async checkNoteForCompletion( + note: Note, taskInfo: ThreadTask ): Promise { - // Only check the last few messages for completion signals - const recentMessages = thread.notes.slice(-3); - - // Build a simple prompt to check for completion const messages: AIMessage[] = [ { role: "system", - content: `You are checking if a task appears to be completed based on recent messages in a thread. + content: `You are checking if a task appears to be completed based on a message in a thread. Look for signals like: - "Done", "Completed", "Finished" @@ -538,10 +501,10 @@ Look for signals like: Return true only if there's clear evidence the task is done.`, }, - ...recentMessages.map((note) => ({ - role: "user" as const, + { + role: "user", content: `User: ${note.content || ""}`, - })), + }, ]; const schema = Type.Object({ @@ -574,7 +537,7 @@ Return true only if there's clear evidence the task is done.`, }); } } catch (error) { - console.error("Failed to check thread for completion:", error); + console.error("Failed to check note for completion:", error); } } } From 76fc6fe140e03affa0ed0c3683a58734aea116d8 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Tue, 3 Mar 2026 23:52:54 -0500 Subject: [PATCH 24/25] Update docs for source-owned lifecycle and link support Rename BUILDING_TOOLS.md to BUILDING_SOURCES.md. Update AGENTS guides, templates, and docs to reflect lifecycle hooks moving to Twist/Source base classes, link processing support, and source-owned channel lifecycle. Update typedoc config. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 5 +- sources/AGENTS.md | 122 ++- twister/README.md | 6 +- twister/cli/templates/AGENTS.template.md | 29 +- twister/cli/templates/README.template.md | 25 +- twister/docs/BUILDING_SOURCES.md | 440 +++++++++++ twister/docs/BUILDING_TOOLS.md | 928 ----------------------- twister/docs/CORE_CONCEPTS.md | 15 +- twister/docs/GETTING_STARTED.md | 2 +- twister/docs/MULTI_USER_AUTH.md | 2 +- twister/docs/SYNC_STRATEGIES.md | 14 +- twister/docs/TOOLS_GUIDE.md | 2 +- twister/docs/index.md | 11 +- twister/typedoc.json | 2 +- 14 files changed, 583 insertions(+), 1020 deletions(-) create mode 100644 twister/docs/BUILDING_SOURCES.md delete mode 100644 twister/docs/BUILDING_TOOLS.md diff --git a/AGENTS.md b/AGENTS.md index 30071c1..733388b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ All types in `twister/src/` with full JSDoc: ## Additional Resources - **Full Documentation**: -- **Building Sources Guide**: `twister/docs/BUILDING_TOOLS.md` +- **Building Sources Guide**: `sources/AGENTS.md` - **Runtime Environment**: `twister/docs/RUNTIME.md` - **Tools Guide**: `twister/docs/TOOLS_GUIDE.md` - **Multi-User Auth**: `twister/docs/MULTI_USER_AUTH.md` @@ -43,8 +43,9 @@ All types in `twister/src/` with full JSDoc: 2. **❌ Long-running operations without batching** — Break into chunks with `runTask()` (~1000 requests per execution) 3. **❌ Passing functions to `this.callback()`** — See `sources/AGENTS.md` for callback serialization pattern 4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `thread.meta` -5. **❌ Not handling initial vs incremental sync** — `unread: false` for initial, omit for incremental +5. **❌ Not handling initial vs incremental sync** — Propagate `initialSync` flag from entry point (`onChannelEnabled` → `true`, webhook → `false`) through all batch callbacks. Set `unread: false` and `archived: false` for initial, omit for incremental 6. **❌ Missing localhost guard in webhooks** — Skip webhook registration when URL contains "localhost" +7. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side markdown conversion --- diff --git a/sources/AGENTS.md b/sources/AGENTS.md index 9362f2c..4a6c7f5 100644 --- a/sources/AGENTS.md +++ b/sources/AGENTS.md @@ -340,6 +340,7 @@ export class MySource extends Source { notes: [{ key: "description", // Enables note upsert content: item.description || null, + contentType: item.descriptionHtml ? "html" as const : "text" as const, links: item.url ? [{ type: LinkType.external, title: "Open in Service", @@ -580,6 +581,61 @@ https://slack.com/app_redirect?channel=&message_ts= — Slack uses full "reply--" — Reply to a comment ``` +## HTML Content Handling + +**Never strip HTML tags locally.** When external APIs return HTML content, pass it through with `contentType: "html"` and let the server convert it to clean markdown. Local regex-based tag stripping produces broken encoding, loses link structure, and collapses whitespace. + +### Pattern + +```typescript +// ✅ CORRECT: Pass raw HTML with contentType +const note = { + key: "description", + content: item.bodyHtml, // Raw HTML from API + contentType: "html" as const, // Server converts to markdown +}; + +// ✅ CORRECT: Use plain text when that's what you have +const note = { + key: "description", + content: item.bodyText, + contentType: "text" as const, +}; + +// ❌ WRONG: Stripping HTML locally +const stripped = html.replace(/<[^>]+>/g, " ").trim(); +const note = { content: stripped }; // Broken encoding, lost links +``` + +### When APIs provide both HTML and plain text + +Prefer HTML — the server-side `toMarkdown()` conversion (via Cloudflare AI) produces cleaner output with proper links, formatting, and character encoding. Only use plain text if no HTML is available. + +```typescript +function extractBody(part: MessagePart): { content: string; contentType: "text" | "html" } { + // Prefer HTML for server-side conversion + const htmlPart = findPart(part, "text/html"); + if (htmlPart) return { content: decode(htmlPart), contentType: "html" }; + + const textPart = findPart(part, "text/plain"); + if (textPart) return { content: decode(textPart), contentType: "text" }; + + return { content: "", contentType: "text" }; +} +``` + +### Previews + +For `preview` fields on threads/links, use a plain-text source (like Gmail's `snippet` or a truncated title) — never raw HTML. Previews are displayed directly and are not processed by the server. + +### ContentType values + +| Value | Meaning | +|-------|---------| +| `"text"` | Plain text — auto-links URLs, preserves line breaks | +| `"markdown"` | Already markdown (default if omitted) | +| `"html"` | HTML — converted to markdown server-side | + ## Sync Metadata Injection **Every synced activity MUST include sync metadata** in `activity.meta` for bulk operations (e.g., archiving all activities when a sync is disabled): @@ -601,7 +657,9 @@ async onChannelDisabled(filter: ActivityFilter): Promise { } ``` -## Initial vs. Incremental Sync +## Initial vs. Incremental Sync (REQUIRED) + +**Every source MUST track whether it is performing an initial sync (first import) or an incremental sync (ongoing updates).** Omitting this causes notification spam from bulk historical imports. | Field | Initial Sync | Incremental Sync | Reason | |-------|-------------|------------------|--------| @@ -616,6 +674,43 @@ const activity = { }; ``` +### How to propagate the flag + +The `initialSync` flag must flow from the entry point (`onChannelEnabled` / `startSync`) through every batch to the point where activities are created. There are two patterns: + +**Pattern A: Store in SyncState** (used in the scaffold above) + +The scaffold's `SyncState` type includes `initialSync: boolean`. Set it to `true` in `startBatchSync`, read it in `syncBatch`, and preserve it across batches. Webhook/incremental handlers pass `false`. + +**Pattern B: Pass as callback argument** (used by sources like Gmail that don't store `initialSync` in state) + +Pass `initialSync` as an explicit argument through `this.callback()`: + +```typescript +// onChannelEnabled — initial sync +const syncCallback = await this.callback(this.syncBatch, 1, "full", channel.id, true); + +// startIncrementalSync — not initial +const syncCallback = await this.callback(this.syncBatch, 1, "incremental", channelId, false); + +// syncBatch — accept and propagate the flag +async syncBatch( + batchNumber: number, + mode: "full" | "incremental", + channelId: string, + initialSync?: boolean // optional for backward compat with old serialized callbacks +): Promise { + const isInitial = initialSync ?? (mode === "full"); // safe default for old callbacks + // ... pass isInitial to processItems and to next batch callback +} +``` + +**Whichever pattern you use, verify that ALL entry points set the flag correctly:** +- `onChannelEnabled` → `true` (first import) +- `startSync` → `true` (manual full sync) +- Webhook / incremental handler → `false` +- Next batch callback → propagate current value + ## Webhook Patterns ### Localhost Guard (REQUIRED) @@ -773,8 +868,10 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove - [ ] Verify webhook signatures - [ ] Use canonical `source` URLs for activity upserts (immutable IDs) - [ ] Use `note.key` for note-level upserts +- [ ] Set `contentType: "html"` on notes with HTML content — **never strip HTML locally** - [ ] Inject `syncProvider` and `channelId` into `activity.meta` -- [ ] Handle `initialSync` flag: `unread: false` and `archived: false` for initial, omit both for incremental +- [ ] Set `created` on notes using the external system's timestamp (not sync time) +- [ ] Handle `initialSync` flag in **every sync entry point**: `onChannelEnabled`/`startSync` set `true`, webhooks/incremental set `false`, and the flag is propagated through all batch callbacks to where activities are created. Set `unread: false` and `archived: false` for initial, omit both for incremental - [ ] Create contacts for authors/assignees with `NewContact` - [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()` - [ ] Add `package.json` with correct structure, `tsconfig.json`, and `src/index.ts` re-export @@ -786,14 +883,17 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove 2. **❌ Storing functions with `this.set()`** — Convert to tokens first 3. **❌ Not validating callback token exists** — Always check before `callbacks.run()` 4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `activity.meta` -5. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key) -6. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit -7. **❌ Missing localhost guard** — Webhook registration fails silently on localhost -8. **❌ Calling `plot.createThread()` from a source** — Sources save data directly via `integrations.saveLink()` -9. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only -10. **❌ Passing `undefined` in serializable values** — Use `null` instead -11. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state -12. **❌ Two-way sync without metadata correlation** — Embed Plot ID in external item metadata to prevent duplicates from race conditions (see SYNC_STRATEGIES.md §6) +5. **❌ Not propagating `initialSync` through the full sync pipeline** — The flag must flow from the entry point (`onChannelEnabled`/`startSync` → `true`, webhook → `false`) through every batch callback to where activities are created. Missing this causes notification spam from bulk historical imports +6. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key) +7. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit +8. **❌ Missing localhost guard** — Webhook registration fails silently on localhost +9. **❌ Calling `plot.createThread()` from a source** — Sources save data directly via `integrations.saveLink()` +10. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only +11. **❌ Passing `undefined` in serializable values** — Use `null` instead +12. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state +13. **❌ Two-way sync without metadata correlation** — Embed Plot ID in external item metadata to prevent duplicates from race conditions (see SYNC_STRATEGIES.md §6) +14. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side conversion. Local regex stripping breaks encoding and loses links +15. **❌ Not setting `created` on notes from external data** — Always pass the external system's timestamp (e.g., `internalDate` from Gmail, `created_at` from an API) as the note's `created` field. Omitting it defaults to sync time, making all notes appear to have been created "just now" ## Study These Examples @@ -802,7 +902,7 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove | `linear/` | ProjectSource | Clean reference implementation, webhook handling, bidirectional sync | | `google-calendar/` | CalendarSource | Recurring events, RSVP write-back, watch renewal, cross-source auth sharing | | `slack/` | MessagingSource | Team-sharded webhooks, thread model, Slack-specific auth | -| `gmail/` | MessagingSource | PubSub webhooks, email thread transformation | +| `gmail/` | MessagingSource | PubSub webhooks, email thread transformation, HTML contentType, callback-arg initialSync pattern | | `google-drive/` | DocumentSource | Document comments, reply threading, file watching | | `jira/` | ProjectSource | Immutable vs mutable IDs, comment metadata for dedup | | `asana/` | ProjectSource | HMAC webhook verification, section-based projects | diff --git a/twister/README.md b/twister/README.md index d22195a..1348196 100644 --- a/twister/README.md +++ b/twister/README.md @@ -115,7 +115,7 @@ async upgrade() // When new version is deployed ### Twist Tools -Twist tools provide capabilities to twists. They are usually unopinionated and do nothing on their own. Use built-in tools or create your own. +Twist tools provide capabilities to twists. They are usually unopinionated and do nothing on their own. **Built-in Tools:** @@ -129,6 +129,8 @@ Twist tools provide capabilities to twists. They are usually unopinionated and d [View all tools →](https://twist.plot.day/documents/Built-in_Tools.html) +External service integrations (Google Calendar, Slack, Linear, etc.) are built as **Sources** — see [Building Sources](https://twist.plot.day/documents/Building_Sources.html). + ### Activities and Notes **Activity** represents something done or to be done (a task, event, or conversation). @@ -206,7 +208,7 @@ plot priority create # Create new priority - [Core Concepts](https://twist.plot.day/documents/Core_Concepts.html) - Twists, tools, and architecture - [Sync Strategies](https://twist.plot.day/documents/Sync_Strategies.html) - Data synchronization patterns (upserts, deduplication, ID management) - [Built-in Tools](https://twist.plot.day/documents/Built-in_Tools.html) - Plot, Store, AI, and more -- [Building Custom Tools](https://twist.plot.day/documents/Building_Custom_Tools.html) - Create reusable twist tools +- [Building Sources](https://twist.plot.day/documents/Building_Sources.html) - Build external service integrations - [Runtime Environment](https://twist.plot.day/documents/Runtime_Environment.html) - Execution constraints and optimization - [Advanced Topics](https://twist.plot.day/documents/Advanced.html) - Complex patterns and techniques diff --git a/twister/cli/templates/AGENTS.template.md b/twister/cli/templates/AGENTS.template.md index fe8e605..e472e40 100644 --- a/twister/cli/templates/AGENTS.template.md +++ b/twister/cli/templates/AGENTS.template.md @@ -78,9 +78,7 @@ import { ThreadType, } from "@plotday/twister"; import { ThreadAccess, Plot } from "@plotday/twister/tools/plot"; -// Import your tools: -// import { GoogleCalendar } from "@plotday/tool-google-calendar"; -// import { Linear } from "@plotday/tool-linear"; +// Import your sources or tools as needed export default class MyTwist extends Twist { build(build: ToolBuilder) { @@ -133,31 +131,6 @@ For complete API documentation of built-in tools including all methods, types, a **Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods. -### External Tools (Add to package.json) - -Add tool dependencies to `package.json`: - -```json -{ - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-calendar": "workspace:^" - } -} -``` - -#### Available External Tools - -- `@plotday/tool-google-calendar`: Google Calendar sync (CalendarTool) -- `@plotday/tool-outlook-calendar`: Outlook Calendar sync (CalendarTool) -- `@plotday/tool-google-contacts`: Google Contacts sync (supporting tool) -- `@plotday/tool-google-drive`: Google Drive sync (DocumentTool) -- `@plotday/tool-gmail`: Gmail sync (MessagingTool) -- `@plotday/tool-slack`: Slack sync (MessagingTool) -- `@plotday/tool-linear`: Linear sync (ProjectTool) -- `@plotday/tool-jira`: Jira sync (ProjectTool) -- `@plotday/tool-asana`: Asana sync (ProjectTool) - ## Lifecycle Methods ### activate(priority: Pick) diff --git a/twister/cli/templates/README.template.md b/twister/cli/templates/README.template.md index 017862c..e7f4811 100644 --- a/twister/cli/templates/README.template.md +++ b/twister/cli/templates/README.template.md @@ -68,30 +68,9 @@ build(build: ToolBuilder) { - **Callbacks**: Create persistent function references for webhooks - **Network**: HTTP access permissions and webhook management -#### External Tools +#### Sources -Add external tool dependencies to `package.json`: - -```json -{ - "dependencies": { - "@plotday/twister": "workspace:^", - "@plotday/tool-google-calendar": "workspace:^" - } -} -``` - -Then use them in your twist: - -```typescript -import GoogleCalendarTool from "@plotday/tool-google-calendar"; - -build(build: ToolBuilder) { - return { - googleCalendar: build(GoogleCalendarTool), - }; -} -``` +External service integrations (Google Calendar, Slack, Linear, etc.) are built as Sources. See the [Building Sources](https://twist.plot.day/documents/Building_Sources.html) guide. ### Activity Types diff --git a/twister/docs/BUILDING_SOURCES.md b/twister/docs/BUILDING_SOURCES.md new file mode 100644 index 0000000..0225997 --- /dev/null +++ b/twister/docs/BUILDING_SOURCES.md @@ -0,0 +1,440 @@ +--- +title: Building Sources +group: Guides +--- + +# Building Sources + +Sources connect Plot to external services like Google Calendar, Slack, Linear, and more. They sync data into Plot and optionally support bidirectional updates. This guide covers everything you need to know about building sources. + +## Table of Contents + +- [Sources vs Twists](#sources-vs-twists) +- [Source Structure](#source-structure) +- [OAuth and Channel Lifecycle](#oauth-and-channel-lifecycle) +- [Data Sync](#data-sync) +- [Batch Processing](#batch-processing) +- [Complete Example](#complete-example) +- [Best Practices](#best-practices) + +--- + +## Sources vs Twists + +| | Sources | Twists | +|---|---|---| +| **Purpose** | Sync data from external services | Implement opinionated workflows | +| **Base class** | `Source` (extends `Twist`) | `Twist` | +| **Auth** | OAuth via `Integrations` with channel lifecycle | Optional | +| **Data flow** | External service -> Plot (and optionally back) | Internal logic, orchestration | +| **Examples** | Google Calendar, Slack, Linear, Jira | Task automation, AI assistants | + +**Build a Source** when you need to integrate an external service — syncing calendars, issues, messages, etc. + +**Build a Twist** when you need workflow logic that doesn't require external service integration, or when you want to orchestrate multiple sources. + +--- + +## Source Structure + +Sources extend the `Source` base class and declare dependencies using `SourceBuilder`: + +```typescript +import { + ActivityType, + Source, + type SourceBuilder, +} from "@plotday/twister"; +import { + AuthProvider, + type AuthToken, + type Authorization, + type Channel, + Integrations, +} from "@plotday/twister/tools/integrations"; +import { Network } from "@plotday/twister/tools/network"; +import { Plot } from "@plotday/twister/tools/plot"; +import { Tasks } from "@plotday/twister/tools/tasks"; +import { Callbacks } from "@plotday/twister/tools/callbacks"; + +export default class MySource extends Source { + static readonly PROVIDER = AuthProvider.Linear; + static readonly SCOPES = ["read", "write"]; + + build(build: SourceBuilder) { + return { + integrations: build(Integrations, { + providers: [{ + provider: MySource.PROVIDER, + scopes: MySource.SCOPES, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, + }], + }), + network: build(Network, { urls: ["https://api.example.com/*"] }), + plot: build(Plot), + tasks: build(Tasks), + callbacks: build(Callbacks), + }; + } + + // ... lifecycle methods below +} +``` + +### Package Structure + +``` +sources/my-source/ + src/ + index.ts # Re-exports: export { default, MySource } from "./my-source" + my-source.ts # Main Source class + package.json + tsconfig.json +``` + +--- + +## OAuth and Channel Lifecycle + +Sources use the Integrations tool for OAuth. Auth is handled automatically in the Flutter edit modal — you don't need to build UI for it. + +### How It Works + +1. Source declares providers in `build()` with `getChannels`, `onChannelEnabled`, `onChannelDisabled` callbacks +2. User clicks "Connect" in the twist edit modal -> OAuth flow happens automatically +3. After auth, the runtime calls `getChannels()` to list available resources +4. User enables/disables resources in the modal + +### getChannels + +Return available resources after authentication: + +```typescript +async getChannels(_auth: Authorization, token: AuthToken): Promise { + const client = new ApiClient({ accessToken: token.token }); + const resources = await client.listResources(); + return resources.map(r => ({ id: r.id, title: r.name })); +} +``` + +### onChannelEnabled + +Called when the user enables a resource. Set up syncing: + +```typescript +async onChannelEnabled(channel: Channel): Promise { + await this.setupWebhook(channel.id); + await this.startBatchSync(channel.id); +} +``` + +### onChannelDisabled + +Called when the user disables a resource. Clean up: + +```typescript +async onChannelDisabled(channel: Channel): Promise { + // Remove webhook + const webhookId = await this.get(`webhook_id_${channel.id}`); + if (webhookId) { + const client = await this.getClient(channel.id); + await client.deleteWebhook(webhookId); + await this.clear(`webhook_id_${channel.id}`); + } + + // Clean up stored state + await this.clear(`sync_state_${channel.id}`); +} +``` + +### Getting Auth Tokens + +Retrieve tokens for API calls using the channel ID: + +```typescript +private async getClient(channelId: string): Promise { + const token = await this.tools.integrations.get(MySource.PROVIDER, channelId); + if (!token) throw new Error("No authentication token available"); + return new ApiClient({ accessToken: token.token }); +} +``` + +--- + +## Data Sync + +Sources sync data using `Activity.source` and `Note.key` for automatic upserts (no manual ID tracking needed). + +### Transforming External Items + +```typescript +private transformItem(item: any, channelId: string, initialSync: boolean) { + return { + source: `myprovider:item:${item.id}`, // Canonical source for deduplication + type: ActivityType.Action, + title: item.title, + meta: { + externalId: item.id, + syncProvider: "myprovider", // Required for bulk operations + channelId, // Required for bulk operations + }, + notes: [{ + key: "description", // Enables note-level upserts + content: item.description || null, + contentType: item.descriptionHtml ? "html" as const : "text" as const, + }], + ...(initialSync ? { unread: false } : {}), // Mark read on initial sync + ...(initialSync ? { archived: false } : {}), // Unarchive on initial sync + }; +} +``` + +### Initial vs Incremental Sync + +All sources **must** distinguish between initial sync (first import) and incremental sync (ongoing updates): + +| Field | Initial Sync | Incremental Sync | Reason | +|-------|-------------|------------------|--------| +| `unread` | `false` | *omit* | Avoid notification spam from historical imports | +| `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates | + +See [Sync Strategies](SYNC_STRATEGIES.md) for detailed patterns on deduplication, upserts, and tag management. + +--- + +## Batch Processing + +Sources run in an ephemeral environment with ~1000 requests per execution. Break long operations into batches using `runTask()`, which creates a new execution with fresh request limits. + +```typescript +private async startBatchSync(channelId: string): Promise { + await this.set(`sync_state_${channelId}`, { + cursor: null, + batchNumber: 1, + initialSync: true, + }); + + const batchCallback = await this.callback(this.syncBatch, channelId); + await this.tools.tasks.runTask(batchCallback); +} + +private async syncBatch(channelId: string): Promise { + const state = await this.get(`sync_state_${channelId}`); + if (!state) return; + + const client = await this.getClient(channelId); + const result = await client.listItems({ cursor: state.cursor, limit: 50 }); + + for (const item of result.items) { + const activity = this.transformItem(item, channelId, state.initialSync); + await this.tools.plot.createActivity(activity); + } + + if (result.nextCursor) { + await this.set(`sync_state_${channelId}`, { + cursor: result.nextCursor, + batchNumber: state.batchNumber + 1, + initialSync: state.initialSync, + }); + const nextBatch = await this.callback(this.syncBatch, channelId); + await this.tools.tasks.runTask(nextBatch); + } else { + await this.clear(`sync_state_${channelId}`); + } +} +``` + +--- + +## Complete Example + +A minimal source that syncs issues from an external service: + +```typescript +import { + ActivityType, + LinkType, + Source, + type SourceBuilder, + type SyncToolOptions, +} from "@plotday/twister"; +import { + AuthProvider, + type AuthToken, + type Authorization, + type Channel, + Integrations, +} from "@plotday/twister/tools/integrations"; +import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; +import { Plot } from "@plotday/twister/tools/plot"; +import { Tasks } from "@plotday/twister/tools/tasks"; +import { Callbacks } from "@plotday/twister/tools/callbacks"; + +export default class IssueSource extends Source { + static readonly PROVIDER = AuthProvider.Linear; + static readonly SCOPES = ["read"]; + static readonly Options: SyncToolOptions; + declare readonly Options: SyncToolOptions; + + build(build: SourceBuilder) { + return { + integrations: build(Integrations, { + providers: [{ + provider: IssueSource.PROVIDER, + scopes: IssueSource.SCOPES, + getChannels: this.getChannels, + onChannelEnabled: this.onChannelEnabled, + onChannelDisabled: this.onChannelDisabled, + }], + }), + network: build(Network, { urls: ["https://api.linear.app/*"] }), + plot: build(Plot), + tasks: build(Tasks), + callbacks: build(Callbacks), + }; + } + + async getChannels(_auth: Authorization, token: AuthToken): Promise { + // Return available projects/teams for the user to select + const client = new LinearClient({ accessToken: token.token }); + const teams = await client.teams(); + return teams.nodes.map(t => ({ id: t.id, title: t.name })); + } + + async onChannelEnabled(channel: Channel): Promise { + // Set up webhook + const webhookUrl = await this.tools.network.createWebhook( + {}, this.onWebhook, channel.id + ); + if (!webhookUrl.includes("localhost")) { + const client = await this.getClient(channel.id); + const webhook = await client.createWebhook({ url: webhookUrl }); + if (webhook?.id) await this.set(`webhook_id_${channel.id}`, webhook.id); + } + + // Start initial sync + await this.set(`sync_state_${channel.id}`, { + cursor: null, batchNumber: 1, initialSync: true, + }); + const batch = await this.callback(this.syncBatch, channel.id); + await this.tools.tasks.runTask(batch); + } + + async onChannelDisabled(channel: Channel): Promise { + const webhookId = await this.get(`webhook_id_${channel.id}`); + if (webhookId) { + try { + const client = await this.getClient(channel.id); + await client.deleteWebhook(webhookId); + } catch { /* ignore */ } + await this.clear(`webhook_id_${channel.id}`); + } + await this.clear(`sync_state_${channel.id}`); + } + + private async getClient(channelId: string) { + const token = await this.tools.integrations.get(IssueSource.PROVIDER, channelId); + if (!token) throw new Error("No auth token"); + return new LinearClient({ accessToken: token.token }); + } + + private async syncBatch(channelId: string): Promise { + const state = await this.get(`sync_state_${channelId}`); + if (!state) return; + + const client = await this.getClient(channelId); + const result = await client.issues({ teamId: channelId, after: state.cursor }); + + for (const issue of result.nodes) { + await this.tools.plot.createActivity({ + source: `linear:issue:${issue.id}`, + type: ActivityType.Action, + title: `${issue.identifier}: ${issue.title}`, + done: issue.completedAt ? new Date(issue.completedAt) : null, + meta: { syncProvider: "linear", channelId }, + notes: [{ + key: "description", + content: issue.description || null, + links: issue.url ? [{ + type: LinkType.external, + title: "Open in Linear", + url: issue.url, + }] : null, + }], + ...(state.initialSync ? { unread: false } : {}), + ...(state.initialSync ? { archived: false } : {}), + }); + } + + if (result.pageInfo.hasNextPage) { + await this.set(`sync_state_${channelId}`, { + cursor: result.pageInfo.endCursor, + batchNumber: state.batchNumber + 1, + initialSync: state.initialSync, + }); + const next = await this.callback(this.syncBatch, channelId); + await this.tools.tasks.runTask(next); + } else { + await this.clear(`sync_state_${channelId}`); + } + } + + private async onWebhook(request: WebhookRequest, channelId: string): Promise { + const payload = JSON.parse(request.rawBody || "{}"); + if (payload.type !== "Issue") return; + + const issue = payload.data; + await this.tools.plot.createActivity({ + source: `linear:issue:${issue.id}`, + type: ActivityType.Action, + title: `${issue.identifier}: ${issue.title}`, + done: issue.completedAt ? new Date(issue.completedAt) : null, + meta: { syncProvider: "linear", channelId }, + notes: [{ + key: "description", + content: issue.description || null, + }], + // Incremental sync: omit unread and archived + }); + } +} +``` + +--- + +## Best Practices + +### 1. Always Inject Sync Metadata + +Every synced activity must include `syncProvider` and `channelId` in `meta` for bulk operations (e.g., archiving all activities when a channel is disabled). + +### 2. Use Canonical Source URLs + +Use immutable IDs in `Activity.source` for deduplication. For services with mutable identifiers (like Jira issue keys), use the immutable ID in `source` and store the mutable key in `meta`. + +### 3. Handle HTML Content Correctly + +Never strip HTML tags locally. Pass raw HTML with `contentType: "html"` for server-side markdown conversion. + +### 4. Add Localhost Guard for Webhooks + +Skip webhook registration in development when the URL contains "localhost". + +### 5. Maintain Callback Backward Compatibility + +All callbacks automatically upgrade to new source versions. Only add optional parameters at the end of callback method signatures. + +### 6. Clean Up on Disable + +Delete webhooks, callbacks, and stored state in `onChannelDisabled()`. + +--- + +## Next Steps + +- **[Source Development Guide](../../sources/AGENTS.md)** - Comprehensive scaffold, patterns, and checklist +- **[Sync Strategies](SYNC_STRATEGIES.md)** - Deduplication, upserts, and tag management +- **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Complete reference for Plot, Store, Integrations, and more +- **[Multi-User Auth](MULTI_USER_AUTH.md)** - Per-user auth for write-backs diff --git a/twister/docs/BUILDING_TOOLS.md b/twister/docs/BUILDING_TOOLS.md deleted file mode 100644 index 5f3b85f..0000000 --- a/twister/docs/BUILDING_TOOLS.md +++ /dev/null @@ -1,928 +0,0 @@ ---- -title: Building Custom Tools -group: Guides ---- - -# Building Custom Tools - -Custom tools let you create reusable functionality that can be shared across twists or published for others to use. This guide covers everything you need to know about building tools. - -## Table of Contents - -- [Why Build Tools?](#why-build-tools) -- [Tool Basics](#tool-basics) -- [Tool Structure](#tool-structure) -- [Lifecycle Methods](#lifecycle-methods) -- [Dependencies](#dependencies) -- [Options and Configuration](#options-and-configuration) -- [Complete Examples](#complete-examples) -- [Testing Tools](#testing-tools) -- [Publishing Tools](#publishing-tools) -- [Best Practices](#best-practices) - ---- - -## Why Build Tools? - -Build custom tools when you need to: - -- **Integrate external services** - GitHub, Slack, Notion, etc. -- **Encapsulate complex logic** - Reusable business logic -- **Share functionality** - Between multiple twists -- **Abstract implementation details** - Clean interfaces for common operations - -### Built-in vs. Custom Tools - -| Built-in Tools | Custom Tools | -| ------------------------------ | ------------------------------------ | -| Plot, Store, AI, Network, etc. | Your integrations and utilities | -| Access to Plot internals | Built on top of built-in tools | -| Provided by twist Builder | Created by you or installed from npm | -| Always available | Declared as dependencies | - ---- - -## Tool Basics - -Tools extend the `Tool` base class and can access other tools through dependencies. - -### Minimal Tool Example - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; - -export class HelloTool extends Tool { - async sayHello(name: string): Promise { - return `Hello, ${name}!`; - } -} -``` - -### Using Your Tool - -```typescript -import { type ToolBuilder, twist } from "@plotday/twister"; - -import { HelloTool } from "./tools/hello"; - -export default class MyTwist extends Twist { - build(build: ToolBuilder) { - return { - hello: build(HelloTool), - }; - } - - async activate() { - const message = await this.tools.hello.sayHello("World"); - console.log(message); // "Hello, World!" - } -} -``` - ---- - -## Tool Structure - -### Class Definition - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; - -// Tool class with type parameter -export class MyTool extends Tool { - // Constructor receives id, options, and toolShed - constructor(id: string, options: InferOptions, toolShed: ToolShed) { - super(id, options, toolShed); - } - - // Public methods - async myMethod(): Promise { - // Implementation - } -} -``` - -### Type Parameter - -The type parameter `` enables: - -- Type-safe options inference -- Type-safe tool dependencies -- Proper TypeScript autocomplete - ---- - -## Lifecycle Methods - -Tools have lifecycle methods that run at specific times during the twist lifecycle. - -### preActivate(priority) - -Called **before** the twist's `activate()` method, depth-first. - -```typescript -async preActivate(priority: Priority): Promise { - // Setup that needs to happen before twist activation - console.log("Tool preparing for activation"); - - // Initialize connections, validate configuration, etc. -} -``` - -**Use for:** - -- Validating configuration -- Setting up connections -- Preparing resources - -### postActivate(priority) - -Called **after** the twist's `activate()` method, reverse order. - -```typescript -async postActivate(priority: Priority): Promise { - // Finalization after twist is activated - console.log("Tool finalizing activation"); - - // Start background processes, register webhooks, etc. -} -``` - -**Use for:** - -- Starting background processes -- Registering webhooks -- Final initialization - -### preUpgrade() - -Called **before** the twist's `upgrade()` method. - -**Use for:** - -- Preparing data migrations -- Checking tool version compatibility -- Handling breaking changes to callback signatures - -```typescript -async preUpgrade(): Promise { - // Prepare for upgrade - const version = await this.get("tool_version"); - - if (version === "1.0.0") { - // Migrate data - } -} -``` - -**IMPORTANT:** Tool callbacks automatically upgrade to the new version. Callbacks are resolved by function name at execution time, so callbacks created in v1.0 will use v2.0's code after upgrade. Maintain backward compatibility in callback signatures or recreate callbacks in `preUpgrade()`: - -```typescript -async preUpgrade(): Promise { - const version = await this.get("tool_version"); - - if (version === "1.0.0") { - // Handle breaking change: recreate callbacks with new signature - const syncs = await this.get("active_syncs"); - for (const syncId of syncs) { - // Delete old callback - const oldCallback = await this.get(`sync_${syncId}`); - if (oldCallback) await this.deleteCallback(oldCallback); - - // Create new callback with updated signature - const newCallback = await this.callback("syncBatchV2", syncId); - await this.set(`sync_${syncId}`, newCallback); - } - } -} -``` - -### postUpgrade() - -Called **after** the twist's `upgrade()` method. - -```typescript -async postUpgrade(): Promise { - // Finalize upgrade - await this.set("tool_version", "2.0.0"); -} -``` - -### preDeactivate() - -Called **before** the twist's `deactivate()` method. - -```typescript -async preDeactivate(): Promise { - // Cleanup before deactivation - await this.stopBackgroundProcesses(); -} -``` - -### postDeactivate() - -Called **after** the twist's `deactivate()` method. - -```typescript -async postDeactivate(): Promise { - // Final cleanup - await this.clearAll(); -} -``` - -### Execution Order - -``` -twist Activation: - 1. Tool.preActivate() (deepest dependencies first) - 2. twist.activate() - 3. Tool.postActivate() (top-level tools first) - -twist Deactivation: - 1. Tool.preDeactivate() (deepest dependencies first) - 2. twist.deactivate() - 3. Tool.postDeactivate() (top-level tools first) -``` - ---- - -## Dependencies - -Tools can depend on other tools, including built-in tools. - -### Declaring Dependencies - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; -import { Network } from "@plotday/twister/tools/network"; -import { Store } from "@plotday/twister/tools/store"; - -export class GitHubTool extends Tool { - // Declare dependencies - build(build: ToolBuilder) { - return { - network: build(Network, { - urls: ["https://api.github.com/*"], - }), - store: build(Store), - }; - } - - // Access dependencies - async getRepository(owner: string, repo: string) { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}` - ); - return await response.json(); - } -} -``` - -### Accessing Dependencies - -Use `this.tools` to access declared dependencies: - -```typescript -async fetchData() { - // Tools are fully typed - const data = await this.tools.network.fetch("https://api.example.com/data"); - await this.tools.store.set("cached_data", data); -} -``` - -### Built-in Tool Access - -Tools have direct access to Store, Tasks, and Callbacks methods: - -```typescript -export class MyTool extends Tool { - async doWork() { - // Store - await this.set("key", "value"); - const value = await this.get("key"); - - // Tasks - const callback = await this.callback("processData"); - await this.runTask(callback); - - // Callbacks - await this.deleteCallback(callback); - } -} -``` - ---- - -## Options and Configuration - -Tools can accept configuration options when declared. - -### Defining Options - -```typescript -import { Tool, type ToolBuilder, type InferOptions } from "@plotday/twister"; - -export class SlackTool extends Tool { - // Define static Options type - static Options = { - workspaceId: "" as string, - defaultChannel?: "" as string | undefined, - }; - - // Access via this.options - async postMessage(message: string, channel?: string) { - const targetChannel = channel || this.options.defaultChannel; - - if (!targetChannel) { - throw new Error("No channel specified"); - } - - console.log(`Posting to ${targetChannel} in ${this.options.workspaceId}`); - // Post message... - } -} -``` - -### Using Options - -```typescript -build(build: ToolBuilder) { - return { - slack: build(SlackTool, { - workspaceId: "T1234567", - defaultChannel: "#general" - }), - }; -} -``` - -### Required vs. Optional Options - -```typescript -static Options = { - // Required - no default value, not undefined - apiKey: "" as string, - workspaceId: "" as string, - - // Optional - has undefined as possible value - defaultChannel?: "" as string | undefined, - timeout?: 0 as number | undefined, - - // Optional with default - retryCount: 3 as number, -}; -``` - ---- - -## Complete Examples - -### Example 1: GitHub Integration Tool - -A complete GitHub integration with webhooks and issue management. - -```typescript -import { type Priority, Tool, type ToolBuilder } from "@plotday/twister"; -import { ActivityLinkType, ActivityType } from "@plotday/twister"; -import { Network, type WebhookRequest } from "@plotday/twister/tools/network"; -import { Plot } from "@plotday/twister/tools/plot"; - -export class GitHubTool extends Tool { - static Options = { - owner: "" as string, - repo: "" as string, - token: "" as string, - }; - - build(build: ToolBuilder) { - return { - network: build(Network, { - urls: ["https://api.github.com/*"], - }), - plot: build(Plot), - }; - } - - async postActivate(priority: Priority): Promise { - // Set up webhook for issue updates - const webhookUrl = await this.tools.network.createWebhook("onIssueUpdate", { - priorityId: priority.id, - }); - - await this.set("webhook_url", webhookUrl); - - // Register webhook with GitHub - await this.registerWebhook(webhookUrl); - } - - async preDeactivate(): Promise { - // Cleanup webhook - const webhookUrl = await this.get("webhook_url"); - if (webhookUrl) { - await this.unregisterWebhook(webhookUrl); - await this.tools.network.deleteWebhook(webhookUrl); - } - } - - async getIssues(): Promise { - const response = await fetch( - `https://api.github.com/repos/${this.options.owner}/${this.options.repo}/issues`, - { - headers: { - Authorization: `Bearer ${this.options.token}`, - Accept: "application/vnd.github.v3+json", - }, - } - ); - - return await response.json(); - } - - async syncIssues(): Promise { - const issues = await this.getIssues(); - - for (const issue of issues) { - // Use source for automatic deduplication - no manual ID tracking needed - await this.tools.plot.createActivity({ - source: issue.html_url, // Enables automatic upserts - type: ActivityType.Action, - title: issue.title, - meta: { - github_issue_id: issue.id.toString(), - github_number: issue.number.toString(), - }, - notes: [ - { - activity: { source: issue.html_url }, - key: "description", // Using key enables upserts - content: issue.body, - links: [ - { - type: ActivityLinkType.external, - title: "View on GitHub", - url: issue.html_url, - }, - ], - }, - ], - }); - } - } - - // Note: For advanced sync patterns (batching, pagination, etc.), - // see the Sync Strategies guide: https://twist.plot.day/documents/Sync_Strategies.html - - async onIssueUpdate( - request: WebhookRequest, - context: { priorityId: string } - ): Promise { - const { action, issue, comment } = request.body; - - if (action === "opened") { - // 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.getActivityBySource({ - github_issue_id: issue.id.toString(), - }); - - if (activity) { - await this.tools.plot.updateActivity(activity.id, { - done: new Date(), - }); - } - } - } - - private async registerWebhook(url: string): Promise { - await fetch( - `https://api.github.com/repos/${this.options.owner}/${this.options.repo}/hooks`, - { - method: "POST", - headers: { - Authorization: `Bearer ${this.options.token}`, - Accept: "application/vnd.github.v3+json", - }, - body: JSON.stringify({ - config: { url, content_type: "json" }, - events: ["issues"], - }), - } - ); - } - - private async unregisterWebhook(url: string): Promise { - // Implementation to remove webhook from GitHub - } -} -``` - -### Example 2: Slack Notification Tool - -A tool for sending Slack notifications. - -```typescript -import { Tool, type ToolBuilder } from "@plotday/twister"; -import { Network } from "@plotday/twister/tools/network"; - -export class SlackTool extends Tool { - static Options = { - webhookUrl: "" as string, - defaultChannel?: "" as string | undefined, - }; - - build(build: ToolBuilder) { - return { - network: build(Network, { - urls: ["https://hooks.slack.com/*"] - }), - }; - } - - async sendMessage(options: { - text: string; - channel?: string; - username?: string; - }): Promise { - const payload = { - text: options.text, - channel: options.channel || this.options.defaultChannel, - username: options.username || "Plot Bot" - }; - - const response = await fetch(this.options.webhookUrl, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - throw new Error(`Slack API error: ${response.statusText}`); - } - } - - async sendAlert(message: string): Promise { - await this.sendMessage({ - text: `:warning: ${message}`, - channel: "#alerts" - }); - } -} -``` - ---- - -## Testing Tools - -### Unit Testing - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; - -import { GitHubTool } from "./github-tool"; - -describe("GitHubTool", () => { - let tool: GitHubTool; - - beforeEach(() => { - tool = new GitHubTool( - "test-id", - { - owner: "test-owner", - repo: "test-repo", - token: "test-token", - }, - mockToolShed - ); - }); - - it("fetches issues", async () => { - const issues = await tool.getIssues(); - expect(issues).toBeInstanceOf(Array); - }); - - it("validates configuration", () => { - expect(tool.options.owner).toBe("test-owner"); - expect(tool.options.repo).toBe("test-repo"); - }); -}); -``` - -### Integration Testing - -Test your tool with a real twist: - -```typescript -import { type ToolBuilder, twist } from "@plotday/twister"; -import { Plot } from "@plotday/twister/tools/plot"; - -import { GitHubTool } from "./github-tool"; - -class TestTwist extends Twist { - build(build: ToolBuilder) { - return { - plot: build(Plot), - github: build(GitHubTool, { - owner: "plotday", - repo: "plot", - token: process.env.GITHUB_TOKEN!, - }), - }; - } - - async activate() { - // Test syncing - await this.tools.github.syncIssues(); - } -} -``` - ---- - -## Publishing Tools - -### Package Structure - -``` -my-plot-tool/ -├── src/ -│ └── index.ts # Tool implementation -├── package.json -├── tsconfig.json -├── README.md -└── LICENSE -``` - -### package.json - -```json -{ - "name": "@mycompany/plot-github-tool", - "version": "1.0.0", - "description": "GitHub integration tool for Plot twists", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "tsc", - "test": "vitest" - }, - "peerDependencies": { - "@plotday/twister": "^0.16.0" - }, - "devDependencies": { - "@plotday/twister": "^0.16.0", - "typescript": "^5.0.0" - } -} -``` - -### Publishing - -```bash -# Build -npm run build - -# Test -npm test - -# Publish -npm publish -``` - -### Documentation - -Include comprehensive README with: - -- Installation instructions -- Configuration options -- Usage examples -- API reference - ---- - -## Best Practices - -### 1. Single Responsibility - -Each tool should have a single, well-defined purpose: - -```typescript -// ✅ GOOD - Focused on GitHub -class GitHubTool extends Tool { - async getIssues() { - /* ... */ - } - async createIssue() { - /* ... */ - } -} - -// ❌ BAD - Mixed concerns -class IntegrationTool extends Tool { - async getGitHubIssues() { - /* ... */ - } - async sendSlackMessage() { - /* ... */ - } - async createJiraTicket() { - /* ... */ - } -} -``` - -### 2. Type Safety - -Use TypeScript features for type safety: - -```typescript -export interface GitHubIssue { - id: number; - title: string; - body: string; - state: "open" | "closed"; -} - -export class GitHubTool extends Tool { - async getIssues(): Promise { - // Return type is enforced - } -} -``` - -### 3. Error Handling - -Handle errors gracefully: - -```typescript -async fetchData(): Promise { - try { - const response = await fetch(this.apiUrl); - - if (!response.ok) { - console.error(`API error: ${response.status}`); - return null; - } - - return await response.json(); - } catch (error) { - console.error("Network error:", error); - return null; - } -} -``` - -### 4. Configuration Validation - -Validate options in preActivate: - -```typescript -async preActivate(priority: Priority): Promise { - if (!this.options.apiKey) { - throw new Error("API key is required"); - } - - if (!this.options.workspaceId.startsWith("T")) { - throw new Error("Invalid workspace ID format"); - } -} -``` - -### 5. Resource Cleanup - -Always clean up resources in deactivation: - -```typescript -async postDeactivate(): Promise { - // Cancel pending tasks - await this.cancelAllTasks(); - - // Delete callbacks - await this.deleteAllCallbacks(); - - // Clear stored data - await this.clearAll(); -} -``` - -### 6. Avoid Instance State - -Use Store instead of instance variables: - -```typescript -// ❌ WRONG - Instance state doesn't persist -class MyTool extends Tool { - private cache: Map = new Map(); -} - -// ✅ CORRECT - Use Store -class MyTool extends Tool { - async getFromCache(key: string) { - return await this.get(`cache:${key}`); - } - - async setInCache(key: string, value: any) { - await this.set(`cache:${key}`, value); - } -} -``` - -### 7. Document Your API - -Add JSDoc comments for documentation: - -````typescript -/** - * Fetches all open issues from the GitHub repository. - * - * @returns Promise resolving to array of GitHub issues - * @throws Error if GitHub API is unavailable - * - * @example - * ```typescript - * const issues = await this.tools.github.getIssues(); - * ``` - */ -async getIssues(): Promise { - // Implementation -} -```` - -### 8. Data Synchronization - -When building tools that sync from external systems, use the recommended patterns for deduplication and updates: - -**Recommended:** Use `Activity.source` and `Note.key` for automatic upserts: - -```typescript -// ✅ GOOD - Automatic deduplication via source -async syncItems(items: ExternalItem[]): Promise { - for (const item of items) { - await this.tools.plot.createActivity({ - source: item.url, // Canonical URL for deduplication - type: ActivityType.Action, - title: item.title, - notes: [{ - activity: { source: item.url }, - key: "description", // Enables note upserts - content: item.description, - }], - }); - } -} - -// ❌ BAD - Manual ID tracking (only use for advanced cases) -async syncItemsManual(items: ExternalItem[]): Promise { - for (const item of items) { - const activityId = await this.get(`item:${item.id}`) || Uuid.Generate(); - await this.set(`item:${item.id}`, activityId); - await this.tools.plot.createActivity({ - id: activityId, - // ... missing automatic deduplication - }); - } -} -``` - -For comprehensive guidance on choosing the right sync strategy (upserts, batching, pagination, tag management, etc.), see the **[Sync Strategies Guide](SYNC_STRATEGIES.md)**. - ---- - -## Next Steps - -- **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Learn from built-in tool patterns -- **API Reference** - Explore the Tool class API diff --git a/twister/docs/CORE_CONCEPTS.md b/twister/docs/CORE_CONCEPTS.md index 754bf40..009a8d8 100644 --- a/twister/docs/CORE_CONCEPTS.md +++ b/twister/docs/CORE_CONCEPTS.md @@ -65,11 +65,12 @@ export default class MyTwist extends Twist { Use twists for: -- **Integrations** - Connecting external services (Google Calendar, GitHub, Slack) - **Automations** - Automatic task creation, reminders, status updates - **Data Processing** - Analyzing and organizing activities - **Notifications** - Sending alerts based on conditions +For external service integrations (Google Calendar, GitHub, Slack, etc.), build a **Source** instead. Sources extend `Source` (which itself extends `Twist`) and provide the OAuth and channel lifecycle needed for syncing external data. See [Building Sources](BUILDING_SOURCES.md). + --- ## Twist Tools @@ -92,15 +93,11 @@ Core Plot functionality provided by the Twist Creator: See the [Built-in Tools Guide](TOOLS_GUIDE.md) for complete documentation. -#### 2. Custom Tools - -Tools you create or install from npm packages: +#### 2. Sources -- **External Service Integrations** - Google Calendar, Slack, GitHub -- **Data Processors** - Text analysis, image processing -- **Utilities** - Date formatting, validation +External service integrations are built as Sources, which extend `Source`. Sources declare OAuth providers, expose channels for users to enable/disable, and sync data from services like Google Calendar, Slack, GitHub, and more. -See [Building Custom Tools](BUILDING_TOOLS.md) to create your own. +See [Building Sources](BUILDING_SOURCES.md) to create your own. ### Declaring Tool Dependencies @@ -758,5 +755,5 @@ await this.tools.plot.createActivity({ ## Next Steps - **[Built-in Tools Guide](TOOLS_GUIDE.md)** - Learn about Plot, Store, AI, and more -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create reusable tools +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations - **[Runtime Environment](RUNTIME.md)** - Understand execution constraints diff --git a/twister/docs/GETTING_STARTED.md b/twister/docs/GETTING_STARTED.md index 5a3f933..10d4893 100644 --- a/twister/docs/GETTING_STARTED.md +++ b/twister/docs/GETTING_STARTED.md @@ -213,7 +213,7 @@ Now that you have a basic twist running, explore: - **[Core Concepts](CORE_CONCEPTS.md)** - Understand twists, tools, and the Plot architecture - **[Built-in Tools](TOOLS_GUIDE.md)** - Learn about Plot, Store, Integrations, AI, and more -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own reusable twist tools +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations - **[Runtime Environment](RUNTIME.md)** - Understand execution constraints and optimization ## Common First Tasks diff --git a/twister/docs/MULTI_USER_AUTH.md b/twister/docs/MULTI_USER_AUTH.md index a87f62d..bfffcac 100644 --- a/twister/docs/MULTI_USER_AUTH.md +++ b/twister/docs/MULTI_USER_AUTH.md @@ -1,6 +1,6 @@ # Multi-User Priority Auth -Twists and tools operating in shared priorities must handle authentication for multiple users. This guide covers the patterns for per-user auth and private auth activities. +Twists and sources operating in shared priorities must handle authentication for multiple users. This guide covers the patterns for per-user auth and private auth activities. ## Auth Models diff --git a/twister/docs/SYNC_STRATEGIES.md b/twister/docs/SYNC_STRATEGIES.md index de0c3b6..201897d 100644 --- a/twister/docs/SYNC_STRATEGIES.md +++ b/twister/docs/SYNC_STRATEGIES.md @@ -1,6 +1,6 @@ # Sync Strategies -This guide explains good ways to build tools that sync other services with Plot. Choosing the right strategy depends on whether you need to update items, deduplicate them, or simply create them once. +This guide explains good ways to build sources that sync other services with Plot. Choosing the right strategy depends on whether you need to update items, deduplicate them, or simply create them once. ## Table of Contents @@ -115,7 +115,7 @@ interface Activity { ### Example: Calendar Event Sync ```typescript -export default class GoogleCalendarTool extends Tool { +export default class GoogleCalendarSource extends Source { async syncEvent(event: calendar_v3.Schema$Event): Promise { const activity: NewActivityWithNotes = { // Use the event's canonical URL as the source @@ -168,7 +168,7 @@ export default class GoogleCalendarTool extends Tool { ### Example: Task/Issue Sync ```typescript -export default class LinearTool extends Tool { +export default class LinearSource extends Source { async syncIssue(issue: LinearIssue): Promise { const activity: NewActivityWithNotes = { source: issue.url, // Linear provides stable URLs @@ -274,7 +274,7 @@ Use this strategy when: ### Example: Multiple Activities from Single Source ```typescript -export default class EmailTool extends Tool { +export default class GmailSource extends Source { /** * Creates separate activities for email threads and individual messages. * One email thread can have multiple Plot activities. @@ -694,9 +694,9 @@ if (existingId) { ## Best Practices -### 1. Be Consistent Within a Tool +### 1. Be Consistent Within a Source -Choose one strategy per tool and stick with it. Mixing strategies in the same tool can lead to confusion and bugs. +Choose one strategy per source and stick with it. Mixing strategies in the same source can lead to confusion and bugs. ### 2. Use Descriptive Keys @@ -805,4 +805,4 @@ For more information: - [Core Concepts](CORE_CONCEPTS.md) - Understanding activities, notes, and priorities - [Tools Guide](TOOLS_GUIDE.md) - Complete reference for the Plot tool -- [Building Tools](BUILDING_TOOLS.md) - Creating custom tools +- [Building Sources](BUILDING_SOURCES.md) - Creating external service integrations diff --git a/twister/docs/TOOLS_GUIDE.md b/twister/docs/TOOLS_GUIDE.md index 424566b..263b3b8 100644 --- a/twister/docs/TOOLS_GUIDE.md +++ b/twister/docs/TOOLS_GUIDE.md @@ -1159,6 +1159,6 @@ build(build: SourceBuilder) { ## Next Steps -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own reusable tools +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations - **[Runtime Environment](RUNTIME.md)** - Understanding execution constraints - **API Reference** - Explore detailed API docs in the sidebar diff --git a/twister/docs/index.md b/twister/docs/index.md index b974266..d6396bf 100644 --- a/twister/docs/index.md +++ b/twister/docs/index.md @@ -40,12 +40,11 @@ Plot Twists are smart automations that connect, organize, and prioritize your wo - Callbacks - Persistent function references - AI - Language model integration -- **[Building Custom Tools](BUILDING_TOOLS.md)** - Create your own twist tools - - Tool class structure - - Lifecycle methods - - Dependencies and configuration +- **[Building Sources](BUILDING_SOURCES.md)** - Build external service integrations + - Source class structure and lifecycle + - OAuth and channel management + - Data sync and batch processing - Complete examples and best practices - - Publishing and sharing ### Reference @@ -67,7 +66,7 @@ Plot Twists are smart automations that connect, organize, and prioritize your wo Explore the complete API documentation using the navigation on the left: -- **Classes** - Twist, Tool, and all built-in tool classes +- **Classes** - Twist, Source, Tool, and all built-in tool classes - **Interfaces** - Activity, Priority, Contact, and data structures - **Enums** - ActivityType, ActorType, and other enumerations - **Type Aliases** - Helper types and utilities diff --git a/twister/typedoc.json b/twister/typedoc.json index bf8e958..0180eaf 100644 --- a/twister/typedoc.json +++ b/twister/typedoc.json @@ -37,7 +37,7 @@ "docs/GETTING_STARTED.md", "docs/CORE_CONCEPTS.md", "docs/TOOLS_GUIDE.md", - "docs/BUILDING_TOOLS.md", + "docs/BUILDING_SOURCES.md", "docs/CLI_REFERENCE.md", "docs/RUNTIME.md" ], From c42cc5a15d8d5b73552e2f2dbe675055af5aaa22 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Wed, 4 Mar 2026 00:12:55 -0500 Subject: [PATCH 25/25] Update lockfile --- pnpm-lock.yaml | 160 ------------------------------------------------- 1 file changed, 160 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9804b3..1010c98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,19 +53,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - sources/github-issues: - dependencies: - '@octokit/rest': - specifier: ^21.1.1 - version: 21.1.1 - '@plotday/twister': - specifier: workspace:^ - version: link:../../twister - devDependencies: - typescript: - specifier: ^5.9.3 - version: 5.9.3 - sources/gmail: dependencies: '@plotday/twister': @@ -222,12 +209,6 @@ importers: twists/message-tasks: dependencies: - '@plotday/source-gmail': - specifier: workspace:^ - version: link:../../sources/gmail - '@plotday/source-slack': - specifier: workspace:^ - version: link:../../sources/slack '@plotday/twister': specifier: workspace:^ version: link:../../twister @@ -744,64 +725,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@octokit/auth-token@5.1.2': - resolution: {integrity: sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==} - engines: {node: '>= 18'} - - '@octokit/core@6.1.6': - resolution: {integrity: sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==} - engines: {node: '>= 18'} - - '@octokit/endpoint@10.1.4': - resolution: {integrity: sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==} - engines: {node: '>= 18'} - - '@octokit/graphql@8.2.2': - resolution: {integrity: sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==} - engines: {node: '>= 18'} - - '@octokit/openapi-types@24.2.0': - resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} - - '@octokit/openapi-types@25.1.0': - resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} - - '@octokit/plugin-paginate-rest@11.6.0': - resolution: {integrity: sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-request-log@5.3.1': - resolution: {integrity: sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-rest-endpoint-methods@13.5.0': - resolution: {integrity: sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==} - engines: {node: '>= 18'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/request-error@6.1.8': - resolution: {integrity: sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==} - engines: {node: '>= 18'} - - '@octokit/request@9.2.4': - resolution: {integrity: sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==} - engines: {node: '>= 18'} - - '@octokit/rest@21.1.1': - resolution: {integrity: sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==} - engines: {node: '>= 18'} - - '@octokit/types@13.10.0': - resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} - - '@octokit/types@14.1.0': - resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - '@shikijs/engine-oniguruma@3.14.0': resolution: {integrity: sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==} @@ -883,9 +806,6 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true - before-after-hook@3.0.2: - resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} - better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -1055,9 +975,6 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - fast-content-type-parse@2.0.1: - resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1587,9 +1504,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - universal-user-agent@7.0.3: - resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -2134,74 +2048,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@octokit/auth-token@5.1.2': {} - - '@octokit/core@6.1.6': - dependencies: - '@octokit/auth-token': 5.1.2 - '@octokit/graphql': 8.2.2 - '@octokit/request': 9.2.4 - '@octokit/request-error': 6.1.8 - '@octokit/types': 14.1.0 - before-after-hook: 3.0.2 - universal-user-agent: 7.0.3 - - '@octokit/endpoint@10.1.4': - dependencies: - '@octokit/types': 14.1.0 - universal-user-agent: 7.0.3 - - '@octokit/graphql@8.2.2': - dependencies: - '@octokit/request': 9.2.4 - '@octokit/types': 14.1.0 - universal-user-agent: 7.0.3 - - '@octokit/openapi-types@24.2.0': {} - - '@octokit/openapi-types@25.1.0': {} - - '@octokit/plugin-paginate-rest@11.6.0(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/types': 13.10.0 - - '@octokit/plugin-request-log@5.3.1(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - - '@octokit/plugin-rest-endpoint-methods@13.5.0(@octokit/core@6.1.6)': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/types': 13.10.0 - - '@octokit/request-error@6.1.8': - dependencies: - '@octokit/types': 14.1.0 - - '@octokit/request@9.2.4': - dependencies: - '@octokit/endpoint': 10.1.4 - '@octokit/request-error': 6.1.8 - '@octokit/types': 14.1.0 - fast-content-type-parse: 2.0.1 - universal-user-agent: 7.0.3 - - '@octokit/rest@21.1.1': - dependencies: - '@octokit/core': 6.1.6 - '@octokit/plugin-paginate-rest': 11.6.0(@octokit/core@6.1.6) - '@octokit/plugin-request-log': 5.3.1(@octokit/core@6.1.6) - '@octokit/plugin-rest-endpoint-methods': 13.5.0(@octokit/core@6.1.6) - - '@octokit/types@13.10.0': - dependencies: - '@octokit/openapi-types': 24.2.0 - - '@octokit/types@14.1.0': - dependencies: - '@octokit/openapi-types': 25.1.0 - '@shikijs/engine-oniguruma@3.14.0': dependencies: '@shikijs/types': 3.14.0 @@ -2291,8 +2137,6 @@ snapshots: baseline-browser-mapping@2.9.11: {} - before-after-hook@3.0.2: {} - better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -2494,8 +2338,6 @@ snapshots: extendable-error@0.1.7: {} - fast-content-type-parse@2.0.1: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2980,8 +2822,6 @@ snapshots: undici-types@7.16.0: {} - universal-user-agent@7.0.3: {} - universalify@0.1.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1):