@plotday/twister 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/deploy.js +4 -0
- package/bin/commands/deploy.js.map +1 -1
- package/bin/templates/AGENTS.template.md +6 -10
- package/cli/templates/AGENTS.template.md +6 -10
- package/dist/connector.d.ts +164 -10
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js +78 -4
- package/dist/connector.js.map +1 -1
- package/dist/docs/assets/hierarchy.js +1 -1
- package/dist/docs/assets/navigation.js +1 -1
- package/dist/docs/assets/search.js +1 -1
- package/dist/docs/classes/index.Connector.html +74 -26
- package/dist/docs/classes/index.FileNotFoundError.html +2 -0
- package/dist/docs/classes/index.Files.html +16 -0
- package/dist/docs/classes/index.Imap.html +1 -1
- package/dist/docs/classes/index.Options.html +1 -1
- package/dist/docs/classes/index.Smtp.html +1 -1
- package/dist/docs/classes/tool.ITool.html +1 -1
- package/dist/docs/classes/tools_ai.AI.html +1 -1
- package/dist/docs/classes/tools_callbacks.Callbacks.html +1 -1
- package/dist/docs/classes/tools_integrations.Integrations.html +17 -23
- package/dist/docs/classes/tools_network.Network.html +1 -1
- package/dist/docs/classes/tools_plot.Plot.html +1 -1
- package/dist/docs/classes/tools_store.Store.html +1 -1
- package/dist/docs/classes/tools_tasks.Tasks.html +1 -1
- package/dist/docs/classes/tools_twists.Twists.html +1 -1
- package/dist/docs/documents/Building_Connectors.html +1 -1
- package/dist/docs/enums/plot.ActionType.html +10 -8
- package/dist/docs/enums/plot.ActorType.html +4 -4
- package/dist/docs/enums/plot.ConferencingProvider.html +6 -6
- package/dist/docs/enums/plot.ThemeColor.html +9 -9
- package/dist/docs/enums/tag.Tag.html +16 -46
- package/dist/docs/enums/tools_integrations.AuthProvider.html +13 -13
- package/dist/docs/hierarchy.html +1 -1
- package/dist/docs/media/AGENTS.md +22 -14
- package/dist/docs/modules/index.html +1 -1
- package/dist/docs/modules/plot.html +1 -1
- package/dist/docs/modules/tools_integrations.html +1 -1
- package/dist/docs/types/index.CreateLinkDraft.html +30 -7
- package/dist/docs/types/index.NoteWriteBackResult.html +3 -3
- package/dist/docs/types/index.ReactionCapabilities.html +17 -0
- package/dist/docs/types/index.ResolvedRecipient.html +13 -0
- package/dist/docs/types/plot.Action.html +9 -2
- package/dist/docs/types/plot.Actor.html +5 -5
- package/dist/docs/types/plot.ActorId.html +1 -1
- package/dist/docs/types/plot.Contact.html +4 -4
- package/dist/docs/types/plot.ContentType.html +1 -1
- package/dist/docs/types/plot.Link.html +16 -16
- package/dist/docs/types/plot.LinkUpdate.html +1 -1
- package/dist/docs/types/plot.NewActor.html +1 -1
- package/dist/docs/types/plot.NewContact.html +1 -1
- package/dist/docs/types/plot.NewLink.html +1 -1
- package/dist/docs/types/plot.NewLinkWithNotes.html +1 -1
- package/dist/docs/types/plot.NewNote.html +4 -2
- package/dist/docs/types/plot.NewPriority.html +1 -1
- package/dist/docs/types/plot.NewReactions.html +4 -0
- package/dist/docs/types/plot.NewTags.html +1 -1
- package/dist/docs/types/plot.NewThread.html +4 -2
- package/dist/docs/types/plot.NewThreadWithNotes.html +1 -1
- package/dist/docs/types/plot.Note.html +1 -1
- package/dist/docs/types/plot.NoteUpdate.html +4 -2
- package/dist/docs/types/plot.PlanOperation.html +1 -1
- package/dist/docs/types/plot.Priority.html +6 -6
- package/dist/docs/types/plot.PriorityUpdate.html +1 -1
- package/dist/docs/types/plot.Reaction.html +13 -0
- package/dist/docs/types/plot.Reactions.html +3 -0
- package/dist/docs/types/plot.Tags.html +1 -1
- package/dist/docs/types/plot.Thread.html +1 -1
- package/dist/docs/types/plot.ThreadAccessLevel.html +1 -1
- package/dist/docs/types/plot.ThreadCommon.html +9 -5
- package/dist/docs/types/plot.ThreadFilter.html +2 -2
- package/dist/docs/types/plot.ThreadMeta.html +1 -1
- package/dist/docs/types/plot.ThreadType.html +1 -1
- package/dist/docs/types/plot.ThreadUpdate.html +1 -1
- package/dist/docs/types/plot.ThreadWithNotes.html +1 -1
- package/dist/docs/types/tools_integrations.ArchiveLinkFilter.html +5 -5
- package/dist/docs/types/tools_integrations.AuthToken.html +4 -4
- package/dist/docs/types/tools_integrations.Authorization.html +4 -4
- package/dist/docs/types/tools_integrations.Channel.html +5 -5
- package/dist/docs/types/tools_integrations.ComposeConfig.html +35 -0
- package/dist/docs/types/tools_integrations.ContactRoleConfig.html +14 -0
- package/dist/docs/types/tools_integrations.LinkTypeConfig.html +69 -19
- package/dist/docs/types/tools_integrations.SyncContext.html +3 -3
- package/dist/llm-docs/connector.d.ts +1 -1
- package/dist/llm-docs/connector.d.ts.map +1 -1
- package/dist/llm-docs/connector.js +1 -1
- package/dist/llm-docs/connector.js.map +1 -1
- package/dist/llm-docs/index.d.ts.map +1 -1
- package/dist/llm-docs/index.js +2 -0
- package/dist/llm-docs/index.js.map +1 -1
- package/dist/llm-docs/plot.d.ts +1 -1
- package/dist/llm-docs/plot.d.ts.map +1 -1
- package/dist/llm-docs/plot.js +1 -1
- package/dist/llm-docs/plot.js.map +1 -1
- package/dist/llm-docs/tag.d.ts +1 -1
- package/dist/llm-docs/tag.d.ts.map +1 -1
- package/dist/llm-docs/tag.js +1 -1
- package/dist/llm-docs/tag.js.map +1 -1
- package/dist/llm-docs/tools/files.d.ts +9 -0
- package/dist/llm-docs/tools/files.d.ts.map +1 -0
- package/dist/llm-docs/tools/files.js +8 -0
- package/dist/llm-docs/tools/files.js.map +1 -0
- package/dist/llm-docs/tools/integrations.d.ts +1 -1
- package/dist/llm-docs/tools/integrations.d.ts.map +1 -1
- package/dist/llm-docs/tools/integrations.js +1 -1
- package/dist/llm-docs/tools/integrations.js.map +1 -1
- package/dist/llm-docs/twist-guide-template.d.ts +1 -1
- package/dist/llm-docs/twist-guide-template.d.ts.map +1 -1
- package/dist/llm-docs/twist-guide-template.js +1 -1
- package/dist/llm-docs/twist-guide-template.js.map +1 -1
- package/dist/plot.d.ts +85 -4
- package/dist/plot.d.ts.map +1 -1
- package/dist/plot.js +2 -0
- package/dist/plot.js.map +1 -1
- package/dist/tag.d.ts +17 -43
- package/dist/tag.d.ts.map +1 -1
- package/dist/tag.js +17 -46
- package/dist/tag.js.map +1 -1
- package/dist/tools/files.d.ts +33 -0
- package/dist/tools/files.d.ts.map +1 -0
- package/dist/tools/files.js +22 -0
- package/dist/tools/files.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +1 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/integrations.d.ts +152 -32
- package/dist/tools/integrations.d.ts.map +1 -1
- package/dist/tools/integrations.js.map +1 -1
- package/dist/twist-guide.d.ts +1 -1
- package/dist/twist-guide.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/connector.ts +167 -10
- package/src/llm-docs/connector.ts +1 -1
- package/src/llm-docs/index.ts +2 -0
- package/src/llm-docs/plot.ts +1 -1
- package/src/llm-docs/tag.ts +1 -1
- package/src/llm-docs/tools/files.ts +8 -0
- package/src/llm-docs/tools/integrations.ts +1 -1
- package/src/llm-docs/twist-guide-template.ts +1 -1
- package/src/plot.ts +94 -4
- package/src/tag.ts +17 -48
- package/src/tools/files.ts +37 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/integrations.ts +153 -43
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../../src/llm-docs/tag.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;wBAEY,
|
|
1
|
+
{"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../../src/llm-docs/tag.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;wBAEY,ugCAA8+B;AAA7/B,wBAA8/B"}
|
package/dist/llm-docs/tag.js
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* This file is auto-generated during build. Do not edit manually.
|
|
5
5
|
* Generated from: prebuild.ts
|
|
6
6
|
*/
|
|
7
|
-
export default "/**\n *
|
|
7
|
+
export default "/**\n * Compute tags — system state the runtime auto-manages on threads and\n * notes (`todo`, `done`, `twist` activity marker, …).\n *\n * The toggle range (100–999) and count range (1000–1027) have been\n * retired in favour of the open Unicode emoji `Reaction` type — see\n * `@plotday/twister/plot`'s `Reactions` / `NewReactions` and the\n * per-row `note.reactions` / `thread.reactions` fields. Connectors\n * route emoji reactions through `reactions`, not `tags`.\n *\n * `Tag.Twist` is the surviving non-trivial tag: a system marker the\n * runtime adds to a note while a twist is processing it, and clears\n * once the twist returns. It's not user-facing and not a reaction.\n */\nexport enum Tag {\n Todo = 1,\n Done = 3,\n /** System marker for \"a twist is processing this note.\" Set by the\n * runtime when a twist callback fires, cleared on return. Twists\n * may still write `{ [Tag.Twist]: true | false }` to twistTags to\n * mark/unmark a note explicitly. */\n Twist = 12,\n}\n";
|
|
8
8
|
//# sourceMappingURL=tag.js.map
|
package/dist/llm-docs/tag.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tag.js","sourceRoot":"","sources":["../../src/llm-docs/tag.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAe,
|
|
1
|
+
{"version":3,"file":"tag.js","sourceRoot":"","sources":["../../src/llm-docs/tag.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAe,8+BAA8+B,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated LLM documentation for @plotday/twister/tools/files
|
|
3
|
+
*
|
|
4
|
+
* This file is auto-generated during build. Do not edit manually.
|
|
5
|
+
* Generated from: prebuild.ts
|
|
6
|
+
*/
|
|
7
|
+
declare const _default: "import { ITool } from \"..\";\n\n/**\n * Built-in tool for reading files attached to notes in Plot.\n *\n * Files are uploaded by clients via POST /files which creates an\n * ActionType.file entry on a note. Connectors call read() during outbound\n * (e.g. onNoteCreated) to retrieve those bytes and send them to the source\n * system.\n *\n * For inbound attachments, connectors emit ActionType.fileRef actions and\n * implement Connector.downloadAttachment \u2014 no upload tool is needed because\n * inbound bytes never enter Plot's R2 storage.\n */\nexport abstract class Files extends ITool {\n /**\n * Read a file uploaded by a client and attached to a note in a priority\n * where this twist is installed.\n *\n * @param fileId The id from an ActionType.file action.\n * @returns Bytes plus original metadata.\n * @throws FileNotFoundError if the file is missing or out of scope.\n */\n abstract read(fileId: string): Promise<{\n data: Uint8Array;\n fileName: string;\n mimeType: string;\n fileSize: number;\n }>;\n}\n\nexport class FileNotFoundError extends Error {\n constructor(fileId: string) {\n super(`File not found or out of scope: ${fileId}`);\n this.name = \"FileNotFoundError\";\n }\n}\n";
|
|
8
|
+
export default _default;
|
|
9
|
+
//# sourceMappingURL=files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../../src/llm-docs/tools/files.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;wBAEY,4tCAAutC;AAAtuC,wBAAuuC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated LLM documentation for @plotday/twister/tools/files
|
|
3
|
+
*
|
|
4
|
+
* This file is auto-generated during build. Do not edit manually.
|
|
5
|
+
* Generated from: prebuild.ts
|
|
6
|
+
*/
|
|
7
|
+
export default "import { ITool } from \"..\";\n\n/**\n * Built-in tool for reading files attached to notes in Plot.\n *\n * Files are uploaded by clients via POST /files which creates an\n * ActionType.file entry on a note. Connectors call read() during outbound\n * (e.g. onNoteCreated) to retrieve those bytes and send them to the source\n * system.\n *\n * For inbound attachments, connectors emit ActionType.fileRef actions and\n * implement Connector.downloadAttachment — no upload tool is needed because\n * inbound bytes never enter Plot's R2 storage.\n */\nexport abstract class Files extends ITool {\n /**\n * Read a file uploaded by a client and attached to a note in a priority\n * where this twist is installed.\n *\n * @param fileId The id from an ActionType.file action.\n * @returns Bytes plus original metadata.\n * @throws FileNotFoundError if the file is missing or out of scope.\n */\n abstract read(fileId: string): Promise<{\n data: Uint8Array;\n fileName: string;\n mimeType: string;\n fileSize: number;\n }>;\n}\n\nexport class FileNotFoundError extends Error {\n constructor(fileId: string) {\n super(`File not found or out of scope: ${fileId}`);\n this.name = \"FileNotFoundError\";\n }\n}\n";
|
|
8
|
+
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"files.js","sourceRoot":"","sources":["../../../src/llm-docs/tools/files.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAe,utCAAutC,CAAC"}
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
* This file is auto-generated during build. Do not edit manually.
|
|
5
5
|
* Generated from: prebuild.ts
|
|
6
6
|
*/
|
|
7
|
-
declare const _default: "import {\n type Actor,\n type ActorId,\n type NewContact,\n type NewLinkWithNotes,\n ITool,\n Serializable,\n} from \"..\";\nimport { Tag } from \"../tag\";\nimport type { JSONValue } from \"../utils/types\";\nimport type { Uuid } from \"../utils/uuid\";\n\n/**\n * A resource that can be synced (e.g., a calendar, project, channel).\n * Returned by getChannels() and managed by users in the twist setup/edit modal.\n */\nexport type Channel = {\n /** External ID shared across users (e.g., Google calendar ID) */\n id: string;\n /** Display name shown in the UI */\n title: string;\n /** Optional nested channel resources (e.g., subfolders) */\n children?: Channel[];\n /** Per-channel link type configs. Overrides twist-level linkTypes when present. */\n linkTypes?: LinkTypeConfig[];\n};\n\n/**\n * Describes a link type that a connector creates.\n * Used for display in the UI (icons, labels).\n */\nexport type LinkTypeConfig = {\n /** Machine-readable type identifier (e.g., \"issue\", \"pull_request\") */\n type: string;\n /** Human-readable label (e.g., \"Issue\", \"Pull Request\") */\n label: string;\n /** URL to an icon for this link type (light mode). Prefer Iconify `logos/*` URLs. */\n logo?: string;\n /** URL to an icon for dark mode. Use when the default logo is invisible on dark backgrounds (e.g., Iconify `simple-icons/*` with `?color=`). */\n logoDark?: string;\n /** URL to a monochrome icon (uses `currentColor`). Prefer Iconify `simple-icons/*` URLs without a `?color=` param. */\n logoMono?: string;\n /** Possible status values for this type */\n statuses?: Array<{\n /** Machine-readable status (e.g., \"open\", \"done\") */\n status: string;\n /** Human-readable label (e.g., \"Open\", \"Done\") */\n label: string;\n /** Tag to propagate to thread when this status is active (e.g., Tag.Done) */\n tag?: Tag;\n /** Whether this status represents completion (done, closed, merged, cancelled, etc.) */\n done?: boolean;\n /**\n * Whether this status represents the connector's \"to-do\" / active state.\n * When a user adds a thread to Plot's agenda, done-status links flip to\n * the status marked `todo: true` (e.g., Gmail's \"starred\", Linear's\n * \"todo\") so the link widget and thread tags reflect the active state.\n * At most one status per type should set this.\n */\n todo?: boolean;\n /**\n * Default status applied when Plot asks the connector to create a new\n * item of this type via `Connector.onCreateLink`. Declaring at least one\n * status with `createDefault: true` is how a link type opts in to\n * Plot-initiated creation. At most one status per type should set this.\n */\n createDefault?: boolean;\n }>;\n /** Whether this link type supports displaying and changing the assignee */\n supportsAssignee?: boolean;\n /** Default thread creation mode for this link type: 'all' | 'actionable' | 'manual' */\n defaultCreateThreads?: string;\n};\n\n/**\n * Context passed to onChannelEnabled with plan-based sync hints.\n * Connectors can use these hints to limit initial sync scope.\n */\nexport type SyncContext = {\n /**\n * Earliest date to include in initial sync, based on the user's plan.\n *\n * Non-calendar connectors should use this as their date filter (timeMin,\n * created.gte, etc.) during initial sync. Calendar connectors should\n * ignore this for API queries (to avoid missing recurring events) \u2014 the\n * API layer filters non-recurring items automatically.\n *\n * Undefined when no limit applies.\n */\n syncHistoryMin?: Date;\n\n /**\n * True when this is a recovery dispatch after the connection's auth was\n * restored (the user re-authorized a previously-broken connection).\n *\n * The framework calls `onChannelEnabled` again for every channel that was\n * already enabled at the time of re-auth so the connector can recover from\n * the auth gap. Connectors should:\n *\n * 1. Drop any persisted incremental sync cursors / sync tokens so the\n * next sync re-walks history (the cursor may be stale or invalid \u2014\n * Google Calendar invalidates syncTokens after ~7 days).\n * 2. Re-register webhooks (any prior subscription may have been\n * invalidated during the auth outage).\n * 3. Treat this as a backfill that walks history but does NOT spam\n * notifications \u2014 set `unread: false` and `archived: false` on\n * items as you would during initial sync.\n *\n * Most connectors can take the same code path as a fresh\n * `onChannelEnabled` for `recovering: true` as long as that path\n * overwrites stored state rather than appending to it.\n */\n recovering?: boolean;\n};\n\n/**\n * Built-in tool for managing OAuth authentication and channel resources.\n *\n * The Integrations tool:\n * 1. Manages channel resources (calendars, projects, etc.) per actor\n * 2. Returns tokens for the user who enabled sync on a channel\n * 3. Supports per-actor auth via actAs() for write-back operations\n * 4. Provides saveLink/saveContacts for Connectors to save data directly\n *\n * Connectors declare their provider, scopes, and channel lifecycle methods as\n * class properties and methods. The Integrations tool reads these automatically.\n * Auth and channel management is handled in the twist edit modal in Flutter.\n *\n * @example\n * ```typescript\n * class CalendarConnector extends Connector<CalendarConnector> {\n * readonly provider = AuthProvider.Google;\n * readonly scopes = [\"https://www.googleapis.com/auth/calendar\"];\n *\n * build(build: ToolBuilder) {\n * return {\n * integrations: build(Integrations),\n * };\n * }\n *\n * async getChannels(auth: Authorization, token: AuthToken): Promise<Channel[]> {\n * const calendars = await this.listCalendars(token);\n * return calendars.map(c => ({ id: c.id, title: c.name }));\n * }\n *\n * async onChannelEnabled(channel: Channel) {\n * // Start syncing\n * }\n *\n * async onChannelDisabled(channel: Channel) {\n * // Stop syncing\n * }\n * }\n * ```\n */\nexport abstract class Integrations extends ITool {\n /**\n * Merge scopes from multiple tools, deduplicating.\n *\n * @param scopeArrays - Arrays of scopes to merge\n * @returns Deduplicated array of scopes\n */\n static MergeScopes(...scopeArrays: string[][]): string[] {\n return Array.from(new Set(scopeArrays.flat()));\n }\n\n /**\n * Retrieves an access token for a channel resource.\n *\n * Returns the token of the user who enabled sync on the given channel.\n * If the channel is not enabled or the token is expired/invalid, returns null.\n *\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n */\n abstract get(channelId: string): Promise<AuthToken | null>;\n /**\n * Retrieves an access token for a channel resource.\n *\n * @param provider - The OAuth provider (deprecated, ignored for single-provider connectors)\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n * @deprecated Use get(channelId) instead. The provider is implicit from the connector.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract get(provider: AuthProvider, channelId: string): Promise<AuthToken | null>;\n\n /**\n * Execute a callback as a specific actor, requesting auth if needed.\n *\n * If the actor has a valid token, calls the callback immediately with it.\n * If the actor has no token, creates a private auth note in the specified\n * activity prompting them to connect. Once they authorize, this callback fires.\n *\n * @param provider - The OAuth provider\n * @param actorId - The actor to act as\n * @param activityId - The activity to create an auth note in (if needed)\n * @param callback - Function to call with the token\n * @param extraArgs - Additional arguments to pass to the callback\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract actAs<\n TArgs extends Serializable[],\n TCallback extends (token: AuthToken, ...args: TArgs) => any\n >(\n provider: AuthProvider,\n actorId: ActorId,\n activityId: Uuid,\n callback: TCallback,\n ...extraArgs: TArgs\n ): Promise<void>;\n\n /**\n * Saves a link with notes to the connector's priority.\n *\n * Creates a thread+link pair. The thread is a lightweight container;\n * the link holds the external entity data (source, meta, type, status, etc.).\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param link - The link with notes to save\n * @returns Promise resolving to the saved thread's UUID, or null if the\n * link was filtered out (e.g. older than the plan's sync history limit)\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLink(link: NewLinkWithNotes): Promise<Uuid | null>;\n\n /**\n * Batch version of {@link saveLink} \u2014 saves many links in one call.\n *\n * Connectors syncing many items per page (e.g. calendar events, issues,\n * messages) should prefer this over looping `saveLink`. Each `saveLink`\n * crosses the runtime boundary and counts against the per-execution\n * request budget; `saveLinks` collapses N saves into a single crossing.\n *\n * Failures on individual links DO NOT abort the batch. A bad item lands\n * as `null` in its slot and the rest still save. This prevents one\n * malformed record from losing an entire page of sync progress.\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param links - Array of links with notes to save\n * @returns Array of the same length and order as `links`. Each entry is\n * the saved thread's UUID, or `null` if the link was filtered out\n * (e.g. older than the plan's sync history limit) OR failed to save.\n * The two null causes are not distinguished; the save failure is\n * logged server-side.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;\n\n /**\n * Saves contacts to the connector's priority.\n *\n * @param contacts - Array of contacts to save\n * @returns Promise resolving to the saved actors\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveContacts(contacts: NewContact[]): Promise<Actor[]>;\n\n /**\n * Archives links matching the given filter that were created by this connector.\n *\n * For each archived link's thread, if no other non-archived links remain,\n * the thread is also archived.\n *\n * @param filter - Filter criteria for which links to archive\n * @returns Promise that resolves when archiving is complete\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract archiveLinks(filter: ArchiveLinkFilter): Promise<void>;\n\n /**\n * Sets or clears todo status on a thread owned by this connector.\n *\n * @param source - The link source URL identifying the thread\n * @param actorId - The user to set the todo for\n * @param todo - true to mark as todo, false to clear/complete\n * @param options - Additional options\n * @param options.date - The todo date (when todo=true). Defaults to today.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract setThreadToDo(\n source: string,\n actorId: ActorId,\n todo: boolean,\n options?: { date?: Date | string }\n ): Promise<void>;\n\n /**\n * Signal that initial bulk-sync (or recovery sync) for a channel is fully\n * complete. The Flutter app uses this to clear the \"syncing\u2026\" indicator\n * on the connection.\n *\n * The framework automatically marks a channel as syncing when it dispatches\n * `onChannelEnabled` (whether initial-enable, auto-enable from\n * `setChannels`, or recovery after re-auth). Connectors do NOT need to\n * call anything to start tracking \u2014 only to signal completion.\n *\n * Call this exactly once when the initial backfill has finished (no more\n * pages, all phases exhausted). Do NOT call it on every incremental sync.\n *\n * If `onChannelEnabled` throws an unhandled exception, the framework\n * automatically clears the syncing state \u2014 connectors don't need a\n * `try/catch` to clear state on failure.\n *\n * No-op when no auth/user mapping exists for the channel (e.g. key-based\n * connectors that don't have a per-user OAuth association).\n *\n * @param channelId - The channel resource ID whose initial sync just finished\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract channelSyncCompleted(channelId: string): Promise<void>;\n\n /**\n * Flag a connection as needing re-authentication so the Flutter app\n * surfaces a re-auth prompt on the next sync.\n *\n * Call this when a connector's API call returns a permanent auth-style\n * error that the runtime can't observe through token refresh \u2014 e.g.\n * Slack `invalid_auth` / `token_revoked` / `not_authed`, or a 401 on a\n * provider that doesn't refresh. The runtime already flags reauth\n * automatically when an OAuth refresh permanently fails or when the\n * stored token is missing on a get(); only call this for cases the\n * runtime can't see.\n *\n * Idempotent: safe to call repeatedly; existing reauth flags are not\n * overwritten. No-op when the channel has no `enabledBy` actor (e.g.\n * key-based connectors).\n *\n * @param channelId - The channel resource ID whose token is bad\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract markNeedsReauth(channelId: string): Promise<void>;\n\n}\n\n/**\n * Filter criteria for archiving links.\n * All fields are optional; only provided fields are used for matching.\n */\nexport type ArchiveLinkFilter = {\n /** Filter by channel ID */\n channelId?: string;\n /** Filter by link type (e.g., \"issue\", \"pull_request\") */\n type?: string;\n /** Filter by link status (e.g., \"open\", \"closed\") */\n status?: string;\n /** Filter by metadata fields (uses containment matching) */\n meta?: Record<string, JSONValue>;\n};\n\n/**\n * Enumeration of supported OAuth providers.\n *\n * Each provider has different OAuth endpoints, scopes, and token formats.\n * The Integrations tool handles the provider-specific implementation details.\n */\nexport enum AuthProvider {\n /** Google OAuth provider for Google Workspace services */\n Google = \"google\",\n /** Microsoft OAuth provider for Microsoft 365 services */\n Microsoft = \"microsoft\",\n /** Notion OAuth provider for Notion workspaces */\n Notion = \"notion\",\n /** Slack OAuth provider for Slack workspaces */\n Slack = \"slack\",\n /** Atlassian OAuth provider for Jira and Confluence */\n Atlassian = \"atlassian\",\n /** Linear OAuth provider for Linear workspaces */\n Linear = \"linear\",\n /** Monday.com OAuth provider */\n Monday = \"monday\",\n /** GitHub OAuth provider for GitHub repositories and organizations */\n GitHub = \"github\",\n /** Asana OAuth provider for Asana workspaces */\n Asana = \"asana\",\n /** HubSpot OAuth provider for HubSpot CRM */\n HubSpot = \"hubspot\",\n /** Todoist OAuth provider for Todoist task management */\n Todoist = \"todoist\",\n /** Airtable OAuth provider for Airtable bases */\n Airtable = \"airtable\",\n}\n\n/**\n * Represents a completed authorization from an OAuth flow.\n *\n * Contains the provider, granted scopes, and the actor (contact) that was authorized.\n * Tokens are looked up by (provider, actorId) rather than a random ID.\n */\nexport type Authorization = {\n /** The OAuth provider this authorization is for */\n provider: AuthProvider;\n /** Array of OAuth scopes this authorization covers */\n scopes: string[];\n /** The external account that was authorized (e.g., the Google account) */\n actor: Actor;\n};\n\n/**\n * Represents a stored OAuth authentication token.\n *\n * Contains the actual access token and the scopes it was granted,\n * which may be a subset of the originally requested scopes.\n */\nexport type AuthToken = {\n /** The OAuth access token */\n token: string;\n /** Array of granted OAuth scopes */\n scopes: string[];\n /**\n * Provider-specific metadata as key-value pairs.\n *\n * For Slack (AuthProvider.Slack):\n * - authed_user_id: The authenticated user's Slack ID\n * - bot_user_id: The bot user's Slack ID\n * - team_name: The Slack workspace/team name\n */\n provider?: Record<string, string>;\n};\n";
|
|
7
|
+
declare const _default: "import {\n type Actor,\n type ActorId,\n type NewContact,\n type NewLinkWithNotes,\n ITool,\n} from \"..\";\nimport type { JSONValue } from \"../utils/types\";\nimport type { Uuid } from \"../utils/uuid\";\n\n/**\n * A resource that can be synced (e.g., a calendar, project, channel).\n * Returned by getChannels() and managed by users in the twist setup/edit modal.\n */\nexport type Channel = {\n /** External ID shared across users (e.g., Google calendar ID) */\n id: string;\n /** Display name shown in the UI */\n title: string;\n /** Optional nested channel resources (e.g., subfolders) */\n children?: Channel[];\n /** Per-channel link type configs. Overrides twist-level linkTypes when present. */\n linkTypes?: LinkTypeConfig[];\n};\n\n/**\n * Describes a link type that a connector creates.\n * Used for display in the UI (icons, labels).\n */\nexport type LinkTypeConfig = {\n /** Machine-readable type identifier (e.g., \"issue\", \"pull_request\") */\n type: string;\n /** Human-readable label (e.g., \"Issue\", \"Pull Request\") */\n label: string;\n /**\n * Connector's word for a note on a linked item of this type \u2014 used by the\n * Flutter app to adapt note/composer copy (\"Add a comment\" on Linear,\n * \"Add a message\" on Slack, \"Add a reply\" on Gmail). Defaults to \"note\"\n * when omitted. Use the singular noun in title case (e.g. \"Comment\").\n */\n noteLabel?: string;\n /** URL to an icon for this link type (light mode). Prefer Iconify `logos/*` URLs. */\n logo?: string;\n /** URL to an icon for dark mode. Use when the default logo is invisible on dark backgrounds (e.g., Iconify `simple-icons/*` with `?color=`). */\n logoDark?: string;\n /** URL to a monochrome icon (uses `currentColor`). Prefer Iconify `simple-icons/*` URLs without a `?color=` param. */\n logoMono?: string;\n /** Possible status values for this type */\n statuses?: Array<{\n /** Machine-readable status (e.g., \"open\", \"done\") */\n status: string;\n /** Human-readable label (e.g., \"Open\", \"Done\") */\n label: string;\n /** Whether this status represents completion (done, closed, merged, cancelled, etc.) */\n done?: boolean;\n /**\n * Mark the thread `active=true` in Plot when a link enters this status.\n * Use for messaging-style flags where the user has indicated they want\n * to act on the thread now \u2014 Gmail's \"starred\", Slack's \"later\", etc.\n * The Plot user can later un-flag the thread without breaking the\n * connector relationship.\n */\n active?: boolean;\n /**\n * Mark the thread `task=true` in Plot when a link enters this status.\n * Use for project-tracker assignments \u2014 Linear / Todoist / Jira / etc.\n * `task` puts the thread on the user's task list without flooding their\n * inbox under `active`: the user explicitly flips it to `active` when\n * they decide to start.\n */\n task?: boolean;\n /**\n * Mark the thread `to_read=true` in Plot when a link enters this status.\n * Use for connectors that explicitly track read-later state (Pocket\n * archives, Slack \"remind me\", etc).\n */\n toRead?: boolean;\n /**\n * @deprecated Use `active` (messaging) or `task` (project tracker) instead.\n * Treated as `task: true` for backward compatibility.\n *\n * Original meaning: whether this status represents the connector's\n * \"to-do\" / active state. When a user adds a thread to Plot's agenda,\n * done-status links flip to the status marked `todo: true` (e.g.,\n * Gmail's \"starred\", Linear's \"todo\").\n */\n todo?: boolean;\n }>;\n /** Whether this link type supports displaying and changing the assignee */\n supportsAssignee?: boolean;\n /** Default thread creation mode for this link type: 'all' | 'actionable' | 'manual' */\n defaultCreateThreads?: string;\n /**\n * Opt-in: declares this link type is composable from Plot via\n * `Connector.onCreateLink`. Omit to make the link type sync-only (no\n * \"Create new \u2026\" picker entry).\n *\n * Connectors that need multiple compose modes for what users perceive as\n * the same kind of thing (e.g. Slack channel post vs DM) should declare\n * **separate linkTypes**, one per user-facing thread type. That keeps\n * each linkType isomorphic to one filter chip.\n */\n compose?: ComposeConfig;\n /**\n * Per-connector contact roles. Examples:\n * email \u2192 [{id:\"to\",label:\"To\",default:true},{id:\"cc\",label:\"CC\"},{id:\"bcc\",label:\"BCC\",hidden:true}]\n * calendar \u2192 [{id:\"required\",label:\"Required\",default:true},{id:\"optional\",label:\"Optional\"}]\n *\n * Plot uses this list to render a role picker on each contact chip in the\n * composer and to label non-default roles on existing threads. Exactly one\n * role should be marked `default: true`. Connectors that don't distinguish\n * roles (Slack, Linear) omit this field entirely.\n */\n contactRoles?: ContactRoleConfig[];\n /**\n * Whether contacts on an existing thread can be added, removed, or have\n * their role changed (email-style mid-thread recipient changes). When\n * false, the thread's contact list is fixed after creation. Defaults to\n * false when omitted.\n */\n supportsContactChanges?: boolean;\n /**\n * Declares how sharing on threads of this link type is scoped:\n *\n * - `\"thread\"` (default): one roster shared across all notes in the\n * thread. Native Plot threads, Slack DMs, calendar events.\n * - `\"channel\"`: visibility is the external channel's membership;\n * the per-thread `contacts` array is ignored for sharing UI.\n * Slack channels, Linear projects.\n * - `\"message\"`: each note carries its own recipient set via\n * `note.access_contacts`; the thread roster is the union across\n * all messages. Email.\n *\n * Omit to default to `\"thread\"`. When set to `\"message\"`, every\n * note this connector ingests must populate `access_contacts`\n * explicitly (never NULL).\n */\n sharingModel?: \"thread\" | \"channel\" | \"message\";\n};\n\n/**\n * Declares how a link type is composable from Plot via\n * `Connector.onCreateLink`. Attached to {@link LinkTypeConfig.compose}.\n */\nexport type ComposeConfig = {\n /**\n * Selects the destination model for the \"Create new \u2026\" picker.\n *\n * - `\"channels\"` (default): one chip per enabled channel (e.g. a Linear\n * team, a Slack channel). Existing behaviour for task-tracker / calendar\n * connectors.\n * - `\"contacts\"`: one chip per connection (account); the user picks\n * recipients from their contacts. The runtime pre-resolves the chosen\n * Plot contacts to platform account IDs via the per-connection\n * `contact_external_account` rows and delivers them as\n * `CreateLinkDraft.recipients`. Contacts without a row for this specific\n * connection are filtered out of the picker \u2014 used by closed-roster\n * messaging platforms (Slack DM, Teams DM, Google Chat DM, LinkedIn DM).\n * - `\"addresses\"`: one chip per connection; the picker accepts any\n * contact with an addressable identifier (e.g. an email) or a free-form\n * typed address. The runtime fills `recipients` for contacts with a\n * connection-scoped row and falls back to the contact's primary address\n * (e.g. `contact.email`) when no row exists. Free-form addresses arrive\n * via the thread's `inviteEmails`. Used by open address spaces like\n * Gmail.\n */\n targets?: \"channels\" | \"contacts\" | \"addresses\";\n /**\n * Status to assign newly-created links. Should match an entry in the\n * parent linkType's `statuses[]`, OR a symbolic id that the connector's\n * `onCreateLink` resolves itself (e.g. Linear's `\"unstarted\"` category is\n * resolved per-team to a state UUID inside the connector \u2014 see\n * `connectors/linear/src/linear.ts`).\n */\n status: string;\n /**\n * Optional override for the picker chip / \"Create new \u2026\" copy. Defaults\n * to the parent linkType's `label`. Use to disambiguate compose entries\n * when the parent label alone isn't specific enough (e.g. \"Direct\n * messages\" for a DM-mode compose on a chat connector).\n */\n label?: string;\n};\n\n/**\n * Declares one contact role for a connector's link type. See\n * `LinkTypeConfig.contactRoles`.\n */\nexport type ContactRoleConfig = {\n /** Stable machine id, e.g. \"to\" / \"cc\" / \"bcc\" / \"required\" / \"optional\". */\n id: string;\n /** Display label shown next to a contact chip, e.g. \"To\", \"CC\", \"Required\". */\n label: string;\n /** Exactly one role per linkType should be marked default. */\n default?: boolean;\n /**\n * Hidden roles are visible only to (a) the contact themselves and\n * (b) the user who added them. The API filters them out of every other\n * viewer's `thread.contacts` and `thread.contactMeta`. Use for BCC-style\n * semantics where other recipients must not see the hidden contact.\n */\n hidden?: boolean;\n};\n\n/**\n * Context passed to onChannelEnabled with plan-based sync hints.\n * Connectors can use these hints to limit initial sync scope.\n */\nexport type SyncContext = {\n /**\n * Earliest date to include in initial sync, based on the user's plan.\n *\n * Non-calendar connectors should use this as their date filter (timeMin,\n * created.gte, etc.) during initial sync. Calendar connectors should\n * ignore this for API queries (to avoid missing recurring events) \u2014 the\n * API layer filters non-recurring items automatically.\n *\n * Undefined when no limit applies.\n */\n syncHistoryMin?: Date;\n\n /**\n * True when this is a recovery dispatch after the connection's auth was\n * restored (the user re-authorized a previously-broken connection).\n *\n * The framework calls `onChannelEnabled` again for every channel that was\n * already enabled at the time of re-auth so the connector can recover from\n * the auth gap. Connectors should:\n *\n * 1. Drop any persisted incremental sync cursors / sync tokens so the\n * next sync re-walks history (the cursor may be stale or invalid \u2014\n * Google Calendar invalidates syncTokens after ~7 days).\n * 2. Re-register webhooks (any prior subscription may have been\n * invalidated during the auth outage).\n * 3. Treat this as a backfill that walks history but does NOT spam\n * notifications \u2014 set `unread: false` and `archived: false` on\n * items as you would during initial sync.\n *\n * Most connectors can take the same code path as a fresh\n * `onChannelEnabled` for `recovering: true` as long as that path\n * overwrites stored state rather than appending to it.\n */\n recovering?: boolean;\n};\n\n/**\n * Built-in tool for managing OAuth authentication and channel resources.\n *\n * The Integrations tool:\n * 1. Manages channel resources (calendars, projects, etc.) per actor\n * 2. Returns tokens for the user who enabled sync on a channel\n * 3. Supports per-actor auth via actAs() for write-back operations\n * 4. Provides saveLink/saveContacts for Connectors to save data directly\n *\n * Connectors declare their provider, scopes, and channel lifecycle methods as\n * class properties and methods. The Integrations tool reads these automatically.\n * Auth and channel management is handled in the twist edit modal in Flutter.\n *\n * @example\n * ```typescript\n * class CalendarConnector extends Connector<CalendarConnector> {\n * readonly provider = AuthProvider.Google;\n * readonly scopes = [\"https://www.googleapis.com/auth/calendar\"];\n *\n * build(build: ToolBuilder) {\n * return {\n * integrations: build(Integrations),\n * };\n * }\n *\n * async getChannels(auth: Authorization, token: AuthToken): Promise<Channel[]> {\n * const calendars = await this.listCalendars(token);\n * return calendars.map(c => ({ id: c.id, title: c.name }));\n * }\n *\n * async onChannelEnabled(channel: Channel) {\n * // Start syncing\n * }\n *\n * async onChannelDisabled(channel: Channel) {\n * // Stop syncing\n * }\n * }\n * ```\n */\nexport abstract class Integrations extends ITool {\n /**\n * Merge scopes from multiple tools, deduplicating.\n *\n * @param scopeArrays - Arrays of scopes to merge\n * @returns Deduplicated array of scopes\n */\n static MergeScopes(...scopeArrays: string[][]): string[] {\n return Array.from(new Set(scopeArrays.flat()));\n }\n\n /**\n * Retrieves an access token for a channel resource.\n *\n * Returns the token of the user who enabled sync on the given channel.\n * If the channel is not enabled or the token is expired/invalid, returns null.\n *\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n */\n abstract get(channelId: string): Promise<AuthToken | null>;\n /**\n * Retrieves an access token for a channel resource.\n *\n * @param provider - The OAuth provider (deprecated, ignored for single-provider connectors)\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n * @deprecated Use get(channelId) instead. The provider is implicit from the connector.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract get(provider: AuthProvider, channelId: string): Promise<AuthToken | null>;\n\n /**\n * Saves a link with notes to the connector's priority.\n *\n * Creates a thread+link pair. The thread is a lightweight container;\n * the link holds the external entity data (source, meta, type, status, etc.).\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param link - The link with notes to save\n * @returns Promise resolving to the saved thread's UUID, or null if the\n * link was filtered out (e.g. older than the plan's sync history limit)\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLink(link: NewLinkWithNotes): Promise<Uuid | null>;\n\n /**\n * Batch version of {@link saveLink} \u2014 saves many links in one call.\n *\n * Connectors syncing many items per page (e.g. calendar events, issues,\n * messages) should prefer this over looping `saveLink`. Each `saveLink`\n * crosses the runtime boundary and counts against the per-execution\n * request budget; `saveLinks` collapses N saves into a single crossing.\n *\n * Failures on individual links DO NOT abort the batch. A bad item lands\n * as `null` in its slot and the rest still save. This prevents one\n * malformed record from losing an entire page of sync progress.\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param links - Array of links with notes to save\n * @returns Array of the same length and order as `links`. Each entry is\n * the saved thread's UUID, or `null` if the link was filtered out\n * (e.g. older than the plan's sync history limit) OR failed to save.\n * The two null causes are not distinguished; the save failure is\n * logged server-side.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;\n\n /**\n * Upserts contacts into the connector's priority without requiring a Link.\n *\n * Use this for messaging connectors to bulk-sync workspace members so the\n * recipient picker can filter contacts by reachable platform account. Populate\n * `NewContact.source` to persist `contact_external_account` rows (the platform\n * identity used to address the contact). Returns one `Actor` per input, in order.\n *\n * @param contacts - Contacts to upsert, keyed by `source`/`key`\n * @returns Promise resolving to the saved actors, 1:1 with input order\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveContacts(contacts: NewContact[]): Promise<Actor[]>;\n\n /**\n * Archives links matching the given filter that were created by this connector.\n *\n * For each archived link's thread, if no other non-archived links remain,\n * the thread is also archived.\n *\n * @param filter - Filter criteria for which links to archive\n * @returns Promise that resolves when archiving is complete\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract archiveLinks(filter: ArchiveLinkFilter): Promise<void>;\n\n /**\n * Sets or clears todo status on a thread owned by this connector.\n *\n * @param source - The link source URL identifying the thread\n * @param actorId - The user to set the todo for\n * @param todo - true to mark as todo, false to clear/complete\n * @param options - Additional options\n * @param options.date - The todo date (when todo=true). Defaults to today.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract setThreadToDo(\n source: string,\n actorId: ActorId,\n todo: boolean,\n options?: { date?: Date | string }\n ): Promise<void>;\n\n /**\n * Signal that initial bulk-sync (or recovery sync) for a channel is fully\n * complete. The Flutter app uses this to clear the \"syncing\u2026\" indicator\n * on the connection.\n *\n * The framework automatically marks a channel as syncing when it dispatches\n * `onChannelEnabled` (whether initial-enable, auto-enable from\n * `setChannels`, or recovery after re-auth). Connectors do NOT need to\n * call anything to start tracking \u2014 only to signal completion.\n *\n * Call this exactly once when the initial backfill has finished (no more\n * pages, all phases exhausted). Do NOT call it on every incremental sync.\n *\n * If `onChannelEnabled` throws an unhandled exception, the framework\n * automatically clears the syncing state \u2014 connectors don't need a\n * `try/catch` to clear state on failure.\n *\n * No-op when no auth/user mapping exists for the channel (e.g. key-based\n * connectors that don't have a per-user OAuth association).\n *\n * @param channelId - The channel resource ID whose initial sync just finished\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract channelSyncCompleted(channelId: string): Promise<void>;\n\n /**\n * Flag a connection as needing re-authentication so the Flutter app\n * surfaces a re-auth prompt on the next sync.\n *\n * Call this when a connector's API call returns a permanent auth-style\n * error that the runtime can't observe through token refresh \u2014 e.g.\n * Slack `invalid_auth` / `token_revoked` / `not_authed`, or a 401 on a\n * provider that doesn't refresh. The runtime already flags reauth\n * automatically when an OAuth refresh permanently fails or when the\n * stored token is missing on a get(); only call this for cases the\n * runtime can't see.\n *\n * Idempotent: safe to call repeatedly; existing reauth flags are not\n * overwritten. No-op when the channel has no `enabledBy` actor (e.g.\n * key-based connectors).\n *\n * @param channelId - The channel resource ID whose token is bad\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract markNeedsReauth(channelId: string): Promise<void>;\n\n}\n\n/**\n * Filter criteria for archiving links.\n * All fields are optional; only provided fields are used for matching.\n */\nexport type ArchiveLinkFilter = {\n /** Filter by channel ID */\n channelId?: string;\n /** Filter by link type (e.g., \"issue\", \"pull_request\") */\n type?: string;\n /** Filter by link status (e.g., \"open\", \"closed\") */\n status?: string;\n /** Filter by metadata fields (uses containment matching) */\n meta?: Record<string, JSONValue>;\n};\n\n/**\n * Enumeration of supported OAuth providers.\n *\n * Each provider has different OAuth endpoints, scopes, and token formats.\n * The Integrations tool handles the provider-specific implementation details.\n */\nexport enum AuthProvider {\n /** Google OAuth provider for Google Workspace services */\n Google = \"google\",\n /** Microsoft OAuth provider for Microsoft 365 services */\n Microsoft = \"microsoft\",\n /** Notion OAuth provider for Notion workspaces */\n Notion = \"notion\",\n /** Slack OAuth provider for Slack workspaces */\n Slack = \"slack\",\n /** Atlassian OAuth provider for Jira and Confluence */\n Atlassian = \"atlassian\",\n /** Linear OAuth provider for Linear workspaces */\n Linear = \"linear\",\n /** Monday.com OAuth provider */\n Monday = \"monday\",\n /** GitHub OAuth provider for GitHub repositories and organizations */\n GitHub = \"github\",\n /** Asana OAuth provider for Asana workspaces */\n Asana = \"asana\",\n /** HubSpot OAuth provider for HubSpot CRM */\n HubSpot = \"hubspot\",\n /** Todoist OAuth provider for Todoist task management */\n Todoist = \"todoist\",\n /** Airtable OAuth provider for Airtable bases */\n Airtable = \"airtable\",\n}\n\n/**\n * Represents a completed authorization from an OAuth flow.\n *\n * Contains the provider, granted scopes, and the actor (contact) that was authorized.\n * Tokens are looked up by (provider, actorId) rather than a random ID.\n */\nexport type Authorization = {\n /** The OAuth provider this authorization is for */\n provider: AuthProvider;\n /** Array of OAuth scopes this authorization covers */\n scopes: string[];\n /** The external account that was authorized (e.g., the Google account) */\n actor: Actor;\n};\n\n/**\n * Represents a stored OAuth authentication token.\n *\n * Contains the actual access token and the scopes it was granted,\n * which may be a subset of the originally requested scopes.\n */\nexport type AuthToken = {\n /** The OAuth access token */\n token: string;\n /** Array of granted OAuth scopes */\n scopes: string[];\n /**\n * Provider-specific metadata as key-value pairs.\n *\n * For Slack (AuthProvider.Slack):\n * - authed_user_id: The authenticated user's Slack ID\n * - bot_user_id: The bot user's Slack ID\n * - team_name: The Slack workspace/team name\n */\n provider?: Record<string, string>;\n};\n";
|
|
8
8
|
export default _default;
|
|
9
9
|
//# sourceMappingURL=integrations.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integrations.d.ts","sourceRoot":"","sources":["../../../src/llm-docs/tools/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;wBAEY,
|
|
1
|
+
{"version":3,"file":"integrations.d.ts","sourceRoot":"","sources":["../../../src/llm-docs/tools/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;wBAEY,i4rBAAuyrB;AAAtzrB,wBAAuzrB"}
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* This file is auto-generated during build. Do not edit manually.
|
|
5
5
|
* Generated from: prebuild.ts
|
|
6
6
|
*/
|
|
7
|
-
export default "import {\n type Actor,\n type ActorId,\n type NewContact,\n type NewLinkWithNotes,\n ITool,\n Serializable,\n} from \"..\";\nimport { Tag } from \"../tag\";\nimport type { JSONValue } from \"../utils/types\";\nimport type { Uuid } from \"../utils/uuid\";\n\n/**\n * A resource that can be synced (e.g., a calendar, project, channel).\n * Returned by getChannels() and managed by users in the twist setup/edit modal.\n */\nexport type Channel = {\n /** External ID shared across users (e.g., Google calendar ID) */\n id: string;\n /** Display name shown in the UI */\n title: string;\n /** Optional nested channel resources (e.g., subfolders) */\n children?: Channel[];\n /** Per-channel link type configs. Overrides twist-level linkTypes when present. */\n linkTypes?: LinkTypeConfig[];\n};\n\n/**\n * Describes a link type that a connector creates.\n * Used for display in the UI (icons, labels).\n */\nexport type LinkTypeConfig = {\n /** Machine-readable type identifier (e.g., \"issue\", \"pull_request\") */\n type: string;\n /** Human-readable label (e.g., \"Issue\", \"Pull Request\") */\n label: string;\n /** URL to an icon for this link type (light mode). Prefer Iconify `logos/*` URLs. */\n logo?: string;\n /** URL to an icon for dark mode. Use when the default logo is invisible on dark backgrounds (e.g., Iconify `simple-icons/*` with `?color=`). */\n logoDark?: string;\n /** URL to a monochrome icon (uses `currentColor`). Prefer Iconify `simple-icons/*` URLs without a `?color=` param. */\n logoMono?: string;\n /** Possible status values for this type */\n statuses?: Array<{\n /** Machine-readable status (e.g., \"open\", \"done\") */\n status: string;\n /** Human-readable label (e.g., \"Open\", \"Done\") */\n label: string;\n /** Tag to propagate to thread when this status is active (e.g., Tag.Done) */\n tag?: Tag;\n /** Whether this status represents completion (done, closed, merged, cancelled, etc.) */\n done?: boolean;\n /**\n * Whether this status represents the connector's \"to-do\" / active state.\n * When a user adds a thread to Plot's agenda, done-status links flip to\n * the status marked `todo: true` (e.g., Gmail's \"starred\", Linear's\n * \"todo\") so the link widget and thread tags reflect the active state.\n * At most one status per type should set this.\n */\n todo?: boolean;\n /**\n * Default status applied when Plot asks the connector to create a new\n * item of this type via `Connector.onCreateLink`. Declaring at least one\n * status with `createDefault: true` is how a link type opts in to\n * Plot-initiated creation. At most one status per type should set this.\n */\n createDefault?: boolean;\n }>;\n /** Whether this link type supports displaying and changing the assignee */\n supportsAssignee?: boolean;\n /** Default thread creation mode for this link type: 'all' | 'actionable' | 'manual' */\n defaultCreateThreads?: string;\n};\n\n/**\n * Context passed to onChannelEnabled with plan-based sync hints.\n * Connectors can use these hints to limit initial sync scope.\n */\nexport type SyncContext = {\n /**\n * Earliest date to include in initial sync, based on the user's plan.\n *\n * Non-calendar connectors should use this as their date filter (timeMin,\n * created.gte, etc.) during initial sync. Calendar connectors should\n * ignore this for API queries (to avoid missing recurring events) — the\n * API layer filters non-recurring items automatically.\n *\n * Undefined when no limit applies.\n */\n syncHistoryMin?: Date;\n\n /**\n * True when this is a recovery dispatch after the connection's auth was\n * restored (the user re-authorized a previously-broken connection).\n *\n * The framework calls `onChannelEnabled` again for every channel that was\n * already enabled at the time of re-auth so the connector can recover from\n * the auth gap. Connectors should:\n *\n * 1. Drop any persisted incremental sync cursors / sync tokens so the\n * next sync re-walks history (the cursor may be stale or invalid —\n * Google Calendar invalidates syncTokens after ~7 days).\n * 2. Re-register webhooks (any prior subscription may have been\n * invalidated during the auth outage).\n * 3. Treat this as a backfill that walks history but does NOT spam\n * notifications — set `unread: false` and `archived: false` on\n * items as you would during initial sync.\n *\n * Most connectors can take the same code path as a fresh\n * `onChannelEnabled` for `recovering: true` as long as that path\n * overwrites stored state rather than appending to it.\n */\n recovering?: boolean;\n};\n\n/**\n * Built-in tool for managing OAuth authentication and channel resources.\n *\n * The Integrations tool:\n * 1. Manages channel resources (calendars, projects, etc.) per actor\n * 2. Returns tokens for the user who enabled sync on a channel\n * 3. Supports per-actor auth via actAs() for write-back operations\n * 4. Provides saveLink/saveContacts for Connectors to save data directly\n *\n * Connectors declare their provider, scopes, and channel lifecycle methods as\n * class properties and methods. The Integrations tool reads these automatically.\n * Auth and channel management is handled in the twist edit modal in Flutter.\n *\n * @example\n * ```typescript\n * class CalendarConnector extends Connector<CalendarConnector> {\n * readonly provider = AuthProvider.Google;\n * readonly scopes = [\"https://www.googleapis.com/auth/calendar\"];\n *\n * build(build: ToolBuilder) {\n * return {\n * integrations: build(Integrations),\n * };\n * }\n *\n * async getChannels(auth: Authorization, token: AuthToken): Promise<Channel[]> {\n * const calendars = await this.listCalendars(token);\n * return calendars.map(c => ({ id: c.id, title: c.name }));\n * }\n *\n * async onChannelEnabled(channel: Channel) {\n * // Start syncing\n * }\n *\n * async onChannelDisabled(channel: Channel) {\n * // Stop syncing\n * }\n * }\n * ```\n */\nexport abstract class Integrations extends ITool {\n /**\n * Merge scopes from multiple tools, deduplicating.\n *\n * @param scopeArrays - Arrays of scopes to merge\n * @returns Deduplicated array of scopes\n */\n static MergeScopes(...scopeArrays: string[][]): string[] {\n return Array.from(new Set(scopeArrays.flat()));\n }\n\n /**\n * Retrieves an access token for a channel resource.\n *\n * Returns the token of the user who enabled sync on the given channel.\n * If the channel is not enabled or the token is expired/invalid, returns null.\n *\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n */\n abstract get(channelId: string): Promise<AuthToken | null>;\n /**\n * Retrieves an access token for a channel resource.\n *\n * @param provider - The OAuth provider (deprecated, ignored for single-provider connectors)\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n * @deprecated Use get(channelId) instead. The provider is implicit from the connector.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract get(provider: AuthProvider, channelId: string): Promise<AuthToken | null>;\n\n /**\n * Execute a callback as a specific actor, requesting auth if needed.\n *\n * If the actor has a valid token, calls the callback immediately with it.\n * If the actor has no token, creates a private auth note in the specified\n * activity prompting them to connect. Once they authorize, this callback fires.\n *\n * @param provider - The OAuth provider\n * @param actorId - The actor to act as\n * @param activityId - The activity to create an auth note in (if needed)\n * @param callback - Function to call with the token\n * @param extraArgs - Additional arguments to pass to the callback\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract actAs<\n TArgs extends Serializable[],\n TCallback extends (token: AuthToken, ...args: TArgs) => any\n >(\n provider: AuthProvider,\n actorId: ActorId,\n activityId: Uuid,\n callback: TCallback,\n ...extraArgs: TArgs\n ): Promise<void>;\n\n /**\n * Saves a link with notes to the connector's priority.\n *\n * Creates a thread+link pair. The thread is a lightweight container;\n * the link holds the external entity data (source, meta, type, status, etc.).\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param link - The link with notes to save\n * @returns Promise resolving to the saved thread's UUID, or null if the\n * link was filtered out (e.g. older than the plan's sync history limit)\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLink(link: NewLinkWithNotes): Promise<Uuid | null>;\n\n /**\n * Batch version of {@link saveLink} — saves many links in one call.\n *\n * Connectors syncing many items per page (e.g. calendar events, issues,\n * messages) should prefer this over looping `saveLink`. Each `saveLink`\n * crosses the runtime boundary and counts against the per-execution\n * request budget; `saveLinks` collapses N saves into a single crossing.\n *\n * Failures on individual links DO NOT abort the batch. A bad item lands\n * as `null` in its slot and the rest still save. This prevents one\n * malformed record from losing an entire page of sync progress.\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param links - Array of links with notes to save\n * @returns Array of the same length and order as `links`. Each entry is\n * the saved thread's UUID, or `null` if the link was filtered out\n * (e.g. older than the plan's sync history limit) OR failed to save.\n * The two null causes are not distinguished; the save failure is\n * logged server-side.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;\n\n /**\n * Saves contacts to the connector's priority.\n *\n * @param contacts - Array of contacts to save\n * @returns Promise resolving to the saved actors\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveContacts(contacts: NewContact[]): Promise<Actor[]>;\n\n /**\n * Archives links matching the given filter that were created by this connector.\n *\n * For each archived link's thread, if no other non-archived links remain,\n * the thread is also archived.\n *\n * @param filter - Filter criteria for which links to archive\n * @returns Promise that resolves when archiving is complete\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract archiveLinks(filter: ArchiveLinkFilter): Promise<void>;\n\n /**\n * Sets or clears todo status on a thread owned by this connector.\n *\n * @param source - The link source URL identifying the thread\n * @param actorId - The user to set the todo for\n * @param todo - true to mark as todo, false to clear/complete\n * @param options - Additional options\n * @param options.date - The todo date (when todo=true). Defaults to today.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract setThreadToDo(\n source: string,\n actorId: ActorId,\n todo: boolean,\n options?: { date?: Date | string }\n ): Promise<void>;\n\n /**\n * Signal that initial bulk-sync (or recovery sync) for a channel is fully\n * complete. The Flutter app uses this to clear the \"syncing…\" indicator\n * on the connection.\n *\n * The framework automatically marks a channel as syncing when it dispatches\n * `onChannelEnabled` (whether initial-enable, auto-enable from\n * `setChannels`, or recovery after re-auth). Connectors do NOT need to\n * call anything to start tracking — only to signal completion.\n *\n * Call this exactly once when the initial backfill has finished (no more\n * pages, all phases exhausted). Do NOT call it on every incremental sync.\n *\n * If `onChannelEnabled` throws an unhandled exception, the framework\n * automatically clears the syncing state — connectors don't need a\n * `try/catch` to clear state on failure.\n *\n * No-op when no auth/user mapping exists for the channel (e.g. key-based\n * connectors that don't have a per-user OAuth association).\n *\n * @param channelId - The channel resource ID whose initial sync just finished\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract channelSyncCompleted(channelId: string): Promise<void>;\n\n /**\n * Flag a connection as needing re-authentication so the Flutter app\n * surfaces a re-auth prompt on the next sync.\n *\n * Call this when a connector's API call returns a permanent auth-style\n * error that the runtime can't observe through token refresh — e.g.\n * Slack `invalid_auth` / `token_revoked` / `not_authed`, or a 401 on a\n * provider that doesn't refresh. The runtime already flags reauth\n * automatically when an OAuth refresh permanently fails or when the\n * stored token is missing on a get(); only call this for cases the\n * runtime can't see.\n *\n * Idempotent: safe to call repeatedly; existing reauth flags are not\n * overwritten. No-op when the channel has no `enabledBy` actor (e.g.\n * key-based connectors).\n *\n * @param channelId - The channel resource ID whose token is bad\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract markNeedsReauth(channelId: string): Promise<void>;\n\n}\n\n/**\n * Filter criteria for archiving links.\n * All fields are optional; only provided fields are used for matching.\n */\nexport type ArchiveLinkFilter = {\n /** Filter by channel ID */\n channelId?: string;\n /** Filter by link type (e.g., \"issue\", \"pull_request\") */\n type?: string;\n /** Filter by link status (e.g., \"open\", \"closed\") */\n status?: string;\n /** Filter by metadata fields (uses containment matching) */\n meta?: Record<string, JSONValue>;\n};\n\n/**\n * Enumeration of supported OAuth providers.\n *\n * Each provider has different OAuth endpoints, scopes, and token formats.\n * The Integrations tool handles the provider-specific implementation details.\n */\nexport enum AuthProvider {\n /** Google OAuth provider for Google Workspace services */\n Google = \"google\",\n /** Microsoft OAuth provider for Microsoft 365 services */\n Microsoft = \"microsoft\",\n /** Notion OAuth provider for Notion workspaces */\n Notion = \"notion\",\n /** Slack OAuth provider for Slack workspaces */\n Slack = \"slack\",\n /** Atlassian OAuth provider for Jira and Confluence */\n Atlassian = \"atlassian\",\n /** Linear OAuth provider for Linear workspaces */\n Linear = \"linear\",\n /** Monday.com OAuth provider */\n Monday = \"monday\",\n /** GitHub OAuth provider for GitHub repositories and organizations */\n GitHub = \"github\",\n /** Asana OAuth provider for Asana workspaces */\n Asana = \"asana\",\n /** HubSpot OAuth provider for HubSpot CRM */\n HubSpot = \"hubspot\",\n /** Todoist OAuth provider for Todoist task management */\n Todoist = \"todoist\",\n /** Airtable OAuth provider for Airtable bases */\n Airtable = \"airtable\",\n}\n\n/**\n * Represents a completed authorization from an OAuth flow.\n *\n * Contains the provider, granted scopes, and the actor (contact) that was authorized.\n * Tokens are looked up by (provider, actorId) rather than a random ID.\n */\nexport type Authorization = {\n /** The OAuth provider this authorization is for */\n provider: AuthProvider;\n /** Array of OAuth scopes this authorization covers */\n scopes: string[];\n /** The external account that was authorized (e.g., the Google account) */\n actor: Actor;\n};\n\n/**\n * Represents a stored OAuth authentication token.\n *\n * Contains the actual access token and the scopes it was granted,\n * which may be a subset of the originally requested scopes.\n */\nexport type AuthToken = {\n /** The OAuth access token */\n token: string;\n /** Array of granted OAuth scopes */\n scopes: string[];\n /**\n * Provider-specific metadata as key-value pairs.\n *\n * For Slack (AuthProvider.Slack):\n * - authed_user_id: The authenticated user's Slack ID\n * - bot_user_id: The bot user's Slack ID\n * - team_name: The Slack workspace/team name\n */\n provider?: Record<string, string>;\n};\n";
|
|
7
|
+
export default "import {\n type Actor,\n type ActorId,\n type NewContact,\n type NewLinkWithNotes,\n ITool,\n} from \"..\";\nimport type { JSONValue } from \"../utils/types\";\nimport type { Uuid } from \"../utils/uuid\";\n\n/**\n * A resource that can be synced (e.g., a calendar, project, channel).\n * Returned by getChannels() and managed by users in the twist setup/edit modal.\n */\nexport type Channel = {\n /** External ID shared across users (e.g., Google calendar ID) */\n id: string;\n /** Display name shown in the UI */\n title: string;\n /** Optional nested channel resources (e.g., subfolders) */\n children?: Channel[];\n /** Per-channel link type configs. Overrides twist-level linkTypes when present. */\n linkTypes?: LinkTypeConfig[];\n};\n\n/**\n * Describes a link type that a connector creates.\n * Used for display in the UI (icons, labels).\n */\nexport type LinkTypeConfig = {\n /** Machine-readable type identifier (e.g., \"issue\", \"pull_request\") */\n type: string;\n /** Human-readable label (e.g., \"Issue\", \"Pull Request\") */\n label: string;\n /**\n * Connector's word for a note on a linked item of this type — used by the\n * Flutter app to adapt note/composer copy (\"Add a comment\" on Linear,\n * \"Add a message\" on Slack, \"Add a reply\" on Gmail). Defaults to \"note\"\n * when omitted. Use the singular noun in title case (e.g. \"Comment\").\n */\n noteLabel?: string;\n /** URL to an icon for this link type (light mode). Prefer Iconify `logos/*` URLs. */\n logo?: string;\n /** URL to an icon for dark mode. Use when the default logo is invisible on dark backgrounds (e.g., Iconify `simple-icons/*` with `?color=`). */\n logoDark?: string;\n /** URL to a monochrome icon (uses `currentColor`). Prefer Iconify `simple-icons/*` URLs without a `?color=` param. */\n logoMono?: string;\n /** Possible status values for this type */\n statuses?: Array<{\n /** Machine-readable status (e.g., \"open\", \"done\") */\n status: string;\n /** Human-readable label (e.g., \"Open\", \"Done\") */\n label: string;\n /** Whether this status represents completion (done, closed, merged, cancelled, etc.) */\n done?: boolean;\n /**\n * Mark the thread `active=true` in Plot when a link enters this status.\n * Use for messaging-style flags where the user has indicated they want\n * to act on the thread now — Gmail's \"starred\", Slack's \"later\", etc.\n * The Plot user can later un-flag the thread without breaking the\n * connector relationship.\n */\n active?: boolean;\n /**\n * Mark the thread `task=true` in Plot when a link enters this status.\n * Use for project-tracker assignments — Linear / Todoist / Jira / etc.\n * `task` puts the thread on the user's task list without flooding their\n * inbox under `active`: the user explicitly flips it to `active` when\n * they decide to start.\n */\n task?: boolean;\n /**\n * Mark the thread `to_read=true` in Plot when a link enters this status.\n * Use for connectors that explicitly track read-later state (Pocket\n * archives, Slack \"remind me\", etc).\n */\n toRead?: boolean;\n /**\n * @deprecated Use `active` (messaging) or `task` (project tracker) instead.\n * Treated as `task: true` for backward compatibility.\n *\n * Original meaning: whether this status represents the connector's\n * \"to-do\" / active state. When a user adds a thread to Plot's agenda,\n * done-status links flip to the status marked `todo: true` (e.g.,\n * Gmail's \"starred\", Linear's \"todo\").\n */\n todo?: boolean;\n }>;\n /** Whether this link type supports displaying and changing the assignee */\n supportsAssignee?: boolean;\n /** Default thread creation mode for this link type: 'all' | 'actionable' | 'manual' */\n defaultCreateThreads?: string;\n /**\n * Opt-in: declares this link type is composable from Plot via\n * `Connector.onCreateLink`. Omit to make the link type sync-only (no\n * \"Create new …\" picker entry).\n *\n * Connectors that need multiple compose modes for what users perceive as\n * the same kind of thing (e.g. Slack channel post vs DM) should declare\n * **separate linkTypes**, one per user-facing thread type. That keeps\n * each linkType isomorphic to one filter chip.\n */\n compose?: ComposeConfig;\n /**\n * Per-connector contact roles. Examples:\n * email → [{id:\"to\",label:\"To\",default:true},{id:\"cc\",label:\"CC\"},{id:\"bcc\",label:\"BCC\",hidden:true}]\n * calendar → [{id:\"required\",label:\"Required\",default:true},{id:\"optional\",label:\"Optional\"}]\n *\n * Plot uses this list to render a role picker on each contact chip in the\n * composer and to label non-default roles on existing threads. Exactly one\n * role should be marked `default: true`. Connectors that don't distinguish\n * roles (Slack, Linear) omit this field entirely.\n */\n contactRoles?: ContactRoleConfig[];\n /**\n * Whether contacts on an existing thread can be added, removed, or have\n * their role changed (email-style mid-thread recipient changes). When\n * false, the thread's contact list is fixed after creation. Defaults to\n * false when omitted.\n */\n supportsContactChanges?: boolean;\n /**\n * Declares how sharing on threads of this link type is scoped:\n *\n * - `\"thread\"` (default): one roster shared across all notes in the\n * thread. Native Plot threads, Slack DMs, calendar events.\n * - `\"channel\"`: visibility is the external channel's membership;\n * the per-thread `contacts` array is ignored for sharing UI.\n * Slack channels, Linear projects.\n * - `\"message\"`: each note carries its own recipient set via\n * `note.access_contacts`; the thread roster is the union across\n * all messages. Email.\n *\n * Omit to default to `\"thread\"`. When set to `\"message\"`, every\n * note this connector ingests must populate `access_contacts`\n * explicitly (never NULL).\n */\n sharingModel?: \"thread\" | \"channel\" | \"message\";\n};\n\n/**\n * Declares how a link type is composable from Plot via\n * `Connector.onCreateLink`. Attached to {@link LinkTypeConfig.compose}.\n */\nexport type ComposeConfig = {\n /**\n * Selects the destination model for the \"Create new …\" picker.\n *\n * - `\"channels\"` (default): one chip per enabled channel (e.g. a Linear\n * team, a Slack channel). Existing behaviour for task-tracker / calendar\n * connectors.\n * - `\"contacts\"`: one chip per connection (account); the user picks\n * recipients from their contacts. The runtime pre-resolves the chosen\n * Plot contacts to platform account IDs via the per-connection\n * `contact_external_account` rows and delivers them as\n * `CreateLinkDraft.recipients`. Contacts without a row for this specific\n * connection are filtered out of the picker — used by closed-roster\n * messaging platforms (Slack DM, Teams DM, Google Chat DM, LinkedIn DM).\n * - `\"addresses\"`: one chip per connection; the picker accepts any\n * contact with an addressable identifier (e.g. an email) or a free-form\n * typed address. The runtime fills `recipients` for contacts with a\n * connection-scoped row and falls back to the contact's primary address\n * (e.g. `contact.email`) when no row exists. Free-form addresses arrive\n * via the thread's `inviteEmails`. Used by open address spaces like\n * Gmail.\n */\n targets?: \"channels\" | \"contacts\" | \"addresses\";\n /**\n * Status to assign newly-created links. Should match an entry in the\n * parent linkType's `statuses[]`, OR a symbolic id that the connector's\n * `onCreateLink` resolves itself (e.g. Linear's `\"unstarted\"` category is\n * resolved per-team to a state UUID inside the connector — see\n * `connectors/linear/src/linear.ts`).\n */\n status: string;\n /**\n * Optional override for the picker chip / \"Create new …\" copy. Defaults\n * to the parent linkType's `label`. Use to disambiguate compose entries\n * when the parent label alone isn't specific enough (e.g. \"Direct\n * messages\" for a DM-mode compose on a chat connector).\n */\n label?: string;\n};\n\n/**\n * Declares one contact role for a connector's link type. See\n * `LinkTypeConfig.contactRoles`.\n */\nexport type ContactRoleConfig = {\n /** Stable machine id, e.g. \"to\" / \"cc\" / \"bcc\" / \"required\" / \"optional\". */\n id: string;\n /** Display label shown next to a contact chip, e.g. \"To\", \"CC\", \"Required\". */\n label: string;\n /** Exactly one role per linkType should be marked default. */\n default?: boolean;\n /**\n * Hidden roles are visible only to (a) the contact themselves and\n * (b) the user who added them. The API filters them out of every other\n * viewer's `thread.contacts` and `thread.contactMeta`. Use for BCC-style\n * semantics where other recipients must not see the hidden contact.\n */\n hidden?: boolean;\n};\n\n/**\n * Context passed to onChannelEnabled with plan-based sync hints.\n * Connectors can use these hints to limit initial sync scope.\n */\nexport type SyncContext = {\n /**\n * Earliest date to include in initial sync, based on the user's plan.\n *\n * Non-calendar connectors should use this as their date filter (timeMin,\n * created.gte, etc.) during initial sync. Calendar connectors should\n * ignore this for API queries (to avoid missing recurring events) — the\n * API layer filters non-recurring items automatically.\n *\n * Undefined when no limit applies.\n */\n syncHistoryMin?: Date;\n\n /**\n * True when this is a recovery dispatch after the connection's auth was\n * restored (the user re-authorized a previously-broken connection).\n *\n * The framework calls `onChannelEnabled` again for every channel that was\n * already enabled at the time of re-auth so the connector can recover from\n * the auth gap. Connectors should:\n *\n * 1. Drop any persisted incremental sync cursors / sync tokens so the\n * next sync re-walks history (the cursor may be stale or invalid —\n * Google Calendar invalidates syncTokens after ~7 days).\n * 2. Re-register webhooks (any prior subscription may have been\n * invalidated during the auth outage).\n * 3. Treat this as a backfill that walks history but does NOT spam\n * notifications — set `unread: false` and `archived: false` on\n * items as you would during initial sync.\n *\n * Most connectors can take the same code path as a fresh\n * `onChannelEnabled` for `recovering: true` as long as that path\n * overwrites stored state rather than appending to it.\n */\n recovering?: boolean;\n};\n\n/**\n * Built-in tool for managing OAuth authentication and channel resources.\n *\n * The Integrations tool:\n * 1. Manages channel resources (calendars, projects, etc.) per actor\n * 2. Returns tokens for the user who enabled sync on a channel\n * 3. Supports per-actor auth via actAs() for write-back operations\n * 4. Provides saveLink/saveContacts for Connectors to save data directly\n *\n * Connectors declare their provider, scopes, and channel lifecycle methods as\n * class properties and methods. The Integrations tool reads these automatically.\n * Auth and channel management is handled in the twist edit modal in Flutter.\n *\n * @example\n * ```typescript\n * class CalendarConnector extends Connector<CalendarConnector> {\n * readonly provider = AuthProvider.Google;\n * readonly scopes = [\"https://www.googleapis.com/auth/calendar\"];\n *\n * build(build: ToolBuilder) {\n * return {\n * integrations: build(Integrations),\n * };\n * }\n *\n * async getChannels(auth: Authorization, token: AuthToken): Promise<Channel[]> {\n * const calendars = await this.listCalendars(token);\n * return calendars.map(c => ({ id: c.id, title: c.name }));\n * }\n *\n * async onChannelEnabled(channel: Channel) {\n * // Start syncing\n * }\n *\n * async onChannelDisabled(channel: Channel) {\n * // Stop syncing\n * }\n * }\n * ```\n */\nexport abstract class Integrations extends ITool {\n /**\n * Merge scopes from multiple tools, deduplicating.\n *\n * @param scopeArrays - Arrays of scopes to merge\n * @returns Deduplicated array of scopes\n */\n static MergeScopes(...scopeArrays: string[][]): string[] {\n return Array.from(new Set(scopeArrays.flat()));\n }\n\n /**\n * Retrieves an access token for a channel resource.\n *\n * Returns the token of the user who enabled sync on the given channel.\n * If the channel is not enabled or the token is expired/invalid, returns null.\n *\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n */\n abstract get(channelId: string): Promise<AuthToken | null>;\n /**\n * Retrieves an access token for a channel resource.\n *\n * @param provider - The OAuth provider (deprecated, ignored for single-provider connectors)\n * @param channelId - The channel resource ID (e.g., calendar ID)\n * @returns Promise resolving to the access token or null\n * @deprecated Use get(channelId) instead. The provider is implicit from the connector.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract get(provider: AuthProvider, channelId: string): Promise<AuthToken | null>;\n\n /**\n * Saves a link with notes to the connector's priority.\n *\n * Creates a thread+link pair. The thread is a lightweight container;\n * the link holds the external entity data (source, meta, type, status, etc.).\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param link - The link with notes to save\n * @returns Promise resolving to the saved thread's UUID, or null if the\n * link was filtered out (e.g. older than the plan's sync history limit)\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLink(link: NewLinkWithNotes): Promise<Uuid | null>;\n\n /**\n * Batch version of {@link saveLink} — saves many links in one call.\n *\n * Connectors syncing many items per page (e.g. calendar events, issues,\n * messages) should prefer this over looping `saveLink`. Each `saveLink`\n * crosses the runtime boundary and counts against the per-execution\n * request budget; `saveLinks` collapses N saves into a single crossing.\n *\n * Failures on individual links DO NOT abort the batch. A bad item lands\n * as `null` in its slot and the rest still save. This prevents one\n * malformed record from losing an entire page of sync progress.\n *\n * This method is available only to Connectors (not regular Twists).\n *\n * @param links - Array of links with notes to save\n * @returns Array of the same length and order as `links`. Each entry is\n * the saved thread's UUID, or `null` if the link was filtered out\n * (e.g. older than the plan's sync history limit) OR failed to save.\n * The two null causes are not distinguished; the save failure is\n * logged server-side.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;\n\n /**\n * Upserts contacts into the connector's priority without requiring a Link.\n *\n * Use this for messaging connectors to bulk-sync workspace members so the\n * recipient picker can filter contacts by reachable platform account. Populate\n * `NewContact.source` to persist `contact_external_account` rows (the platform\n * identity used to address the contact). Returns one `Actor` per input, in order.\n *\n * @param contacts - Contacts to upsert, keyed by `source`/`key`\n * @returns Promise resolving to the saved actors, 1:1 with input order\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract saveContacts(contacts: NewContact[]): Promise<Actor[]>;\n\n /**\n * Archives links matching the given filter that were created by this connector.\n *\n * For each archived link's thread, if no other non-archived links remain,\n * the thread is also archived.\n *\n * @param filter - Filter criteria for which links to archive\n * @returns Promise that resolves when archiving is complete\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract archiveLinks(filter: ArchiveLinkFilter): Promise<void>;\n\n /**\n * Sets or clears todo status on a thread owned by this connector.\n *\n * @param source - The link source URL identifying the thread\n * @param actorId - The user to set the todo for\n * @param todo - true to mark as todo, false to clear/complete\n * @param options - Additional options\n * @param options.date - The todo date (when todo=true). Defaults to today.\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract setThreadToDo(\n source: string,\n actorId: ActorId,\n todo: boolean,\n options?: { date?: Date | string }\n ): Promise<void>;\n\n /**\n * Signal that initial bulk-sync (or recovery sync) for a channel is fully\n * complete. The Flutter app uses this to clear the \"syncing…\" indicator\n * on the connection.\n *\n * The framework automatically marks a channel as syncing when it dispatches\n * `onChannelEnabled` (whether initial-enable, auto-enable from\n * `setChannels`, or recovery after re-auth). Connectors do NOT need to\n * call anything to start tracking — only to signal completion.\n *\n * Call this exactly once when the initial backfill has finished (no more\n * pages, all phases exhausted). Do NOT call it on every incremental sync.\n *\n * If `onChannelEnabled` throws an unhandled exception, the framework\n * automatically clears the syncing state — connectors don't need a\n * `try/catch` to clear state on failure.\n *\n * No-op when no auth/user mapping exists for the channel (e.g. key-based\n * connectors that don't have a per-user OAuth association).\n *\n * @param channelId - The channel resource ID whose initial sync just finished\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract channelSyncCompleted(channelId: string): Promise<void>;\n\n /**\n * Flag a connection as needing re-authentication so the Flutter app\n * surfaces a re-auth prompt on the next sync.\n *\n * Call this when a connector's API call returns a permanent auth-style\n * error that the runtime can't observe through token refresh — e.g.\n * Slack `invalid_auth` / `token_revoked` / `not_authed`, or a 401 on a\n * provider that doesn't refresh. The runtime already flags reauth\n * automatically when an OAuth refresh permanently fails or when the\n * stored token is missing on a get(); only call this for cases the\n * runtime can't see.\n *\n * Idempotent: safe to call repeatedly; existing reauth flags are not\n * overwritten. No-op when the channel has no `enabledBy` actor (e.g.\n * key-based connectors).\n *\n * @param channelId - The channel resource ID whose token is bad\n */\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n abstract markNeedsReauth(channelId: string): Promise<void>;\n\n}\n\n/**\n * Filter criteria for archiving links.\n * All fields are optional; only provided fields are used for matching.\n */\nexport type ArchiveLinkFilter = {\n /** Filter by channel ID */\n channelId?: string;\n /** Filter by link type (e.g., \"issue\", \"pull_request\") */\n type?: string;\n /** Filter by link status (e.g., \"open\", \"closed\") */\n status?: string;\n /** Filter by metadata fields (uses containment matching) */\n meta?: Record<string, JSONValue>;\n};\n\n/**\n * Enumeration of supported OAuth providers.\n *\n * Each provider has different OAuth endpoints, scopes, and token formats.\n * The Integrations tool handles the provider-specific implementation details.\n */\nexport enum AuthProvider {\n /** Google OAuth provider for Google Workspace services */\n Google = \"google\",\n /** Microsoft OAuth provider for Microsoft 365 services */\n Microsoft = \"microsoft\",\n /** Notion OAuth provider for Notion workspaces */\n Notion = \"notion\",\n /** Slack OAuth provider for Slack workspaces */\n Slack = \"slack\",\n /** Atlassian OAuth provider for Jira and Confluence */\n Atlassian = \"atlassian\",\n /** Linear OAuth provider for Linear workspaces */\n Linear = \"linear\",\n /** Monday.com OAuth provider */\n Monday = \"monday\",\n /** GitHub OAuth provider for GitHub repositories and organizations */\n GitHub = \"github\",\n /** Asana OAuth provider for Asana workspaces */\n Asana = \"asana\",\n /** HubSpot OAuth provider for HubSpot CRM */\n HubSpot = \"hubspot\",\n /** Todoist OAuth provider for Todoist task management */\n Todoist = \"todoist\",\n /** Airtable OAuth provider for Airtable bases */\n Airtable = \"airtable\",\n}\n\n/**\n * Represents a completed authorization from an OAuth flow.\n *\n * Contains the provider, granted scopes, and the actor (contact) that was authorized.\n * Tokens are looked up by (provider, actorId) rather than a random ID.\n */\nexport type Authorization = {\n /** The OAuth provider this authorization is for */\n provider: AuthProvider;\n /** Array of OAuth scopes this authorization covers */\n scopes: string[];\n /** The external account that was authorized (e.g., the Google account) */\n actor: Actor;\n};\n\n/**\n * Represents a stored OAuth authentication token.\n *\n * Contains the actual access token and the scopes it was granted,\n * which may be a subset of the originally requested scopes.\n */\nexport type AuthToken = {\n /** The OAuth access token */\n token: string;\n /** Array of granted OAuth scopes */\n scopes: string[];\n /**\n * Provider-specific metadata as key-value pairs.\n *\n * For Slack (AuthProvider.Slack):\n * - authed_user_id: The authenticated user's Slack ID\n * - bot_user_id: The bot user's Slack ID\n * - team_name: The Slack workspace/team name\n */\n provider?: Record<string, string>;\n};\n";
|
|
8
8
|
//# sourceMappingURL=integrations.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integrations.js","sourceRoot":"","sources":["../../../src/llm-docs/tools/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAe,
|
|
1
|
+
{"version":3,"file":"integrations.js","sourceRoot":"","sources":["../../../src/llm-docs/tools/integrations.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAe,uyrBAAuyrB,CAAC"}
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
* This file is auto-generated during build. Do not edit manually.
|
|
5
5
|
* Generated from: cli/templates/AGENTS.template.md
|
|
6
6
|
*/
|
|
7
|
-
declare const _default: "# Twist Implementation Guide for LLMs\n\nThis document provides context for AI assistants generating or modifying twists.\n\n## Architecture Overview\n\nPlot Twists are TypeScript classes that extend the `Twist` base class. Twists interact with external services and Plot's core functionality through a tool-based architecture.\n\n### Runtime Environment\n\n**Critical**: All Twists and tool functions are executed in a sandboxed, ephemeral environment with limited resources:\n\n- **Memory is temporary**: Anything stored in memory (e.g. as a variable in the twist/tool object) is lost after the function completes. Use the Store tool instead. Only use memory for temporary caching.\n- **Limited requests per execution**: Each execution has ~1000 requests (HTTP requests, tool calls, database operations)\n- **Limited CPU time**: Each execution has limited CPU time (typically ~60 seconds) and memory (128MB)\n- **Use tasks to get fresh request limits**: `this.runTask()` creates a NEW execution with a fresh ~1000 request limit\n- **Calling callbacks continues same execution**: `this.run()` continues the same execution and shares the request count\n- **Break long loops**: Split large operations into batches that each stay under the ~1000 request limit\n- **Store intermediate state**: Use the Store tool to persist state between batches\n- **Examples**: Syncing large datasets, processing many API calls, or performing batch operations\n\n## Understanding Threads and Notes\n\n**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.\n\n**Think of a Thread as a thread** on a messaging platform, and **Notes as the messages in that thread**.\n\n### Key Guidelines\n\n1. **Always create Threads with an initial Note** - The title is just a summary; detailed content goes in Notes\n2. **Add Notes to existing Threads for updates** - Don't create a new Thread for each related message\n3. **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.\n4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot threads per external item (see SYNC_STRATEGIES.md)\n5. **Most Threads should be `ThreadType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end`\n\n### Recommended Decision Tree (Strategy 2: Upsert via Source/Key)\n\n```\nNew event/task/conversation from external system?\n \u251C\u2500 Has stable URL or ID?\n \u2502 \u2514\u2500 Yes \u2192 Set Thread.source to the canonical URL/ID\n \u2502 Create Thread (Plot handles deduplication automatically)\n \u2502 Use Note.key for different note types:\n \u2502 - \"description\" for main content\n \u2502 - \"metadata\" for status/priority/assignee\n \u2502 - \"comment-{id}\" for individual comments\n \u2502\n \u2514\u2500 No stable identifier OR need multiple Plot threads per external item?\n \u2514\u2500 Use Advanced Pattern (Strategy 3: Generate and Store IDs)\n See SYNC_STRATEGIES.md for details\n```\n\n### Advanced Decision Tree (Strategy 3: Generate and Store IDs)\n\nOnly use when source/key upserts aren't sufficient (e.g., creating multiple threads from one external item):\n\n```\nNew event/task/conversation?\n \u251C\u2500 Yes \u2192 Generate UUID with Uuid.Generate()\n \u2502 Create new Thread with that UUID\n \u2502 Store mapping: external_id \u2192 thread_uuid\n \u2502\n \u2514\u2500 No (update/reply/comment) \u2192 Look up mapping by external_id\n \u251C\u2500 Found \u2192 Add Note to existing Thread using stored UUID\n \u2514\u2500 Not found \u2192 Create new Thread with UUID + store mapping\n```\n\n## Twist Structure Pattern\n\n```typescript\nimport {\n type Thread,\n type NewThreadWithNotes,\n type ThreadFilter,\n type Priority,\n type ToolBuilder,\n Twist,\n ThreadType,\n} from \"@plotday/twister\";\nimport { ThreadAccess, Plot } from \"@plotday/twister/tools/plot\";\n// Import your sources or tools as needed\n\nexport default class MyTwist extends Twist<MyTwist> {\n build(build: ToolBuilder) {\n return {\n plot: build(Plot, {\n thread: { access: ThreadAccess.Create },\n }),\n };\n }\n\n async activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection handled in the twist edit modal.\n }\n}\n```\n\n## Tool System\n\n### Accessing Tools\n\nAll tools are declared in the `build` method:\n\n```typescript\nbuild(build: ToolBuilder) {\n return {\n toolName: build(ToolClass),\n };\n}\n```\n\nAll `build()` calls must occur in the `build` method as they are used for dependency analysis.\n\nIMPORTANT: HTTP access is restricted to URLs requested via `build(Network, { urls: [url1, url2, ...] })` in the `build` method. Wildcards are supported. Use `build(Network, { urls: ['*'] })` if full access is needed.\n\n### Built-in Tools (Always Available)\n\nFor complete API documentation of built-in tools including all methods, types, and detailed examples, see the TypeScript definitions in your installed package at `node_modules/@plotday/twister/src/tools/*.ts`. Each tool file contains comprehensive JSDoc documentation.\n\n**Quick reference - Available tools:**\n\n- `@plotday/twister/tools/plot` - Core data layer (create/update activities, priorities, contacts)\n- `@plotday/twister/tools/ai` - LLM integration (text generation, structured output, reasoning)\n - Use ModelPreferences to specify `speed` (fast/balanced/capable) and `cost` (low/medium/high)\n- `@plotday/twister/tools/store` - Persistent key-value storage (also via `this.set()`, `this.get()`)\n- `@plotday/twister/tools/tasks` - Queue batched work (also via `this.run()`)\n- `@plotday/twister/tools/callbacks` - Persistent function references (also via `this.callback()`)\n- `@plotday/twister/tools/integrations` - OAuth2 authentication flows\n- `@plotday/twister/tools/network` - HTTP access permissions and webhook management\n- `@plotday/twister/tools/twists` - Manage other Twists\n\n**Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods.\n\n## Lifecycle Methods\n\n### activate(priority: Pick<Priority, \"id\">)\n\nCalled when the twist is enabled for a priority. Auth and resource selection are handled automatically via the twist edit modal when using external tools with Integrations.\n\nMost twists have an empty or minimal `activate()`:\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection are handled in the twist edit modal.\n // Only add custom initialization here if needed.\n}\n```\n\n**Store Parent Thread for Later (optional):**\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n const threadId = await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Setup complete\",\n notes: [{\n content: \"Your twist is ready. Threads will appear as they sync.\",\n }],\n });\n await this.set(\"setup_thread_id\", threadId);\n}\n```\n\n### Event Callbacks (via build options)\n\nTwists respond to events through callbacks declared in `build()`:\n\n**React to thread changes (for two-way sync):**\n\n```typescript\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\nasync onThreadUpdated(thread: Thread, changes: { tagsAdded, tagsRemoved }): Promise<void> {\n const tool = this.getToolForThread(thread);\n if (tool?.updateIssue) await tool.updateIssue(thread);\n}\n\nasync onNoteCreated(note: Note, thread: Thread): Promise<NoteWriteBackResult | void> {\n if (note.author.type === ActorType.Twist) return; // Prevent loops\n // Sync note to external service as a comment. Return the external\n // system's id + stored content so the runtime can set note.key AND\n // record a sync baseline that preserves Plot's content on round-trip.\n // See connectors/AGENTS.md \u2192 \"Sync baseline preservation\".\n const comment = await externalApi.createComment(thread.meta.externalId, { body: note.content ?? \"\" });\n if (!comment?.id) return;\n return { key: `comment-${comment.id}`, externalContent: comment.body };\n}\n```\n\n**Respond to mentions (AI twist pattern):**\n\n```typescript\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n intents: [{\n description: \"Respond to general questions\",\n examples: [\"What's the weather?\", \"Help me plan my week\"],\n handler: this.respond,\n }],\n },\n}),\n```\n\n**Default mention on replies:**\n\nWhen your twist processes replies (two-way comment sync or conversational AI), set `defaultMention: true` so the user doesn't have to manually re-mention your twist on every note:\n\n- `thread.defaultMention` \u2014 Auto-mention on replies to threads your twist created (e.g., synced issues, emails)\n- `note.defaultMention` \u2014 Auto-mention on follow-up notes in threads where your twist was @-mentioned (e.g., conversational agents)\n\n```typescript\n// Connector with two-way comment sync\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n defaultMention: true, // Users replying to synced threads will mention this twist by default\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\n// Conversational AI twist\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n defaultMention: true, // Follow-up notes auto-mention this twist\n intents: [{ description: \"...\", examples: [...], handler: this.respond }],\n },\n}),\n```\n\nWithout `defaultMention`, the twist chip appears toggled OFF by default \u2014 the user can still enable it manually per-note.\n\n## Actions\n\nActions enable user interaction:\n\n```typescript\nimport { type Action, ActionType } from \"@plotday/twister\";\n\n// External URL action\nconst urlAction: Action = {\n title: \"Open website\",\n type: ActionType.external,\n url: \"https://example.com\",\n};\n\n// Callback action (uses Callbacks tool \u2014 use linkCallback, not callback)\nconst token = await this.linkCallback(this.onActionClicked, \"context\");\nconst callbackAction: Action = {\n title: \"Click me\",\n type: ActionType.callback,\n callback: token,\n};\n\n// Add to thread note\nawait this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Task with actions\",\n notes: [\n {\n content: \"Click the actions below to take action.\",\n actions: [urlAction, callbackAction],\n },\n ],\n});\n\n// Callback handler receives the Action as first argument\nasync onActionClicked(action: Action, context: string): Promise<void> {\n // Handle action click\n}\n```\n\n## Authentication Pattern\n\nAuth is handled automatically via the Integrations tool. Tools declare their OAuth provider in `build()`, and users connect in the twist edit modal. **You do not need to create auth activities manually.**\n\n```typescript\n// In your tool's build() method:\nbuild(build: ToolBuilder) {\n return {\n integrations: build(Integrations, {\n providers: [{\n provider: AuthProvider.Google,\n scopes: [\"https://www.googleapis.com/auth/calendar\"],\n getChannels: this.getChannels, // List available resources after auth\n onChannelEnabled: this.onChannelEnabled, // User enabled a resource\n onChannelDisabled: this.onChannelDisabled, // User disabled a resource\n }],\n }),\n // ...\n };\n}\n\n// Get a token for API calls:\nconst token = await this.tools.integrations.get(AuthProvider.Google, channelId);\nif (!token) throw new Error(\"No auth token available\");\nconst client = new ApiClient({ accessToken: token.token });\n```\n\nFor per-user write-backs (e.g., RSVP, comments attributed to the acting user):\n\n```typescript\nawait this.tools.integrations.actAs(\n AuthProvider.Google,\n actorId, // The user who performed the action\n threadId, // Thread to prompt for auth if needed\n this.performWriteBack,\n ...extraArgs\n);\n```\n\n## Sync Pattern\n\n### Upsert via Source/Key (Strategy 2)\n\nUse source/key for automatic upserts:\n\n```typescript\nasync handleEvent(event: ExternalEvent): Promise<void> {\n const thread: NewThreadWithNotes = {\n source: event.htmlLink, // Canonical URL for automatic deduplication\n type: ThreadType.Event,\n title: event.summary || \"(No title)\",\n notes: [],\n };\n\n if (event.description) {\n thread.notes.push({\n thread: { source: event.htmlLink },\n key: \"description\", // This key enables note-level upserts\n content: event.description,\n });\n }\n\n // Create or update \u2014 Plot handles deduplication automatically\n await this.tools.plot.createThread(thread);\n}\n```\n\n### Advanced: Generate and Store IDs (Strategy 3)\n\nOnly 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.\n\n```typescript\nasync handleEventAdvanced(\n incomingThread: NewThreadWithNotes,\n calendarId: string\n): Promise<void> {\n // Extract external event ID from meta (adapt based on your tool's data)\n const externalId = incomingThread.meta?.eventId;\n\n if (!externalId) {\n console.error(\"Event missing external ID\");\n return;\n }\n\n // Check if we've already synced this event\n const mappingKey = `event_mapping:${calendarId}:${externalId}`;\n const existingThreadId = await this.get<Uuid>(mappingKey);\n\n if (existingThreadId) {\n // Event already exists - add update as a Note (add message to thread)\n if (incomingThread.notes?.[0]?.content) {\n await this.tools.plot.createNote({\n thread: { id: existingThreadId },\n content: incomingThread.notes[0].content,\n });\n }\n return;\n }\n\n // New event - generate UUID and store mapping\n const threadId = Uuid.Generate();\n await this.set(mappingKey, threadId);\n\n // Create new Thread with initial Note (new thread with first message)\n await this.tools.plot.createThread({\n ...incomingThread,\n id: threadId,\n });\n}\n```\n\n## Resource Selection\n\nResource 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.\n\n```typescript\n// In your tool:\nasync getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> {\n const client = new ApiClient({ accessToken: token.token });\n const calendars = await client.listCalendars();\n return calendars.map(c => ({\n id: c.id,\n title: c.name,\n children: c.subCalendars?.map(sc => ({ id: sc.id, title: sc.name })),\n }));\n}\n```\n\n## Batch Processing Pattern\n\n**Important**: Because Twists run in an ephemeral environment with limited requests per execution (~1000 requests), you must break long operations into batches. Each batch runs independently in a new execution context with its own fresh request limit.\n\n### Key Principles\n\n1. **Stay under request limits**: Each execution has ~1000 requests. Size batches accordingly.\n2. **Use runTask() for fresh limits**: Each call to `this.runTask()` creates a NEW execution with fresh ~1000 requests\n3. **Store state between batches**: Use the Store tool to persist progress\n4. **Calculate safe batch sizes**: Determine requests per item to size batches (e.g., ~10 requests per item = ~100 items per batch)\n5. **Clean up when done**: Delete stored state after completion\n6. **Handle failures**: Store enough state to resume if a batch fails\n\n### Example Implementation\n\n```typescript\nasync startSync(resourceId: string): Promise<void> {\n // Initialize state in Store (persists between executions)\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: null,\n batchNumber: 1,\n itemsProcessed: 0,\n initialSync: true, // Track whether this is the first sync\n });\n\n // Queue first batch using runTask method\n const callback = await this.callback(this.syncBatch, resourceId);\n // runTask creates NEW execution with fresh ~1000 request limit\n await this.runTask(callback);\n}\n\nasync syncBatch(resourceId: string): Promise<void> {\n // Load state from Store (set by previous execution)\n const state = await this.get(`sync_state_${resourceId}`);\n\n // Process one batch (size to stay under ~1000 request limit)\n const result = await this.fetchBatch(state.nextPageToken);\n\n // Process results using source/key pattern (automatic upserts, no manual tracking)\n // If each item makes ~10 requests, keep batch size \u2264 100 items to stay under limit\n for (const item of result.items) {\n // Each createThread may make ~5-10 requests depending on notes/links\n await this.tools.plot.createThread({\n source: item.url, // Use item's canonical URL for automatic deduplication\n type: ThreadType.Note,\n title: item.title,\n notes: [{\n activity: { source: item.url },\n key: \"description\", // Use key for upsertable notes\n content: item.description,\n }],\n ...(state.initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(state.initialSync ? { archived: false } : {}), // unarchive on initial only\n });\n }\n\n if (result.nextPageToken) {\n // Update state in Store for next batch\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: result.nextPageToken,\n batchNumber: state.batchNumber + 1,\n itemsProcessed: state.itemsProcessed + result.items.length,\n initialSync: state.initialSync, // Preserve initialSync flag across batches\n });\n\n // Queue next batch - creates NEW execution with fresh request limit\n const nextCallback = await this.callback(this.syncBatch, resourceId);\n await this.runTask(nextCallback);\n } else {\n // Cleanup when complete\n await this.clear(`sync_state_${resourceId}`);\n\n // Optionally notify user of completion\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Sync complete\",\n notes: [\n {\n content: `Successfully processed ${state.itemsProcessed + result.items.length} items.`,\n },\n ],\n });\n }\n}\n```\n\n## Thread Sync Best Practices\n\nWhen syncing threads from external systems, follow these patterns for optimal user experience:\n\n### The `initialSync` Flag\n\nAll sync-based tools should distinguish between initial sync (first import) and incremental sync (ongoing updates):\n\n| Field | Initial Sync | Incremental Sync | Reason |\n|-------|--------------|------------------|---------|\n| `unread` | `false` | *omit* | Initial: mark read for all. Incremental: auto-mark read for author only |\n| `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates |\n\n**Example:**\n```typescript\nconst thread: NewThread = {\n type: ThreadType.Event,\n source: event.url,\n title: event.title,\n ...(initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(initialSync ? { archived: false } : {}), // unarchive on initial only\n};\n```\n\n**Why this matters:**\n- **Initial sync**: Activities are unarchived and marked as read for all users, preventing spam from bulk historical imports\n- **Incremental sync**: Activities are auto-marked read for the author (twist owner), unread for everyone else. Archived state is preserved\n- **Reinstall**: Acts as initial sync, so previously archived activities are unarchived (fresh start)\n\n### Two-Way Sync: Avoiding Race Conditions\n\nWhen 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.\n\n**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.\n\n```typescript\nasync pushNoteAsComment(note: Note, externalItemId: string): Promise<void> {\n // Create external item with Plot ID in metadata for webhook correlation\n const externalComment = await externalApi.createComment(externalItemId, {\n body: note.content,\n metadata: { plotNoteId: note.id },\n });\n\n // Update Note with external key AFTER creation\n // A webhook may arrive before this completes \u2014 that's OK (see onWebhook below)\n await this.tools.plot.updateNote({\n id: note.id,\n key: `comment-${externalComment.id}`,\n });\n}\n\nasync onWebhook(payload: WebhookPayload): Promise<void> {\n const comment = payload.comment;\n\n // Use Plot ID from metadata if present (handles race condition),\n // otherwise fall back to upserting by activity source and key\n await this.tools.plot.createNote({\n ...(comment.metadata?.plotNoteId\n ? { id: comment.metadata.plotNoteId }\n : { activity: { source: payload.itemUrl } }),\n key: `comment-${comment.id}`,\n content: comment.body,\n });\n}\n```\n\n## Error Handling\n\nAlways handle errors gracefully and communicate them to users:\n\n```typescript\ntry {\n await this.externalOperation();\n} catch (error) {\n console.error(\"Operation failed:\", error);\n\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Operation failed\",\n notes: [\n {\n content: `Failed to complete operation: ${error.message}`,\n },\n ],\n });\n}\n```\n\n## Common Pitfalls\n\n- **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.\n- **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.\n- **Always create Threads with Notes** - See \"Understanding Threads and Notes\" section above for the thread/message pattern and decision tree.\n- **Use correct Thread types** - Most should be `ThreadType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`.\n- **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).\n- **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.\n- Tools are declared in the `build` method and accessed via `this.tools.toolName` in twist methods.\n- **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).\n- **Always use Callbacks tool for persistent references** - Direct function references don't survive worker restarts.\n- **Store auth tokens** - Don't re-request authentication unnecessarily.\n- **Clean up callbacks and stored state** - Delete callbacks and Store entries when no longer needed.\n- **Handle missing auth gracefully** - Check for stored auth before operations.\n- **CRITICAL: Maintain callback backward compatibility** - All callbacks (webhooks, tasks, batch operations) automatically upgrade to new twist versions. You **must** maintain backward compatibility in callback method signatures. Only add optional parameters at the end, never remove or reorder parameters. For breaking changes, implement migration logic in the `upgrade()` lifecycle method to recreate affected callbacks.\n\n## Testing\n\nBefore deploying, verify:\n\n1. Linting passes: `{{packageManager}} lint`\n2. All dependencies are in package.json\n3. Authentication flow works end-to-end\n4. Batch operations handle pagination correctly\n5. Error cases are handled gracefully\n";
|
|
7
|
+
declare const _default: "# Twist Implementation Guide for LLMs\n\nThis document provides context for AI assistants generating or modifying twists.\n\n## Architecture Overview\n\nPlot Twists are TypeScript classes that extend the `Twist` base class. Twists interact with external services and Plot's core functionality through a tool-based architecture.\n\n### Runtime Environment\n\n**Critical**: All Twists and tool functions are executed in a sandboxed, ephemeral environment with limited resources:\n\n- **Memory is temporary**: Anything stored in memory (e.g. as a variable in the twist/tool object) is lost after the function completes. Use the Store tool instead. Only use memory for temporary caching.\n- **Limited requests per execution**: Each execution has ~1000 requests (HTTP requests, tool calls, database operations)\n- **Limited CPU time**: Each execution has limited CPU time (typically ~60 seconds) and memory (128MB)\n- **Use tasks to get fresh request limits**: `this.runTask()` creates a NEW execution with a fresh ~1000 request limit\n- **Calling callbacks continues same execution**: `this.run()` continues the same execution and shares the request count\n- **Break long loops**: Split large operations into batches that each stay under the ~1000 request limit\n- **Store intermediate state**: Use the Store tool to persist state between batches\n- **Examples**: Syncing large datasets, processing many API calls, or performing batch operations\n\n## Understanding Threads and Notes\n\n**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.\n\n**Think of a Thread as a thread** on a messaging platform, and **Notes as the messages in that thread**.\n\n### Key Guidelines\n\n1. **Always create Threads with an initial Note** - The title is just a summary; detailed content goes in Notes\n2. **Add Notes to existing Threads for updates** - Don't create a new Thread for each related message\n3. **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.\n4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot threads per external item (see SYNC_STRATEGIES.md)\n5. **Most Threads should be `ThreadType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end`\n\n### Recommended Decision Tree (Strategy 2: Upsert via Source/Key)\n\n```\nNew event/task/conversation from external system?\n \u251C\u2500 Has stable URL or ID?\n \u2502 \u2514\u2500 Yes \u2192 Set Thread.source to the canonical URL/ID\n \u2502 Create Thread (Plot handles deduplication automatically)\n \u2502 Use Note.key for different note types:\n \u2502 - \"description\" for main content\n \u2502 - \"metadata\" for status/priority/assignee\n \u2502 - \"comment-{id}\" for individual comments\n \u2502\n \u2514\u2500 No stable identifier OR need multiple Plot threads per external item?\n \u2514\u2500 Use Advanced Pattern (Strategy 3: Generate and Store IDs)\n See SYNC_STRATEGIES.md for details\n```\n\n### Advanced Decision Tree (Strategy 3: Generate and Store IDs)\n\nOnly use when source/key upserts aren't sufficient (e.g., creating multiple threads from one external item):\n\n```\nNew event/task/conversation?\n \u251C\u2500 Yes \u2192 Generate UUID with Uuid.Generate()\n \u2502 Create new Thread with that UUID\n \u2502 Store mapping: external_id \u2192 thread_uuid\n \u2502\n \u2514\u2500 No (update/reply/comment) \u2192 Look up mapping by external_id\n \u251C\u2500 Found \u2192 Add Note to existing Thread using stored UUID\n \u2514\u2500 Not found \u2192 Create new Thread with UUID + store mapping\n```\n\n## Twist Structure Pattern\n\n```typescript\nimport {\n type Thread,\n type NewThreadWithNotes,\n type ThreadFilter,\n type Priority,\n type ToolBuilder,\n Twist,\n ThreadType,\n} from \"@plotday/twister\";\nimport { ThreadAccess, Plot } from \"@plotday/twister/tools/plot\";\n// Import your sources or tools as needed\n\nexport default class MyTwist extends Twist<MyTwist> {\n build(build: ToolBuilder) {\n return {\n plot: build(Plot, {\n thread: { access: ThreadAccess.Create },\n }),\n };\n }\n\n async activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection handled in the twist edit modal.\n }\n}\n```\n\n## Tool System\n\n### Accessing Tools\n\nAll tools are declared in the `build` method:\n\n```typescript\nbuild(build: ToolBuilder) {\n return {\n toolName: build(ToolClass),\n };\n}\n```\n\nAll `build()` calls must occur in the `build` method as they are used for dependency analysis.\n\nIMPORTANT: HTTP access is restricted to URLs requested via `build(Network, { urls: [url1, url2, ...] })` in the `build` method. Wildcards are supported. Use `build(Network, { urls: ['*'] })` if full access is needed.\n\n### Built-in Tools (Always Available)\n\nFor complete API documentation of built-in tools including all methods, types, and detailed examples, see the TypeScript definitions in your installed package at `node_modules/@plotday/twister/src/tools/*.ts`. Each tool file contains comprehensive JSDoc documentation.\n\n**Quick reference - Available tools:**\n\n- `@plotday/twister/tools/plot` - Core data layer (create/update activities, priorities, contacts)\n- `@plotday/twister/tools/ai` - LLM integration (text generation, structured output, reasoning)\n - Use ModelPreferences to specify `speed` (fast/balanced/capable) and `cost` (low/medium/high)\n- `@plotday/twister/tools/store` - Persistent key-value storage (also via `this.set()`, `this.get()`)\n- `@plotday/twister/tools/tasks` - Queue batched work (also via `this.run()`)\n- `@plotday/twister/tools/callbacks` - Persistent function references (also via `this.callback()`)\n- `@plotday/twister/tools/integrations` - OAuth2 authentication flows\n- `@plotday/twister/tools/network` - HTTP access permissions and webhook management\n- `@plotday/twister/tools/twists` - Manage other Twists\n\n**Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods.\n\n## Lifecycle Methods\n\n### activate(priority: Pick<Priority, \"id\">)\n\nCalled when the twist is enabled for a priority. Auth and resource selection are handled automatically via the twist edit modal when using external tools with Integrations.\n\nMost twists have an empty or minimal `activate()`:\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection are handled in the twist edit modal.\n // Only add custom initialization here if needed.\n}\n```\n\n**Store Parent Thread for Later (optional):**\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n const threadId = await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Setup complete\",\n notes: [{\n content: \"Your twist is ready. Threads will appear as they sync.\",\n }],\n });\n await this.set(\"setup_thread_id\", threadId);\n}\n```\n\n### Event Callbacks (via build options)\n\nTwists respond to events through callbacks declared in `build()`:\n\n**React to thread changes (for two-way sync):**\n\n```typescript\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\nasync onThreadUpdated(thread: Thread, changes: { tagsAdded, tagsRemoved }): Promise<void> {\n const tool = this.getToolForThread(thread);\n if (tool?.updateIssue) await tool.updateIssue(thread);\n}\n\nasync onNoteCreated(note: Note, thread: Thread): Promise<NoteWriteBackResult | void> {\n if (note.author.type === ActorType.Twist) return; // Prevent loops\n // Sync note to external service as a comment. Return the external\n // system's id + stored content so the runtime can set note.key AND\n // record a sync baseline that preserves Plot's content on round-trip.\n // See connectors/AGENTS.md \u2192 \"Sync baseline preservation\".\n const comment = await externalApi.createComment(thread.meta.externalId, { body: note.content ?? \"\" });\n if (!comment?.id) return;\n return { key: `comment-${comment.id}`, externalContent: comment.body };\n}\n```\n\n**Respond to mentions (AI twist pattern):**\n\n```typescript\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n intents: [{\n description: \"Respond to general questions\",\n examples: [\"What's the weather?\", \"Help me plan my week\"],\n handler: this.respond,\n }],\n },\n}),\n```\n\n**Default mention on replies:**\n\nWhen your twist processes replies (two-way comment sync or conversational AI), set `defaultMention: true` so the user doesn't have to manually re-mention your twist on every note:\n\n- `thread.defaultMention` \u2014 Auto-mention on replies to threads your twist created (e.g., synced issues, emails)\n- `note.defaultMention` \u2014 Auto-mention on follow-up notes in threads where your twist was @-mentioned (e.g., conversational agents)\n\n```typescript\n// Connector with two-way comment sync\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n defaultMention: true, // Users replying to synced threads will mention this twist by default\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\n// Conversational AI twist\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n defaultMention: true, // Follow-up notes auto-mention this twist\n intents: [{ description: \"...\", examples: [...], handler: this.respond }],\n },\n}),\n```\n\nWithout `defaultMention`, the twist chip appears toggled OFF by default \u2014 the user can still enable it manually per-note.\n\n## Actions\n\nActions enable user interaction:\n\n```typescript\nimport { type Action, ActionType } from \"@plotday/twister\";\n\n// External URL action\nconst urlAction: Action = {\n title: \"Open website\",\n type: ActionType.external,\n url: \"https://example.com\",\n};\n\n// Callback action (uses Callbacks tool \u2014 use linkCallback, not callback)\nconst token = await this.linkCallback(this.onActionClicked, \"context\");\nconst callbackAction: Action = {\n title: \"Click me\",\n type: ActionType.callback,\n callback: token,\n};\n\n// Add to thread note\nawait this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Task with actions\",\n notes: [\n {\n content: \"Click the actions below to take action.\",\n actions: [urlAction, callbackAction],\n },\n ],\n});\n\n// Callback handler receives the Action as first argument\nasync onActionClicked(action: Action, context: string): Promise<void> {\n // Handle action click\n}\n```\n\n## Authentication Pattern\n\nAuth is handled automatically via the Integrations tool. Tools declare their OAuth provider in `build()`, and users connect in the twist edit modal. **You do not need to create auth activities manually.**\n\n```typescript\n// In your tool's build() method:\nbuild(build: ToolBuilder) {\n return {\n integrations: build(Integrations, {\n providers: [{\n provider: AuthProvider.Google,\n scopes: [\"https://www.googleapis.com/auth/calendar\"],\n getChannels: this.getChannels, // List available resources after auth\n onChannelEnabled: this.onChannelEnabled, // User enabled a resource\n onChannelDisabled: this.onChannelDisabled, // User disabled a resource\n }],\n }),\n // ...\n };\n}\n\n// Get a token for API calls:\nconst token = await this.tools.integrations.get(AuthProvider.Google, channelId);\nif (!token) throw new Error(\"No auth token available\");\nconst client = new ApiClient({ accessToken: token.token });\n```\n\nFor per-user write-backs (e.g., RSVP, comments attributed to the acting user):\nthe dispatch runtime routes the change to the acting user's own connector\ninstance, so your callback (`onScheduleContactUpdated`, etc.) already runs\nunder that user's auth. Just call `this.tools.integrations.get(channelId)`\nto fetch the token and the write-back is attributed correctly. If the\nacting user has no connection of this type, the change lives in Plot but\nis not dispatched.\n\n## Sync Pattern\n\n### Upsert via Source/Key (Strategy 2)\n\nUse source/key for automatic upserts:\n\n```typescript\nasync handleEvent(event: ExternalEvent): Promise<void> {\n const thread: NewThreadWithNotes = {\n source: event.htmlLink, // Canonical URL for automatic deduplication\n type: ThreadType.Event,\n title: event.summary || \"(No title)\",\n notes: [],\n };\n\n if (event.description) {\n thread.notes.push({\n thread: { source: event.htmlLink },\n key: \"description\", // This key enables note-level upserts\n content: event.description,\n });\n }\n\n // Create or update \u2014 Plot handles deduplication automatically\n await this.tools.plot.createThread(thread);\n}\n```\n\n### Advanced: Generate and Store IDs (Strategy 3)\n\nOnly 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.\n\n```typescript\nasync handleEventAdvanced(\n incomingThread: NewThreadWithNotes,\n calendarId: string\n): Promise<void> {\n // Extract external event ID from meta (adapt based on your tool's data)\n const externalId = incomingThread.meta?.eventId;\n\n if (!externalId) {\n console.error(\"Event missing external ID\");\n return;\n }\n\n // Check if we've already synced this event\n const mappingKey = `event_mapping:${calendarId}:${externalId}`;\n const existingThreadId = await this.get<Uuid>(mappingKey);\n\n if (existingThreadId) {\n // Event already exists - add update as a Note (add message to thread)\n if (incomingThread.notes?.[0]?.content) {\n await this.tools.plot.createNote({\n thread: { id: existingThreadId },\n content: incomingThread.notes[0].content,\n });\n }\n return;\n }\n\n // New event - generate UUID and store mapping\n const threadId = Uuid.Generate();\n await this.set(mappingKey, threadId);\n\n // Create new Thread with initial Note (new thread with first message)\n await this.tools.plot.createThread({\n ...incomingThread,\n id: threadId,\n });\n}\n```\n\n## Resource Selection\n\nResource 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.\n\n```typescript\n// In your tool:\nasync getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> {\n const client = new ApiClient({ accessToken: token.token });\n const calendars = await client.listCalendars();\n return calendars.map(c => ({\n id: c.id,\n title: c.name,\n children: c.subCalendars?.map(sc => ({ id: sc.id, title: sc.name })),\n }));\n}\n```\n\n## Batch Processing Pattern\n\n**Important**: Because Twists run in an ephemeral environment with limited requests per execution (~1000 requests), you must break long operations into batches. Each batch runs independently in a new execution context with its own fresh request limit.\n\n### Key Principles\n\n1. **Stay under request limits**: Each execution has ~1000 requests. Size batches accordingly.\n2. **Use runTask() for fresh limits**: Each call to `this.runTask()` creates a NEW execution with fresh ~1000 requests\n3. **Store state between batches**: Use the Store tool to persist progress\n4. **Calculate safe batch sizes**: Determine requests per item to size batches (e.g., ~10 requests per item = ~100 items per batch)\n5. **Clean up when done**: Delete stored state after completion\n6. **Handle failures**: Store enough state to resume if a batch fails\n\n### Example Implementation\n\n```typescript\nasync startSync(resourceId: string): Promise<void> {\n // Initialize state in Store (persists between executions)\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: null,\n batchNumber: 1,\n itemsProcessed: 0,\n initialSync: true, // Track whether this is the first sync\n });\n\n // Queue first batch using runTask method\n const callback = await this.callback(this.syncBatch, resourceId);\n // runTask creates NEW execution with fresh ~1000 request limit\n await this.runTask(callback);\n}\n\nasync syncBatch(resourceId: string): Promise<void> {\n // Load state from Store (set by previous execution)\n const state = await this.get(`sync_state_${resourceId}`);\n\n // Process one batch (size to stay under ~1000 request limit)\n const result = await this.fetchBatch(state.nextPageToken);\n\n // Process results using source/key pattern (automatic upserts, no manual tracking)\n // If each item makes ~10 requests, keep batch size \u2264 100 items to stay under limit\n for (const item of result.items) {\n // Each createThread may make ~5-10 requests depending on notes/links\n await this.tools.plot.createThread({\n source: item.url, // Use item's canonical URL for automatic deduplication\n type: ThreadType.Note,\n title: item.title,\n notes: [{\n activity: { source: item.url },\n key: \"description\", // Use key for upsertable notes\n content: item.description,\n }],\n ...(state.initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(state.initialSync ? { archived: false } : {}), // unarchive on initial only\n });\n }\n\n if (result.nextPageToken) {\n // Update state in Store for next batch\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: result.nextPageToken,\n batchNumber: state.batchNumber + 1,\n itemsProcessed: state.itemsProcessed + result.items.length,\n initialSync: state.initialSync, // Preserve initialSync flag across batches\n });\n\n // Queue next batch - creates NEW execution with fresh request limit\n const nextCallback = await this.callback(this.syncBatch, resourceId);\n await this.runTask(nextCallback);\n } else {\n // Cleanup when complete\n await this.clear(`sync_state_${resourceId}`);\n\n // Optionally notify user of completion\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Sync complete\",\n notes: [\n {\n content: `Successfully processed ${state.itemsProcessed + result.items.length} items.`,\n },\n ],\n });\n }\n}\n```\n\n## Thread Sync Best Practices\n\nWhen syncing threads from external systems, follow these patterns for optimal user experience:\n\n### The `initialSync` Flag\n\nAll sync-based tools should distinguish between initial sync (first import) and incremental sync (ongoing updates):\n\n| Field | Initial Sync | Incremental Sync | Reason |\n|-------|--------------|------------------|---------|\n| `unread` | `false` | *omit* | Initial: mark read for all. Incremental: auto-mark read for author only |\n| `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates |\n\n**Example:**\n```typescript\nconst thread: NewThread = {\n type: ThreadType.Event,\n source: event.url,\n title: event.title,\n ...(initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(initialSync ? { archived: false } : {}), // unarchive on initial only\n};\n```\n\n**Why this matters:**\n- **Initial sync**: Activities are unarchived and marked as read for all users, preventing spam from bulk historical imports\n- **Incremental sync**: Activities are auto-marked read for the author (twist owner), unread for everyone else. Archived state is preserved\n- **Reinstall**: Acts as initial sync, so previously archived activities are unarchived (fresh start)\n\n### Two-Way Sync: Avoiding Race Conditions\n\nWhen 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.\n\n**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.\n\n```typescript\nasync pushNoteAsComment(note: Note, externalItemId: string): Promise<void> {\n // Create external item with Plot ID in metadata for webhook correlation\n const externalComment = await externalApi.createComment(externalItemId, {\n body: note.content,\n metadata: { plotNoteId: note.id },\n });\n\n // Update Note with external key AFTER creation\n // A webhook may arrive before this completes \u2014 that's OK (see onWebhook below)\n await this.tools.plot.updateNote({\n id: note.id,\n key: `comment-${externalComment.id}`,\n });\n}\n\nasync onWebhook(payload: WebhookPayload): Promise<void> {\n const comment = payload.comment;\n\n // Use Plot ID from metadata if present (handles race condition),\n // otherwise fall back to upserting by activity source and key\n await this.tools.plot.createNote({\n ...(comment.metadata?.plotNoteId\n ? { id: comment.metadata.plotNoteId }\n : { activity: { source: payload.itemUrl } }),\n key: `comment-${comment.id}`,\n content: comment.body,\n });\n}\n```\n\n## Error Handling\n\nAlways handle errors gracefully and communicate them to users:\n\n```typescript\ntry {\n await this.externalOperation();\n} catch (error) {\n console.error(\"Operation failed:\", error);\n\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Operation failed\",\n notes: [\n {\n content: `Failed to complete operation: ${error.message}`,\n },\n ],\n });\n}\n```\n\n## Common Pitfalls\n\n- **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.\n- **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.\n- **Always create Threads with Notes** - See \"Understanding Threads and Notes\" section above for the thread/message pattern and decision tree.\n- **Use correct Thread types** - Most should be `ThreadType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`.\n- **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).\n- **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.\n- Tools are declared in the `build` method and accessed via `this.tools.toolName` in twist methods.\n- **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).\n- **Always use Callbacks tool for persistent references** - Direct function references don't survive worker restarts.\n- **Store auth tokens** - Don't re-request authentication unnecessarily.\n- **Clean up callbacks and stored state** - Delete callbacks and Store entries when no longer needed.\n- **Handle missing auth gracefully** - Check for stored auth before operations.\n- **CRITICAL: Maintain callback backward compatibility** - All callbacks (webhooks, tasks, batch operations) automatically upgrade to new twist versions. You **must** maintain backward compatibility in callback method signatures. Only add optional parameters at the end, never remove or reorder parameters. For breaking changes, implement migration logic in the `upgrade()` lifecycle method to recreate affected callbacks.\n\n## Testing\n\nBefore deploying, verify:\n\n1. Linting passes: `{{packageManager}} lint`\n2. All dependencies are in package.json\n3. Authentication flow works end-to-end\n4. Batch operations handle pagination correctly\n5. Error cases are handled gracefully\n";
|
|
8
8
|
export default _default;
|
|
9
9
|
//# sourceMappingURL=twist-guide-template.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"twist-guide-template.d.ts","sourceRoot":"","sources":["../../src/llm-docs/twist-guide-template.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;wBAEY,
|
|
1
|
+
{"version":3,"file":"twist-guide-template.d.ts","sourceRoot":"","sources":["../../src/llm-docs/twist-guide-template.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;wBAEY,s6wBAA8twB;AAA7uwB,wBAA8uwB"}
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* This file is auto-generated during build. Do not edit manually.
|
|
5
5
|
* Generated from: cli/templates/AGENTS.template.md
|
|
6
6
|
*/
|
|
7
|
-
export default "# Twist Implementation Guide for LLMs\n\nThis document provides context for AI assistants generating or modifying twists.\n\n## Architecture Overview\n\nPlot Twists are TypeScript classes that extend the `Twist` base class. Twists interact with external services and Plot's core functionality through a tool-based architecture.\n\n### Runtime Environment\n\n**Critical**: All Twists and tool functions are executed in a sandboxed, ephemeral environment with limited resources:\n\n- **Memory is temporary**: Anything stored in memory (e.g. as a variable in the twist/tool object) is lost after the function completes. Use the Store tool instead. Only use memory for temporary caching.\n- **Limited requests per execution**: Each execution has ~1000 requests (HTTP requests, tool calls, database operations)\n- **Limited CPU time**: Each execution has limited CPU time (typically ~60 seconds) and memory (128MB)\n- **Use tasks to get fresh request limits**: `this.runTask()` creates a NEW execution with a fresh ~1000 request limit\n- **Calling callbacks continues same execution**: `this.run()` continues the same execution and shares the request count\n- **Break long loops**: Split large operations into batches that each stay under the ~1000 request limit\n- **Store intermediate state**: Use the Store tool to persist state between batches\n- **Examples**: Syncing large datasets, processing many API calls, or performing batch operations\n\n## Understanding Threads and Notes\n\n**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.\n\n**Think of a Thread as a thread** on a messaging platform, and **Notes as the messages in that thread**.\n\n### Key Guidelines\n\n1. **Always create Threads with an initial Note** - The title is just a summary; detailed content goes in Notes\n2. **Add Notes to existing Threads for updates** - Don't create a new Thread for each related message\n3. **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.\n4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot threads per external item (see SYNC_STRATEGIES.md)\n5. **Most Threads should be `ThreadType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end`\n\n### Recommended Decision Tree (Strategy 2: Upsert via Source/Key)\n\n```\nNew event/task/conversation from external system?\n ├─ Has stable URL or ID?\n │ └─ Yes → Set Thread.source to the canonical URL/ID\n │ Create Thread (Plot handles deduplication automatically)\n │ Use Note.key for different note types:\n │ - \"description\" for main content\n │ - \"metadata\" for status/priority/assignee\n │ - \"comment-{id}\" for individual comments\n │\n └─ No stable identifier OR need multiple Plot threads per external item?\n └─ Use Advanced Pattern (Strategy 3: Generate and Store IDs)\n See SYNC_STRATEGIES.md for details\n```\n\n### Advanced Decision Tree (Strategy 3: Generate and Store IDs)\n\nOnly use when source/key upserts aren't sufficient (e.g., creating multiple threads from one external item):\n\n```\nNew event/task/conversation?\n ├─ Yes → Generate UUID with Uuid.Generate()\n │ Create new Thread with that UUID\n │ Store mapping: external_id → thread_uuid\n │\n └─ No (update/reply/comment) → Look up mapping by external_id\n ├─ Found → Add Note to existing Thread using stored UUID\n └─ Not found → Create new Thread with UUID + store mapping\n```\n\n## Twist Structure Pattern\n\n```typescript\nimport {\n type Thread,\n type NewThreadWithNotes,\n type ThreadFilter,\n type Priority,\n type ToolBuilder,\n Twist,\n ThreadType,\n} from \"@plotday/twister\";\nimport { ThreadAccess, Plot } from \"@plotday/twister/tools/plot\";\n// Import your sources or tools as needed\n\nexport default class MyTwist extends Twist<MyTwist> {\n build(build: ToolBuilder) {\n return {\n plot: build(Plot, {\n thread: { access: ThreadAccess.Create },\n }),\n };\n }\n\n async activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection handled in the twist edit modal.\n }\n}\n```\n\n## Tool System\n\n### Accessing Tools\n\nAll tools are declared in the `build` method:\n\n```typescript\nbuild(build: ToolBuilder) {\n return {\n toolName: build(ToolClass),\n };\n}\n```\n\nAll `build()` calls must occur in the `build` method as they are used for dependency analysis.\n\nIMPORTANT: HTTP access is restricted to URLs requested via `build(Network, { urls: [url1, url2, ...] })` in the `build` method. Wildcards are supported. Use `build(Network, { urls: ['*'] })` if full access is needed.\n\n### Built-in Tools (Always Available)\n\nFor complete API documentation of built-in tools including all methods, types, and detailed examples, see the TypeScript definitions in your installed package at `node_modules/@plotday/twister/src/tools/*.ts`. Each tool file contains comprehensive JSDoc documentation.\n\n**Quick reference - Available tools:**\n\n- `@plotday/twister/tools/plot` - Core data layer (create/update activities, priorities, contacts)\n- `@plotday/twister/tools/ai` - LLM integration (text generation, structured output, reasoning)\n - Use ModelPreferences to specify `speed` (fast/balanced/capable) and `cost` (low/medium/high)\n- `@plotday/twister/tools/store` - Persistent key-value storage (also via `this.set()`, `this.get()`)\n- `@plotday/twister/tools/tasks` - Queue batched work (also via `this.run()`)\n- `@plotday/twister/tools/callbacks` - Persistent function references (also via `this.callback()`)\n- `@plotday/twister/tools/integrations` - OAuth2 authentication flows\n- `@plotday/twister/tools/network` - HTTP access permissions and webhook management\n- `@plotday/twister/tools/twists` - Manage other Twists\n\n**Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods.\n\n## Lifecycle Methods\n\n### activate(priority: Pick<Priority, \"id\">)\n\nCalled when the twist is enabled for a priority. Auth and resource selection are handled automatically via the twist edit modal when using external tools with Integrations.\n\nMost twists have an empty or minimal `activate()`:\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection are handled in the twist edit modal.\n // Only add custom initialization here if needed.\n}\n```\n\n**Store Parent Thread for Later (optional):**\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n const threadId = await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Setup complete\",\n notes: [{\n content: \"Your twist is ready. Threads will appear as they sync.\",\n }],\n });\n await this.set(\"setup_thread_id\", threadId);\n}\n```\n\n### Event Callbacks (via build options)\n\nTwists respond to events through callbacks declared in `build()`:\n\n**React to thread changes (for two-way sync):**\n\n```typescript\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\nasync onThreadUpdated(thread: Thread, changes: { tagsAdded, tagsRemoved }): Promise<void> {\n const tool = this.getToolForThread(thread);\n if (tool?.updateIssue) await tool.updateIssue(thread);\n}\n\nasync onNoteCreated(note: Note, thread: Thread): Promise<NoteWriteBackResult | void> {\n if (note.author.type === ActorType.Twist) return; // Prevent loops\n // Sync note to external service as a comment. Return the external\n // system's id + stored content so the runtime can set note.key AND\n // record a sync baseline that preserves Plot's content on round-trip.\n // See connectors/AGENTS.md → \"Sync baseline preservation\".\n const comment = await externalApi.createComment(thread.meta.externalId, { body: note.content ?? \"\" });\n if (!comment?.id) return;\n return { key: `comment-${comment.id}`, externalContent: comment.body };\n}\n```\n\n**Respond to mentions (AI twist pattern):**\n\n```typescript\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n intents: [{\n description: \"Respond to general questions\",\n examples: [\"What's the weather?\", \"Help me plan my week\"],\n handler: this.respond,\n }],\n },\n}),\n```\n\n**Default mention on replies:**\n\nWhen your twist processes replies (two-way comment sync or conversational AI), set `defaultMention: true` so the user doesn't have to manually re-mention your twist on every note:\n\n- `thread.defaultMention` — Auto-mention on replies to threads your twist created (e.g., synced issues, emails)\n- `note.defaultMention` — Auto-mention on follow-up notes in threads where your twist was @-mentioned (e.g., conversational agents)\n\n```typescript\n// Connector with two-way comment sync\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n defaultMention: true, // Users replying to synced threads will mention this twist by default\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\n// Conversational AI twist\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n defaultMention: true, // Follow-up notes auto-mention this twist\n intents: [{ description: \"...\", examples: [...], handler: this.respond }],\n },\n}),\n```\n\nWithout `defaultMention`, the twist chip appears toggled OFF by default — the user can still enable it manually per-note.\n\n## Actions\n\nActions enable user interaction:\n\n```typescript\nimport { type Action, ActionType } from \"@plotday/twister\";\n\n// External URL action\nconst urlAction: Action = {\n title: \"Open website\",\n type: ActionType.external,\n url: \"https://example.com\",\n};\n\n// Callback action (uses Callbacks tool — use linkCallback, not callback)\nconst token = await this.linkCallback(this.onActionClicked, \"context\");\nconst callbackAction: Action = {\n title: \"Click me\",\n type: ActionType.callback,\n callback: token,\n};\n\n// Add to thread note\nawait this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Task with actions\",\n notes: [\n {\n content: \"Click the actions below to take action.\",\n actions: [urlAction, callbackAction],\n },\n ],\n});\n\n// Callback handler receives the Action as first argument\nasync onActionClicked(action: Action, context: string): Promise<void> {\n // Handle action click\n}\n```\n\n## Authentication Pattern\n\nAuth is handled automatically via the Integrations tool. Tools declare their OAuth provider in `build()`, and users connect in the twist edit modal. **You do not need to create auth activities manually.**\n\n```typescript\n// In your tool's build() method:\nbuild(build: ToolBuilder) {\n return {\n integrations: build(Integrations, {\n providers: [{\n provider: AuthProvider.Google,\n scopes: [\"https://www.googleapis.com/auth/calendar\"],\n getChannels: this.getChannels, // List available resources after auth\n onChannelEnabled: this.onChannelEnabled, // User enabled a resource\n onChannelDisabled: this.onChannelDisabled, // User disabled a resource\n }],\n }),\n // ...\n };\n}\n\n// Get a token for API calls:\nconst token = await this.tools.integrations.get(AuthProvider.Google, channelId);\nif (!token) throw new Error(\"No auth token available\");\nconst client = new ApiClient({ accessToken: token.token });\n```\n\nFor per-user write-backs (e.g., RSVP, comments attributed to the acting user):\n\n```typescript\nawait this.tools.integrations.actAs(\n AuthProvider.Google,\n actorId, // The user who performed the action\n threadId, // Thread to prompt for auth if needed\n this.performWriteBack,\n ...extraArgs\n);\n```\n\n## Sync Pattern\n\n### Upsert via Source/Key (Strategy 2)\n\nUse source/key for automatic upserts:\n\n```typescript\nasync handleEvent(event: ExternalEvent): Promise<void> {\n const thread: NewThreadWithNotes = {\n source: event.htmlLink, // Canonical URL for automatic deduplication\n type: ThreadType.Event,\n title: event.summary || \"(No title)\",\n notes: [],\n };\n\n if (event.description) {\n thread.notes.push({\n thread: { source: event.htmlLink },\n key: \"description\", // This key enables note-level upserts\n content: event.description,\n });\n }\n\n // Create or update — Plot handles deduplication automatically\n await this.tools.plot.createThread(thread);\n}\n```\n\n### Advanced: Generate and Store IDs (Strategy 3)\n\nOnly 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.\n\n```typescript\nasync handleEventAdvanced(\n incomingThread: NewThreadWithNotes,\n calendarId: string\n): Promise<void> {\n // Extract external event ID from meta (adapt based on your tool's data)\n const externalId = incomingThread.meta?.eventId;\n\n if (!externalId) {\n console.error(\"Event missing external ID\");\n return;\n }\n\n // Check if we've already synced this event\n const mappingKey = `event_mapping:${calendarId}:${externalId}`;\n const existingThreadId = await this.get<Uuid>(mappingKey);\n\n if (existingThreadId) {\n // Event already exists - add update as a Note (add message to thread)\n if (incomingThread.notes?.[0]?.content) {\n await this.tools.plot.createNote({\n thread: { id: existingThreadId },\n content: incomingThread.notes[0].content,\n });\n }\n return;\n }\n\n // New event - generate UUID and store mapping\n const threadId = Uuid.Generate();\n await this.set(mappingKey, threadId);\n\n // Create new Thread with initial Note (new thread with first message)\n await this.tools.plot.createThread({\n ...incomingThread,\n id: threadId,\n });\n}\n```\n\n## Resource Selection\n\nResource 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.\n\n```typescript\n// In your tool:\nasync getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> {\n const client = new ApiClient({ accessToken: token.token });\n const calendars = await client.listCalendars();\n return calendars.map(c => ({\n id: c.id,\n title: c.name,\n children: c.subCalendars?.map(sc => ({ id: sc.id, title: sc.name })),\n }));\n}\n```\n\n## Batch Processing Pattern\n\n**Important**: Because Twists run in an ephemeral environment with limited requests per execution (~1000 requests), you must break long operations into batches. Each batch runs independently in a new execution context with its own fresh request limit.\n\n### Key Principles\n\n1. **Stay under request limits**: Each execution has ~1000 requests. Size batches accordingly.\n2. **Use runTask() for fresh limits**: Each call to `this.runTask()` creates a NEW execution with fresh ~1000 requests\n3. **Store state between batches**: Use the Store tool to persist progress\n4. **Calculate safe batch sizes**: Determine requests per item to size batches (e.g., ~10 requests per item = ~100 items per batch)\n5. **Clean up when done**: Delete stored state after completion\n6. **Handle failures**: Store enough state to resume if a batch fails\n\n### Example Implementation\n\n```typescript\nasync startSync(resourceId: string): Promise<void> {\n // Initialize state in Store (persists between executions)\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: null,\n batchNumber: 1,\n itemsProcessed: 0,\n initialSync: true, // Track whether this is the first sync\n });\n\n // Queue first batch using runTask method\n const callback = await this.callback(this.syncBatch, resourceId);\n // runTask creates NEW execution with fresh ~1000 request limit\n await this.runTask(callback);\n}\n\nasync syncBatch(resourceId: string): Promise<void> {\n // Load state from Store (set by previous execution)\n const state = await this.get(`sync_state_${resourceId}`);\n\n // Process one batch (size to stay under ~1000 request limit)\n const result = await this.fetchBatch(state.nextPageToken);\n\n // Process results using source/key pattern (automatic upserts, no manual tracking)\n // If each item makes ~10 requests, keep batch size ≤ 100 items to stay under limit\n for (const item of result.items) {\n // Each createThread may make ~5-10 requests depending on notes/links\n await this.tools.plot.createThread({\n source: item.url, // Use item's canonical URL for automatic deduplication\n type: ThreadType.Note,\n title: item.title,\n notes: [{\n activity: { source: item.url },\n key: \"description\", // Use key for upsertable notes\n content: item.description,\n }],\n ...(state.initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(state.initialSync ? { archived: false } : {}), // unarchive on initial only\n });\n }\n\n if (result.nextPageToken) {\n // Update state in Store for next batch\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: result.nextPageToken,\n batchNumber: state.batchNumber + 1,\n itemsProcessed: state.itemsProcessed + result.items.length,\n initialSync: state.initialSync, // Preserve initialSync flag across batches\n });\n\n // Queue next batch - creates NEW execution with fresh request limit\n const nextCallback = await this.callback(this.syncBatch, resourceId);\n await this.runTask(nextCallback);\n } else {\n // Cleanup when complete\n await this.clear(`sync_state_${resourceId}`);\n\n // Optionally notify user of completion\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Sync complete\",\n notes: [\n {\n content: `Successfully processed ${state.itemsProcessed + result.items.length} items.`,\n },\n ],\n });\n }\n}\n```\n\n## Thread Sync Best Practices\n\nWhen syncing threads from external systems, follow these patterns for optimal user experience:\n\n### The `initialSync` Flag\n\nAll sync-based tools should distinguish between initial sync (first import) and incremental sync (ongoing updates):\n\n| Field | Initial Sync | Incremental Sync | Reason |\n|-------|--------------|------------------|---------|\n| `unread` | `false` | *omit* | Initial: mark read for all. Incremental: auto-mark read for author only |\n| `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates |\n\n**Example:**\n```typescript\nconst thread: NewThread = {\n type: ThreadType.Event,\n source: event.url,\n title: event.title,\n ...(initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(initialSync ? { archived: false } : {}), // unarchive on initial only\n};\n```\n\n**Why this matters:**\n- **Initial sync**: Activities are unarchived and marked as read for all users, preventing spam from bulk historical imports\n- **Incremental sync**: Activities are auto-marked read for the author (twist owner), unread for everyone else. Archived state is preserved\n- **Reinstall**: Acts as initial sync, so previously archived activities are unarchived (fresh start)\n\n### Two-Way Sync: Avoiding Race Conditions\n\nWhen 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.\n\n**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.\n\n```typescript\nasync pushNoteAsComment(note: Note, externalItemId: string): Promise<void> {\n // Create external item with Plot ID in metadata for webhook correlation\n const externalComment = await externalApi.createComment(externalItemId, {\n body: note.content,\n metadata: { plotNoteId: note.id },\n });\n\n // Update Note with external key AFTER creation\n // A webhook may arrive before this completes — that's OK (see onWebhook below)\n await this.tools.plot.updateNote({\n id: note.id,\n key: `comment-${externalComment.id}`,\n });\n}\n\nasync onWebhook(payload: WebhookPayload): Promise<void> {\n const comment = payload.comment;\n\n // Use Plot ID from metadata if present (handles race condition),\n // otherwise fall back to upserting by activity source and key\n await this.tools.plot.createNote({\n ...(comment.metadata?.plotNoteId\n ? { id: comment.metadata.plotNoteId }\n : { activity: { source: payload.itemUrl } }),\n key: `comment-${comment.id}`,\n content: comment.body,\n });\n}\n```\n\n## Error Handling\n\nAlways handle errors gracefully and communicate them to users:\n\n```typescript\ntry {\n await this.externalOperation();\n} catch (error) {\n console.error(\"Operation failed:\", error);\n\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Operation failed\",\n notes: [\n {\n content: `Failed to complete operation: ${error.message}`,\n },\n ],\n });\n}\n```\n\n## Common Pitfalls\n\n- **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.\n- **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.\n- **Always create Threads with Notes** - See \"Understanding Threads and Notes\" section above for the thread/message pattern and decision tree.\n- **Use correct Thread types** - Most should be `ThreadType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`.\n- **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).\n- **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.\n- Tools are declared in the `build` method and accessed via `this.tools.toolName` in twist methods.\n- **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).\n- **Always use Callbacks tool for persistent references** - Direct function references don't survive worker restarts.\n- **Store auth tokens** - Don't re-request authentication unnecessarily.\n- **Clean up callbacks and stored state** - Delete callbacks and Store entries when no longer needed.\n- **Handle missing auth gracefully** - Check for stored auth before operations.\n- **CRITICAL: Maintain callback backward compatibility** - All callbacks (webhooks, tasks, batch operations) automatically upgrade to new twist versions. You **must** maintain backward compatibility in callback method signatures. Only add optional parameters at the end, never remove or reorder parameters. For breaking changes, implement migration logic in the `upgrade()` lifecycle method to recreate affected callbacks.\n\n## Testing\n\nBefore deploying, verify:\n\n1. Linting passes: `{{packageManager}} lint`\n2. All dependencies are in package.json\n3. Authentication flow works end-to-end\n4. Batch operations handle pagination correctly\n5. Error cases are handled gracefully\n";
|
|
7
|
+
export default "# Twist Implementation Guide for LLMs\n\nThis document provides context for AI assistants generating or modifying twists.\n\n## Architecture Overview\n\nPlot Twists are TypeScript classes that extend the `Twist` base class. Twists interact with external services and Plot's core functionality through a tool-based architecture.\n\n### Runtime Environment\n\n**Critical**: All Twists and tool functions are executed in a sandboxed, ephemeral environment with limited resources:\n\n- **Memory is temporary**: Anything stored in memory (e.g. as a variable in the twist/tool object) is lost after the function completes. Use the Store tool instead. Only use memory for temporary caching.\n- **Limited requests per execution**: Each execution has ~1000 requests (HTTP requests, tool calls, database operations)\n- **Limited CPU time**: Each execution has limited CPU time (typically ~60 seconds) and memory (128MB)\n- **Use tasks to get fresh request limits**: `this.runTask()` creates a NEW execution with a fresh ~1000 request limit\n- **Calling callbacks continues same execution**: `this.run()` continues the same execution and shares the request count\n- **Break long loops**: Split large operations into batches that each stay under the ~1000 request limit\n- **Store intermediate state**: Use the Store tool to persist state between batches\n- **Examples**: Syncing large datasets, processing many API calls, or performing batch operations\n\n## Understanding Threads and Notes\n\n**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.\n\n**Think of a Thread as a thread** on a messaging platform, and **Notes as the messages in that thread**.\n\n### Key Guidelines\n\n1. **Always create Threads with an initial Note** - The title is just a summary; detailed content goes in Notes\n2. **Add Notes to existing Threads for updates** - Don't create a new Thread for each related message\n3. **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.\n4. **For advanced cases, use generated UUIDs** - Only when you need multiple Plot threads per external item (see SYNC_STRATEGIES.md)\n5. **Most Threads should be `ThreadType.Note`** - Use `Action` only for tasks with `done`, use `Event` only for items with `start`/`end`\n\n### Recommended Decision Tree (Strategy 2: Upsert via Source/Key)\n\n```\nNew event/task/conversation from external system?\n ├─ Has stable URL or ID?\n │ └─ Yes → Set Thread.source to the canonical URL/ID\n │ Create Thread (Plot handles deduplication automatically)\n │ Use Note.key for different note types:\n │ - \"description\" for main content\n │ - \"metadata\" for status/priority/assignee\n │ - \"comment-{id}\" for individual comments\n │\n └─ No stable identifier OR need multiple Plot threads per external item?\n └─ Use Advanced Pattern (Strategy 3: Generate and Store IDs)\n See SYNC_STRATEGIES.md for details\n```\n\n### Advanced Decision Tree (Strategy 3: Generate and Store IDs)\n\nOnly use when source/key upserts aren't sufficient (e.g., creating multiple threads from one external item):\n\n```\nNew event/task/conversation?\n ├─ Yes → Generate UUID with Uuid.Generate()\n │ Create new Thread with that UUID\n │ Store mapping: external_id → thread_uuid\n │\n └─ No (update/reply/comment) → Look up mapping by external_id\n ├─ Found → Add Note to existing Thread using stored UUID\n └─ Not found → Create new Thread with UUID + store mapping\n```\n\n## Twist Structure Pattern\n\n```typescript\nimport {\n type Thread,\n type NewThreadWithNotes,\n type ThreadFilter,\n type Priority,\n type ToolBuilder,\n Twist,\n ThreadType,\n} from \"@plotday/twister\";\nimport { ThreadAccess, Plot } from \"@plotday/twister/tools/plot\";\n// Import your sources or tools as needed\n\nexport default class MyTwist extends Twist<MyTwist> {\n build(build: ToolBuilder) {\n return {\n plot: build(Plot, {\n thread: { access: ThreadAccess.Create },\n }),\n };\n }\n\n async activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection handled in the twist edit modal.\n }\n}\n```\n\n## Tool System\n\n### Accessing Tools\n\nAll tools are declared in the `build` method:\n\n```typescript\nbuild(build: ToolBuilder) {\n return {\n toolName: build(ToolClass),\n };\n}\n```\n\nAll `build()` calls must occur in the `build` method as they are used for dependency analysis.\n\nIMPORTANT: HTTP access is restricted to URLs requested via `build(Network, { urls: [url1, url2, ...] })` in the `build` method. Wildcards are supported. Use `build(Network, { urls: ['*'] })` if full access is needed.\n\n### Built-in Tools (Always Available)\n\nFor complete API documentation of built-in tools including all methods, types, and detailed examples, see the TypeScript definitions in your installed package at `node_modules/@plotday/twister/src/tools/*.ts`. Each tool file contains comprehensive JSDoc documentation.\n\n**Quick reference - Available tools:**\n\n- `@plotday/twister/tools/plot` - Core data layer (create/update activities, priorities, contacts)\n- `@plotday/twister/tools/ai` - LLM integration (text generation, structured output, reasoning)\n - Use ModelPreferences to specify `speed` (fast/balanced/capable) and `cost` (low/medium/high)\n- `@plotday/twister/tools/store` - Persistent key-value storage (also via `this.set()`, `this.get()`)\n- `@plotday/twister/tools/tasks` - Queue batched work (also via `this.run()`)\n- `@plotday/twister/tools/callbacks` - Persistent function references (also via `this.callback()`)\n- `@plotday/twister/tools/integrations` - OAuth2 authentication flows\n- `@plotday/twister/tools/network` - HTTP access permissions and webhook management\n- `@plotday/twister/tools/twists` - Manage other Twists\n\n**Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods.\n\n## Lifecycle Methods\n\n### activate(priority: Pick<Priority, \"id\">)\n\nCalled when the twist is enabled for a priority. Auth and resource selection are handled automatically via the twist edit modal when using external tools with Integrations.\n\nMost twists have an empty or minimal `activate()`:\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n // Auth and resource selection are handled in the twist edit modal.\n // Only add custom initialization here if needed.\n}\n```\n\n**Store Parent Thread for Later (optional):**\n\n```typescript\nasync activate(_priority: Pick<Priority, \"id\">) {\n const threadId = await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Setup complete\",\n notes: [{\n content: \"Your twist is ready. Threads will appear as they sync.\",\n }],\n });\n await this.set(\"setup_thread_id\", threadId);\n}\n```\n\n### Event Callbacks (via build options)\n\nTwists respond to events through callbacks declared in `build()`:\n\n**React to thread changes (for two-way sync):**\n\n```typescript\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\nasync onThreadUpdated(thread: Thread, changes: { tagsAdded, tagsRemoved }): Promise<void> {\n const tool = this.getToolForThread(thread);\n if (tool?.updateIssue) await tool.updateIssue(thread);\n}\n\nasync onNoteCreated(note: Note, thread: Thread): Promise<NoteWriteBackResult | void> {\n if (note.author.type === ActorType.Twist) return; // Prevent loops\n // Sync note to external service as a comment. Return the external\n // system's id + stored content so the runtime can set note.key AND\n // record a sync baseline that preserves Plot's content on round-trip.\n // See connectors/AGENTS.md → \"Sync baseline preservation\".\n const comment = await externalApi.createComment(thread.meta.externalId, { body: note.content ?? \"\" });\n if (!comment?.id) return;\n return { key: `comment-${comment.id}`, externalContent: comment.body };\n}\n```\n\n**Respond to mentions (AI twist pattern):**\n\n```typescript\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n intents: [{\n description: \"Respond to general questions\",\n examples: [\"What's the weather?\", \"Help me plan my week\"],\n handler: this.respond,\n }],\n },\n}),\n```\n\n**Default mention on replies:**\n\nWhen your twist processes replies (two-way comment sync or conversational AI), set `defaultMention: true` so the user doesn't have to manually re-mention your twist on every note:\n\n- `thread.defaultMention` — Auto-mention on replies to threads your twist created (e.g., synced issues, emails)\n- `note.defaultMention` — Auto-mention on follow-up notes in threads where your twist was @-mentioned (e.g., conversational agents)\n\n```typescript\n// Connector with two-way comment sync\nplot: build(Plot, {\n thread: {\n access: ThreadAccess.Create,\n defaultMention: true, // Users replying to synced threads will mention this twist by default\n updated: this.onThreadUpdated,\n },\n note: {\n created: this.onNoteCreated,\n },\n}),\n\n// Conversational AI twist\nplot: build(Plot, {\n thread: { access: ThreadAccess.Respond },\n note: {\n defaultMention: true, // Follow-up notes auto-mention this twist\n intents: [{ description: \"...\", examples: [...], handler: this.respond }],\n },\n}),\n```\n\nWithout `defaultMention`, the twist chip appears toggled OFF by default — the user can still enable it manually per-note.\n\n## Actions\n\nActions enable user interaction:\n\n```typescript\nimport { type Action, ActionType } from \"@plotday/twister\";\n\n// External URL action\nconst urlAction: Action = {\n title: \"Open website\",\n type: ActionType.external,\n url: \"https://example.com\",\n};\n\n// Callback action (uses Callbacks tool — use linkCallback, not callback)\nconst token = await this.linkCallback(this.onActionClicked, \"context\");\nconst callbackAction: Action = {\n title: \"Click me\",\n type: ActionType.callback,\n callback: token,\n};\n\n// Add to thread note\nawait this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Task with actions\",\n notes: [\n {\n content: \"Click the actions below to take action.\",\n actions: [urlAction, callbackAction],\n },\n ],\n});\n\n// Callback handler receives the Action as first argument\nasync onActionClicked(action: Action, context: string): Promise<void> {\n // Handle action click\n}\n```\n\n## Authentication Pattern\n\nAuth is handled automatically via the Integrations tool. Tools declare their OAuth provider in `build()`, and users connect in the twist edit modal. **You do not need to create auth activities manually.**\n\n```typescript\n// In your tool's build() method:\nbuild(build: ToolBuilder) {\n return {\n integrations: build(Integrations, {\n providers: [{\n provider: AuthProvider.Google,\n scopes: [\"https://www.googleapis.com/auth/calendar\"],\n getChannels: this.getChannels, // List available resources after auth\n onChannelEnabled: this.onChannelEnabled, // User enabled a resource\n onChannelDisabled: this.onChannelDisabled, // User disabled a resource\n }],\n }),\n // ...\n };\n}\n\n// Get a token for API calls:\nconst token = await this.tools.integrations.get(AuthProvider.Google, channelId);\nif (!token) throw new Error(\"No auth token available\");\nconst client = new ApiClient({ accessToken: token.token });\n```\n\nFor per-user write-backs (e.g., RSVP, comments attributed to the acting user):\nthe dispatch runtime routes the change to the acting user's own connector\ninstance, so your callback (`onScheduleContactUpdated`, etc.) already runs\nunder that user's auth. Just call `this.tools.integrations.get(channelId)`\nto fetch the token and the write-back is attributed correctly. If the\nacting user has no connection of this type, the change lives in Plot but\nis not dispatched.\n\n## Sync Pattern\n\n### Upsert via Source/Key (Strategy 2)\n\nUse source/key for automatic upserts:\n\n```typescript\nasync handleEvent(event: ExternalEvent): Promise<void> {\n const thread: NewThreadWithNotes = {\n source: event.htmlLink, // Canonical URL for automatic deduplication\n type: ThreadType.Event,\n title: event.summary || \"(No title)\",\n notes: [],\n };\n\n if (event.description) {\n thread.notes.push({\n thread: { source: event.htmlLink },\n key: \"description\", // This key enables note-level upserts\n content: event.description,\n });\n }\n\n // Create or update — Plot handles deduplication automatically\n await this.tools.plot.createThread(thread);\n}\n```\n\n### Advanced: Generate and Store IDs (Strategy 3)\n\nOnly 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.\n\n```typescript\nasync handleEventAdvanced(\n incomingThread: NewThreadWithNotes,\n calendarId: string\n): Promise<void> {\n // Extract external event ID from meta (adapt based on your tool's data)\n const externalId = incomingThread.meta?.eventId;\n\n if (!externalId) {\n console.error(\"Event missing external ID\");\n return;\n }\n\n // Check if we've already synced this event\n const mappingKey = `event_mapping:${calendarId}:${externalId}`;\n const existingThreadId = await this.get<Uuid>(mappingKey);\n\n if (existingThreadId) {\n // Event already exists - add update as a Note (add message to thread)\n if (incomingThread.notes?.[0]?.content) {\n await this.tools.plot.createNote({\n thread: { id: existingThreadId },\n content: incomingThread.notes[0].content,\n });\n }\n return;\n }\n\n // New event - generate UUID and store mapping\n const threadId = Uuid.Generate();\n await this.set(mappingKey, threadId);\n\n // Create new Thread with initial Note (new thread with first message)\n await this.tools.plot.createThread({\n ...incomingThread,\n id: threadId,\n });\n}\n```\n\n## Resource Selection\n\nResource 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.\n\n```typescript\n// In your tool:\nasync getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> {\n const client = new ApiClient({ accessToken: token.token });\n const calendars = await client.listCalendars();\n return calendars.map(c => ({\n id: c.id,\n title: c.name,\n children: c.subCalendars?.map(sc => ({ id: sc.id, title: sc.name })),\n }));\n}\n```\n\n## Batch Processing Pattern\n\n**Important**: Because Twists run in an ephemeral environment with limited requests per execution (~1000 requests), you must break long operations into batches. Each batch runs independently in a new execution context with its own fresh request limit.\n\n### Key Principles\n\n1. **Stay under request limits**: Each execution has ~1000 requests. Size batches accordingly.\n2. **Use runTask() for fresh limits**: Each call to `this.runTask()` creates a NEW execution with fresh ~1000 requests\n3. **Store state between batches**: Use the Store tool to persist progress\n4. **Calculate safe batch sizes**: Determine requests per item to size batches (e.g., ~10 requests per item = ~100 items per batch)\n5. **Clean up when done**: Delete stored state after completion\n6. **Handle failures**: Store enough state to resume if a batch fails\n\n### Example Implementation\n\n```typescript\nasync startSync(resourceId: string): Promise<void> {\n // Initialize state in Store (persists between executions)\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: null,\n batchNumber: 1,\n itemsProcessed: 0,\n initialSync: true, // Track whether this is the first sync\n });\n\n // Queue first batch using runTask method\n const callback = await this.callback(this.syncBatch, resourceId);\n // runTask creates NEW execution with fresh ~1000 request limit\n await this.runTask(callback);\n}\n\nasync syncBatch(resourceId: string): Promise<void> {\n // Load state from Store (set by previous execution)\n const state = await this.get(`sync_state_${resourceId}`);\n\n // Process one batch (size to stay under ~1000 request limit)\n const result = await this.fetchBatch(state.nextPageToken);\n\n // Process results using source/key pattern (automatic upserts, no manual tracking)\n // If each item makes ~10 requests, keep batch size ≤ 100 items to stay under limit\n for (const item of result.items) {\n // Each createThread may make ~5-10 requests depending on notes/links\n await this.tools.plot.createThread({\n source: item.url, // Use item's canonical URL for automatic deduplication\n type: ThreadType.Note,\n title: item.title,\n notes: [{\n activity: { source: item.url },\n key: \"description\", // Use key for upsertable notes\n content: item.description,\n }],\n ...(state.initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(state.initialSync ? { archived: false } : {}), // unarchive on initial only\n });\n }\n\n if (result.nextPageToken) {\n // Update state in Store for next batch\n await this.set(`sync_state_${resourceId}`, {\n nextPageToken: result.nextPageToken,\n batchNumber: state.batchNumber + 1,\n itemsProcessed: state.itemsProcessed + result.items.length,\n initialSync: state.initialSync, // Preserve initialSync flag across batches\n });\n\n // Queue next batch - creates NEW execution with fresh request limit\n const nextCallback = await this.callback(this.syncBatch, resourceId);\n await this.runTask(nextCallback);\n } else {\n // Cleanup when complete\n await this.clear(`sync_state_${resourceId}`);\n\n // Optionally notify user of completion\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Sync complete\",\n notes: [\n {\n content: `Successfully processed ${state.itemsProcessed + result.items.length} items.`,\n },\n ],\n });\n }\n}\n```\n\n## Thread Sync Best Practices\n\nWhen syncing threads from external systems, follow these patterns for optimal user experience:\n\n### The `initialSync` Flag\n\nAll sync-based tools should distinguish between initial sync (first import) and incremental sync (ongoing updates):\n\n| Field | Initial Sync | Incremental Sync | Reason |\n|-------|--------------|------------------|---------|\n| `unread` | `false` | *omit* | Initial: mark read for all. Incremental: auto-mark read for author only |\n| `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates |\n\n**Example:**\n```typescript\nconst thread: NewThread = {\n type: ThreadType.Event,\n source: event.url,\n title: event.title,\n ...(initialSync ? { unread: false } : {}), // false for initial, omit for incremental\n ...(initialSync ? { archived: false } : {}), // unarchive on initial only\n};\n```\n\n**Why this matters:**\n- **Initial sync**: Activities are unarchived and marked as read for all users, preventing spam from bulk historical imports\n- **Incremental sync**: Activities are auto-marked read for the author (twist owner), unread for everyone else. Archived state is preserved\n- **Reinstall**: Acts as initial sync, so previously archived activities are unarchived (fresh start)\n\n### Two-Way Sync: Avoiding Race Conditions\n\nWhen 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.\n\n**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.\n\n```typescript\nasync pushNoteAsComment(note: Note, externalItemId: string): Promise<void> {\n // Create external item with Plot ID in metadata for webhook correlation\n const externalComment = await externalApi.createComment(externalItemId, {\n body: note.content,\n metadata: { plotNoteId: note.id },\n });\n\n // Update Note with external key AFTER creation\n // A webhook may arrive before this completes — that's OK (see onWebhook below)\n await this.tools.plot.updateNote({\n id: note.id,\n key: `comment-${externalComment.id}`,\n });\n}\n\nasync onWebhook(payload: WebhookPayload): Promise<void> {\n const comment = payload.comment;\n\n // Use Plot ID from metadata if present (handles race condition),\n // otherwise fall back to upserting by activity source and key\n await this.tools.plot.createNote({\n ...(comment.metadata?.plotNoteId\n ? { id: comment.metadata.plotNoteId }\n : { activity: { source: payload.itemUrl } }),\n key: `comment-${comment.id}`,\n content: comment.body,\n });\n}\n```\n\n## Error Handling\n\nAlways handle errors gracefully and communicate them to users:\n\n```typescript\ntry {\n await this.externalOperation();\n} catch (error) {\n console.error(\"Operation failed:\", error);\n\n await this.tools.plot.createThread({\n type: ThreadType.Note,\n title: \"Operation failed\",\n notes: [\n {\n content: `Failed to complete operation: ${error.message}`,\n },\n ],\n });\n}\n```\n\n## Common Pitfalls\n\n- **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.\n- **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.\n- **Always create Threads with Notes** - See \"Understanding Threads and Notes\" section above for the thread/message pattern and decision tree.\n- **Use correct Thread types** - Most should be `ThreadType.Note`. Only use `Action` for tasks with `done`, and `Event` for items with `start`/`end`.\n- **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).\n- **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.\n- Tools are declared in the `build` method and accessed via `this.tools.toolName` in twist methods.\n- **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).\n- **Always use Callbacks tool for persistent references** - Direct function references don't survive worker restarts.\n- **Store auth tokens** - Don't re-request authentication unnecessarily.\n- **Clean up callbacks and stored state** - Delete callbacks and Store entries when no longer needed.\n- **Handle missing auth gracefully** - Check for stored auth before operations.\n- **CRITICAL: Maintain callback backward compatibility** - All callbacks (webhooks, tasks, batch operations) automatically upgrade to new twist versions. You **must** maintain backward compatibility in callback method signatures. Only add optional parameters at the end, never remove or reorder parameters. For breaking changes, implement migration logic in the `upgrade()` lifecycle method to recreate affected callbacks.\n\n## Testing\n\nBefore deploying, verify:\n\n1. Linting passes: `{{packageManager}} lint`\n2. All dependencies are in package.json\n3. Authentication flow works end-to-end\n4. Batch operations handle pagination correctly\n5. Error cases are handled gracefully\n";
|
|
8
8
|
//# sourceMappingURL=twist-guide-template.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"twist-guide-template.js","sourceRoot":"","sources":["../../src/llm-docs/twist-guide-template.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAe,
|
|
1
|
+
{"version":3,"file":"twist-guide-template.js","sourceRoot":"","sources":["../../src/llm-docs/twist-guide-template.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAe,8twBAA8twB,CAAC"}
|