Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canonical-thread-link-connectors.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `Integrations.saveNotes`/`saveNote` (attach note-attached links to a thread by id or source) and `Integrations.archiveNotes` (mirror of archiveLinks for the note model).
14 changes: 14 additions & 0 deletions connectors/google-calendar/src/google-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1169,6 +1169,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
title: activityData.title || undefined,
status: "Cancelled",
preview: "Cancelled",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
meta: activityData.meta ?? null,
notes: [cancelNote],
schedules: [
Expand DownExpand Up@@ -1341,6 +1348,13 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
? "Tentative"
: undefined,
title: activityData.title || "",
// The calendar that OWNS the event (user is the organizer) outranks a
// subscribed/secondary copy, so its link is the displayed primary.
priority: event.organizer?.self
? 100
: event.attendees?.some((a) => a.self)
? 50
: 0,
access: "private",
accessContacts: attendeeMentions,
author: authorContact,
Expand Down
130 changes: 74 additions & 56 deletions connectors/granola/src/granola.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { type Action, ActionType, type NewLinkWithNotes } from "@plotday/twister";
import { ActionType } from "@plotday/twister";
import { Connector } from "@plotday/twister/connector";
import type { NewNote } from "@plotday/twister/plot";
import { Options } from "@plotday/twister/options";
import type { ToolBuilder } from "@plotday/twister/tool";
import { Callbacks } from "@plotday/twister/tools/callbacks";
Expand DownExpand Up@@ -31,11 +32,15 @@ type SyncState = {
* keys (https://docs.granola.ai). WorkOS-based SSO in their docs covers
* end-user login to the Granola app itself, not programmatic access.
*
* Cross-connector bundling: each Granola note's `sources` includes the
* canonical `icaluid:<calendar_event_id>` alias plus Google/Outlook event-id
* aliases, so the upsert in `link.ts` finds an existing calendar event link
* by array overlap and attaches the Granola note onto that thread. When no
* calendar event matches, a standalone Granola thread is created instead.
* Cross-connector bundling: each Granola note attaches to the calendar
* event's canonical thread (addressed by `note.thread.source =
* icaluid:<calendar_event_id>`) and carries Granola's own link note-scoped
* via `note.link`. The note-attached link's `sources` includes the canonical
* `icaluid:<calendar_event_id>` alias plus Google/Outlook/Apple event-id
* aliases, so a later calendar `createLink` co-locates onto this thread by
* sources overlap and becomes the thread's primary canonical link. When no
* calendar event matches, the note get its own thread keyed by the Granola
* self source (ad-hoc meeting).
*/
export class Granola extends Connector<Granola> {
readonly singleChannel = true;
Expand DownExpand Up@@ -138,10 +143,7 @@ export class Granola extends Connector<Granola> {
async onChannelDisabled(channel: Channel): Promise<void> {
await this.clear(`sync_enabled_${channel.id}`);
await this.clear(`sync_state_${channel.id}`);
await this.tools.integrations.archiveLinks({
channelId: channel.id,
meta: { syncProvider: "granola", channelId: channel.id },
});
await this.tools.integrations.archiveNotes({ channelId: channel.id });
}

private async startBatchSync(
Expand All@@ -162,8 +164,9 @@ export class Granola extends Connector<Granola> {

/**
* Fetch a page of note ids, then for each one fetch full details and emit
* a link. Pagination chains via tasks.runTask() to respect Granola's
* 300 req/min rate limit and the worker's ~1000 req/exec budget.
* a note (carrying Granola's link note-scoped) addressed to the calendar
* event's thread. Pagination chains via tasks.runTask() to respect
* Granola's 300 req/min rate limit and the worker's ~1000 req/exec budget.
*/
async syncBatch(channelId: string, initialSync?: boolean): Promise<void> {
const state = await this.get<SyncState>(`sync_state_${channelId}`);
Expand All@@ -177,11 +180,11 @@ export class Granola extends Connector<Granola> {
updatedAfter: state.syncHistoryMin ?? undefined,
});

const notes: NewNote[] = [];
for (const summary of list.data) {
try {
const note = await api.getNote(summary.id);
const link = this.transformNote(note, channelId, isInitial);
await this.tools.integrations.saveLink(link);
notes.push(this.transformNote(note, channelId, isInitial));
} catch (err) {
// Granola's get-note can fail if the note's AI summary is still
// pending. Skip and pick it up on the next sync.
Expand All@@ -191,6 +194,9 @@ export class Granola extends Connector<Granola> {
);
}
}
if (notes.length > 0) {
await this.tools.integrations.saveNotes(notes);
}

if (list.hasMore && list.cursor) {
await this.set(`sync_state_${channelId}`, {
Expand All@@ -208,16 +214,21 @@ export class Granola extends Connector<Granola> {
}

/**
* Map a Granola note → NewLinkWithNotes. The `sources` array carries the
* connector-native id plus canonical aliases pointing at the calendar
* event. The runtime's array-overlap upsert attaches this note to the
* calendar thread if one exists; otherwise it creates a standalone thread.
* Map a Granola note → NewNote addressed to the calendar event's thread.
*
* Instead of creating a thread-level link owned by Granola, we emit a note
* that attaches to the calendar event's canonical thread (when one exists),
* carrying Granola's own link note-scoped via `note.link`. The note's
* `link.sources` carries the connector-native id plus canonical calendar
* aliases so a later calendar `createLink` co-locates onto this thread via
* sources overlap. When no calendar event matches, the note gets its own
* thread keyed by the Granola self source (ad-hoc meeting).
*/
private transformNote(
note: GranolaNote,
channelId: string,
initialSync: boolean
): NewLinkWithNotes {
): NewNote {
const sources: string[] = [`granola:note:${note.id}`];

// Granola's calendar_event_id is the meeting's calendar identifier. We
Expand All@@ -226,11 +237,13 @@ export class Granola extends Connector<Granola> {
// namespace will overlap with the calendar connector's `sources`.
const calendarEventId = note.calendar_event?.calendar_event_id;
if (calendarEventId) {
sources.push(`icaluid:${calendarEventId}`);
sources.push(`google-event:${calendarEventId}`);
sources.push(`google-calendar:${calendarEventId}`);
// Apple ICS UID — same UID format as iCalUID.
sources.push(`apple-calendar:${calendarEventId}`);
sources.push(
`icaluid:${calendarEventId}`,
`google-event:${calendarEventId}`,
`google-calendar:${calendarEventId}`,
// Apple ICS UID — same UID format as iCalUID.
`apple-calendar:${calendarEventId}`
);
}

const rawContent = note.summary_markdown ?? note.summary_text ?? "";
Expand All@@ -240,42 +253,47 @@ export class Granola extends Connector<Granola> {
// (it's the deep link Granola itself promotes); fall back to web_url.
const granolaUrl = chatUrl ?? note.web_url;

const actions: Action[] = [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
];
// Address the thread by the cross-connector calendar alias when we have one
// (so we co-locate with the calendar event's thread); otherwise the Granola
// note gets its own thread keyed by its self source (ad-hoc meeting).
const threadSource = calendarEventId
? `icaluid:${calendarEventId}`
: `granola:note:${note.id}`;

return {
source: `granola:note:${note.id}`,
sources,
title: note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
sourceUrl: granolaUrl,
actions,
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
thread: { source: threadSource },
// Stable key so re-syncing the same note replaces in place rather than
// appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown",
created: new Date(note.updated_at),
...(initialSync ? { unread: false } : {}),
link: {
source: `granola:note:${note.id}`,
sources,
title:
note.title ?? note.calendar_event?.event_title ?? "Meeting notes",
type: "meeting",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
sourceUrl: granolaUrl,
actions: [
{
type: ActionType.external,
title: "Chat with meeting transcript",
url: granolaUrl,
},
],
created: note.calendar_event?.scheduled_start_time
? new Date(note.calendar_event.scheduled_start_time)
: new Date(note.created_at),
meta: {
syncProvider: "granola",
channelId,
noteId: note.id,
...(calendarEventId ? { calendarEventId } : {}),
},
},
notes: [
{
// Stable key so re-syncing the same note replaces in place
// rather than appending a duplicate summary.
key: "granola-summary",
content,
contentType: "markdown" as const,
created: new Date(note.updated_at),
} as any,
],
...(initialSync ? { unread: false, archived: false } : {}),
};
}
}
Expand Down
32 changes: 32 additions & 0 deletions twister/src/tools/integrations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
type ActorId,
type NewContact,
type NewLinkWithNotes,
type NewNote,
ITool,
} from "..";
import type { JSONValue } from "../utils/types";
Expand DownExpand Up@@ -452,6 +453,28 @@ export abstract class Integrations extends ITool {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveLinks(links: NewLinkWithNotes[]): Promise<(Uuid | null)[]>;

/**
* Save one or more notes. Unlike saveLink (which creates a thread-level
* canonical link), these notes attach to an EXISTING thread — addressed by
* `note.thread: { id }` or `{ source }` — and may carry their own
* note-attached link via `note.link` (a note-scoped link, NOT a thread-level
* canonical link). When `{ source }` resolves to no thread yet, the runtime
* find-or-creates the thread by that source. Use for augmenter content
* (e.g. meeting notes attached to a calendar event).
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNotes(notes: NewNote[]): Promise<(Uuid | null)[]>;
/** Save a single note. See {@link saveNotes}. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract saveNote(note: NewNote): Promise<Uuid | null>;
/**
* Archive every note this connector created (optionally scoped to a channel),
* plus their note-attached links. Mirror of {@link archiveLinks} for the
* note-attached content model. Use in `onChannelDisabled`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
abstract archiveNotes(filter: ArchiveNotesFilter): Promise<void>;

/**
* Upserts contacts into the connector's focus without requiring a Link.
*
Expand DownExpand Up@@ -567,6 +590,15 @@ export type ArchiveLinkFilter = {
meta?: Record<string, JSONValue>;
};

/**
* Filter criteria for archiving notes (and their note-attached links).
* All fields are optional; only provided fields are used for matching.
*/
export type ArchiveNotesFilter = {
/** Restrict to notes whose note-attached link is on this channel. */
channelId?: string;
};

/**
* A workspace custom emoji to cache so Plot can render and round-trip it as a
* reaction. `id` is the provider-scoped ref stored on reactions, of the form
Expand Down
Loading