` link), or a bare meeting
+ * UUID or `not_…` note id. Granola's MCP server reads either form.
+ */
+export function parseGranolaNoteId(value: string): string | null {
+ const trimmed = value.trim();
+ if (UUID.test(trimmed)) return trimmed.toLowerCase();
+ if (NOTE_ID.test(trimmed)) return trimmed;
+ let url: URL;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ const host = url.hostname.toLowerCase();
+ if (
+ url.protocol !== "https:" ||
+ url.username ||
+ url.password ||
+ !(host === "granola.ai" || host.endsWith(".granola.ai"))
+ )
+ return null;
+ // Shared links (`/t/-`) append a code to the meeting UUID.
+ const segment = url.pathname.split("/").findLast(Boolean) ?? "";
+ return LINK_SEGMENT.exec(segment)?.[1]?.toLowerCase() ?? null;
+}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 702b5e4c644eb..c2bba2a719143 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -34,6 +34,7 @@ export * from "./vcs.ts";
export * from "./sourceControl.ts";
export * from "./linear.ts";
export * from "./notion.ts";
+export * from "./granola.ts";
export * from "./slack.ts";
export * from "./githubIssues.ts";
export * from "./contextRepositories.ts";
diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts
index d9c665be1d703..5f4232d9ef6f7 100644
--- a/packages/contracts/src/orchestratorMcp.ts
+++ b/packages/contracts/src/orchestratorMcp.ts
@@ -467,7 +467,7 @@ export const OrchestratorMcpContextLinks = Schema.Array(
.check(Schema.isMaxLength(10))
.annotate({
description:
- "Links to attach as context, read now and sent with the message: Slack message permalinks (the whole thread), Notion pages, Linear issues, GitHub issues, and GitHub repository roots (cloned into the thread's context folder). A link that also appears in the message text is replaced there by its chip; the rest are appended. Pull request links are not attachable; leave them in the text. Fails if an integration is not connected or the link cannot be read.",
+ "Links to attach as context, read now and sent with the message: Slack message permalinks (the whole thread), Notion pages, Granola meeting notes, Linear issues, GitHub issues, and GitHub repository roots (cloned into the thread's context folder). A link that also appears in the message text is replaced there by its chip; the rest are appended. Pull request links are not attachable; leave them in the text. Fails if an integration is not connected or the link cannot be read.",
});
export const OrchestratorMcpThreadSendInput = Schema.Struct({
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 6ebe39e14ccbd..6f5e8e4b96e64 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -19,6 +19,20 @@ import {
NotionThreadLinks,
NotionUnlinkThreadInput,
} from "./notion.ts";
+import {
+ GranolaCancelLoginInput,
+ GranolaConnectionState,
+ GranolaError,
+ GranolaGetNoteInput,
+ GranolaLinkThreadInput,
+ GranolaNoteContext,
+ GranolaNoteSummary,
+ GranolaThreadLink,
+ GranolaThreadLinks,
+ GranolaUnlinkThreadInput,
+ GranolaSearchNotesInput,
+ GranolaSearchNotesResult,
+} from "./granola.ts";
import {
McpAppCallToolInput,
McpAppCallToolResult,
@@ -693,6 +707,16 @@ export const WS_METHODS = {
notionSubscribeThreadLinks: "notion.subscribeThreadLinks",
notionLinkThread: "notion.linkThread",
notionUnlinkThread: "notion.unlinkThread",
+ granolaSubscribeState: "granola.subscribeState",
+ granolaStartLogin: "granola.startLogin",
+ granolaCancelLogin: "granola.cancelLogin",
+ granolaDisconnect: "granola.disconnect",
+ granolaSearchNotes: "granola.searchNotes",
+ granolaGetNote: "granola.getNote",
+ granolaGetNoteSummary: "granola.getNoteSummary",
+ granolaSubscribeThreadLinks: "granola.subscribeThreadLinks",
+ granolaLinkThread: "granola.linkThread",
+ granolaUnlinkThread: "granola.unlinkThread",
slackSubscribeState: "slack.subscribeState",
slackStartLogin: "slack.startLogin",
slackCompleteLogin: "slack.completeLogin",
@@ -1072,6 +1096,61 @@ const WsNotionUnlinkThreadRpc = Rpc.make(WS_METHODS.notionUnlinkThread, {
error: NotionRpcError,
});
+const GranolaRpcError = Schema.Union([GranolaError, EnvironmentAuthorizationError]);
+const WsGranolaSubscribeStateRpc = Rpc.make(WS_METHODS.granolaSubscribeState, {
+ payload: Schema.Struct({}),
+ success: GranolaConnectionState,
+ error: GranolaRpcError,
+ stream: true,
+});
+const WsGranolaStartLoginRpc = Rpc.make(WS_METHODS.granolaStartLogin, {
+ payload: Schema.Struct({}),
+ success: GranolaConnectionState,
+ error: GranolaRpcError,
+});
+const WsGranolaCancelLoginRpc = Rpc.make(WS_METHODS.granolaCancelLogin, {
+ payload: GranolaCancelLoginInput,
+ success: GranolaConnectionState,
+ error: GranolaRpcError,
+});
+const WsGranolaDisconnectRpc = Rpc.make(WS_METHODS.granolaDisconnect, {
+ payload: Schema.Struct({}),
+ success: GranolaConnectionState,
+ error: GranolaRpcError,
+});
+const WsGranolaSearchNotesRpc = Rpc.make(WS_METHODS.granolaSearchNotes, {
+ payload: GranolaSearchNotesInput,
+ success: GranolaSearchNotesResult,
+ error: GranolaRpcError,
+});
+const WsGranolaGetNoteRpc = Rpc.make(WS_METHODS.granolaGetNote, {
+ payload: GranolaGetNoteInput,
+ success: GranolaNoteContext,
+ error: GranolaRpcError,
+});
+/** A note's title and meeting, for naming a bare note link; the server keeps each one it reads. */
+const WsGranolaGetNoteSummaryRpc = Rpc.make(WS_METHODS.granolaGetNoteSummary, {
+ payload: GranolaGetNoteInput,
+ success: GranolaNoteSummary,
+ error: GranolaRpcError,
+});
+const WsGranolaSubscribeThreadLinksRpc = Rpc.make(WS_METHODS.granolaSubscribeThreadLinks, {
+ payload: Schema.Struct({}),
+ success: GranolaThreadLinks,
+ error: EnvironmentAuthorizationError,
+ stream: true,
+});
+const WsGranolaLinkThreadRpc = Rpc.make(WS_METHODS.granolaLinkThread, {
+ payload: GranolaLinkThreadInput,
+ success: GranolaThreadLink,
+ error: GranolaRpcError,
+});
+const WsGranolaUnlinkThreadRpc = Rpc.make(WS_METHODS.granolaUnlinkThread, {
+ payload: GranolaUnlinkThreadInput,
+ success: Schema.Struct({}),
+ error: GranolaRpcError,
+});
+
const SlackRpcError = Schema.Union([SlackError, EnvironmentAuthorizationError]);
const WsSlackSubscribeStateRpc = Rpc.make(WS_METHODS.slackSubscribeState, {
@@ -2474,6 +2553,16 @@ export const WsRpcGroup = RpcGroup.make(
WsNotionSubscribeThreadLinksRpc,
WsNotionLinkThreadRpc,
WsNotionUnlinkThreadRpc,
+ WsGranolaSubscribeStateRpc,
+ WsGranolaStartLoginRpc,
+ WsGranolaCancelLoginRpc,
+ WsGranolaDisconnectRpc,
+ WsGranolaSearchNotesRpc,
+ WsGranolaGetNoteRpc,
+ WsGranolaGetNoteSummaryRpc,
+ WsGranolaSubscribeThreadLinksRpc,
+ WsGranolaLinkThreadRpc,
+ WsGranolaUnlinkThreadRpc,
WsSlackSubscribeStateRpc,
WsSlackStartLoginRpc,
WsSlackCompleteLoginRpc,
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 1a985a342808c..6f5816a9c6f16 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1460,6 +1460,8 @@ export const ServerSettings = Schema.Struct({
),
/** Enable Notion attachment entry points and automatic link resolution for this environment. */
enableNotionIntegration: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ /** Enable Granola attachment entry points and automatic link resolution for this environment. */
+ enableGranolaIntegration: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
/** Fork: Linear issues assigned to the connected account start threads. Empty is off. */
linearAssignmentTriggers: LinearAssignmentTriggers.pipe(
Schema.withDecodingDefault(Effect.succeed([])),
@@ -1920,6 +1922,7 @@ export const ServerSettingsPatch = Schema.Struct({
/** Replaces the whole trigger, so a cleared channel list sticks. */
slackMentionTrigger: Schema.optionalKey(SlackMentionTriggerSettings),
enableNotionIntegration: Schema.optionalKey(Schema.Boolean),
+ enableGranolaIntegration: Schema.optionalKey(Schema.Boolean),
linearAssignmentTriggers: Schema.optionalKey(LinearAssignmentTriggers),
continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 20cec9c2d0b58..610af7dab579c 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
+ "./filterFacets": {
+ "types": "./src/filterFacets.ts",
+ "import": "./src/filterFacets.ts"
+ },
"./githubMarkdownLinks": {
"types": "./src/githubMarkdownLinks.ts",
"import": "./src/githubMarkdownLinks.ts"
diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts
index 40a20bee0b6fc..21f3306aacd9d 100644
--- a/packages/shared/src/composerContextReferences.ts
+++ b/packages/shared/src/composerContextReferences.ts
@@ -255,6 +255,10 @@ function formatComposerContextProviderPayload(record: KnownComposerContextRecord
return [`chat: ${record.title}`, `threadId: ${record.threadId}`, record.summary].join("\n");
case "notion-page":
return [`notion page: ${record.title}`, `url: ${record.url}`, record.markdown].join("\n");
+ case "granola-note":
+ return [`granola meeting note: ${record.title}`, `url: ${record.url}`, record.markdown].join(
+ "\n",
+ );
case "linear-issue":
return [
`linear issue: ${record.identifier}`,
diff --git a/packages/shared/src/composerObjectLinks.test.ts b/packages/shared/src/composerObjectLinks.test.ts
index 93b12cd63c294..e8f56cec65493 100644
--- a/packages/shared/src/composerObjectLinks.test.ts
+++ b/packages/shared/src/composerObjectLinks.test.ts
@@ -163,3 +163,22 @@ it("recognizes Notion page links in pasted prose", () => {
expect(objectLinkLabel(url)).toBe("Notion page");
expect(findComposerObjectLinks(`Read ${url}.`)).toHaveLength(1);
});
+
+it("recognizes Granola note links in pasted prose", () => {
+ const url = "https://notes.granola.ai/d/ABD3F11C-1FF0-4E2C-AD7D-078D1FFCF27F";
+ expect(parseComposerObjectLink(url)).toMatchObject({
+ kind: "granola-note",
+ noteId: "abd3f11c-1ff0-4e2c-ad7d-078d1ffcf27f",
+ });
+ expect(objectLinkLabel(url)).toBe("Granola note");
+ expect(findComposerObjectLinks(`Notes: ${url}.`)).toHaveLength(1);
+ expect(
+ parseComposerObjectLink(
+ "https://notes.granola.ai/t/8a5ddce1-1ff0-4e2c-ad7d-078d1ffcf27f-008umkv4",
+ ),
+ ).toMatchObject({ kind: "granola-note", noteId: "8a5ddce1-1ff0-4e2c-ad7d-078d1ffcf27f" });
+ expect(parseComposerObjectLink("https://www.granola.ai/pricing")).toBeNull();
+ expect(
+ parseComposerObjectLink("http://notes.granola.ai/d/abd3f11c-1ff0-4e2c-ad7d-078d1ffcf27f"),
+ ).toBeNull();
+});
diff --git a/packages/shared/src/composerObjectLinks.ts b/packages/shared/src/composerObjectLinks.ts
index 38f426728c608..75c6551e996cd 100644
--- a/packages/shared/src/composerObjectLinks.ts
+++ b/packages/shared/src/composerObjectLinks.ts
@@ -1,12 +1,13 @@
-import { parseNotionPageId, parseGitHubIssueUrl } from "@t3tools/contracts";
+import { parseGranolaNoteId, parseNotionPageId, parseGitHubIssueUrl } from "@t3tools/contracts";
import { parseChangeRequestUrl } from "./changeRequestUrl.ts";
/**
* A link in composer text to something the composer can attach as a chip instead: a Linear
- * issue, a GitHub issue, a pull request, a GitHub repository root, a Slack message, or a Notion page.
+ * issue, a GitHub issue, a pull request, a GitHub repository root, a Slack message, a Notion page, or a Granola note.
*/
export type ComposerObjectLink =
| { readonly kind: "notion-page"; readonly url: string; readonly pageId: string }
+ | { readonly kind: "granola-note"; readonly url: string; readonly noteId: string }
| { readonly kind: "linear-issue"; readonly url: string; readonly identifier: string }
| {
readonly kind: "slack-message";
@@ -77,6 +78,8 @@ export function parseComposerObjectLink(url: string): ComposerObjectLink | null
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
const notionPageId = parseNotionPageId(url);
if (notionPageId !== null) return { kind: "notion-page", url, pageId: notionPageId };
+ const granolaNoteId = parseGranolaNoteId(url);
+ if (granolaNoteId !== null) return { kind: "granola-note", url, noteId: granolaNoteId };
const segments = parsed.pathname.split("/").filter(Boolean);
// https://linear.app//issue//
@@ -126,6 +129,8 @@ export function objectLinkLabel(url: string): string | null {
return null;
case "notion-page":
return "Notion page";
+ case "granola-note":
+ return "Granola note";
case "linear-issue":
return link.identifier;
case "slack-message":
diff --git a/packages/shared/src/filterFacets.test.ts b/packages/shared/src/filterFacets.test.ts
new file mode 100644
index 0000000000000..3577946f9deef
--- /dev/null
+++ b/packages/shared/src/filterFacets.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { applyFilterDimensions, filterFacets } from "./filterFacets.ts";
+
+interface Issue {
+ readonly id: string;
+ readonly author: string;
+ readonly labels: ReadonlyArray;
+}
+
+const issues: ReadonlyArray = [
+ { id: "1", author: "ada", labels: ["bug", "web"] },
+ { id: "2", author: "ada", labels: ["web"] },
+ { id: "3", author: "grace", labels: ["bug"] },
+ { id: "4", author: "linus", labels: [] },
+];
+
+const dimensions = (author: string[], labels: string[]) => ({
+ author: { values: (issue: Issue) => [issue.author], selected: author },
+ labels: { values: (issue: Issue) => issue.labels, selected: labels },
+});
+
+describe("filterFacets", () => {
+ it("counts each dimension under the others, not itself", () => {
+ const facets = filterFacets(issues, dimensions(["ada"], []));
+ // Picking ada leaves the author menu whole, so more authors can be added.
+ expect(facets.author).toEqual([
+ { value: "ada", count: 2 },
+ { value: "grace", count: 1 },
+ { value: "linus", count: 1 },
+ ]);
+ // The labels menu only counts ada's issues.
+ expect(facets.labels).toEqual([
+ { value: "web", count: 2 },
+ { value: "bug", count: 1 },
+ ]);
+ });
+
+ it("keeps a picked value the other filters exclude, at zero", () => {
+ const facets = filterFacets(issues, dimensions(["linus"], ["bug"]));
+ expect(facets.author).toEqual([
+ { value: "ada", count: 1 },
+ { value: "grace", count: 1 },
+ { value: "linus", count: 0 },
+ ]);
+ expect(facets.labels).toEqual([{ value: "bug", count: 0 }]);
+ });
+
+ it("uses a dimension's own matcher when it has one", () => {
+ const allOf = {
+ ...dimensions([], ["bug", "web"]),
+ labels: {
+ values: (issue: Issue) => issue.labels,
+ selected: ["bug", "web"],
+ matches: (issue: Issue) => ["bug", "web"].every((label) => issue.labels.includes(label)),
+ },
+ };
+ expect(applyFilterDimensions(issues, allOf).map((issue) => issue.id)).toEqual(["1"]);
+ expect(filterFacets(issues, allOf).author).toEqual([{ value: "ada", count: 1 }]);
+ });
+});
diff --git a/packages/shared/src/filterFacets.ts b/packages/shared/src/filterFacets.ts
new file mode 100644
index 0000000000000..a00b6a95f8bee
--- /dev/null
+++ b/packages/shared/src/filterFacets.ts
@@ -0,0 +1,66 @@
+/**
+ * Faceted filtering for list filter menus. Each dimension's choices, and the count beside each,
+ * come from the items that pass every *other* applied dimension, so a menu shows what picking a
+ * choice would leave rather than what the whole list holds. A picked value the other filters
+ * now exclude stays offered with a count of 0, so it can still be unpicked.
+ */
+
+export interface FilterDimension- {
+ /** The choices one item belongs to in this dimension, such as its labels or its author. */
+ readonly values: (item: Item) => ReadonlyArray
;
+ /** The applied choices. Empty means the dimension is off. */
+ readonly selected: ReadonlyArray;
+ /**
+ * Whether an item passes this dimension's selection. Defaults to belonging to any selected
+ * value; pass one for "all of", ranges, or anything else.
+ */
+ readonly matches?: (item: Item) => boolean;
+}
+
+export interface FacetChoice {
+ readonly value: string;
+ readonly count: number;
+}
+
+function passes- (dimension: FilterDimension
- , item: Item): boolean {
+ if (dimension.matches) return dimension.matches(item);
+ if (dimension.selected.length === 0) return true;
+ return dimension.values(item).some((value) => dimension.selected.includes(value));
+}
+
+/** The items every dimension lets through. */
+export function applyFilterDimensions
- (
+ items: ReadonlyArray
- ,
+ dimensions: Record
>,
+): Item[] {
+ const all = Object.values>(dimensions);
+ return items.filter((item) => all.every((dimension) => passes(dimension, item)));
+}
+
+/**
+ * Each dimension's choices with how many items picking each would show under the other applied
+ * dimensions, most frequent first, then by value. Callers that list choices in a fixed order
+ * (date ranges, states) can look counts up by value instead.
+ */
+export function filterFacets- (
+ items: ReadonlyArray
- ,
+ dimensions: Record
>,
+): Record {
+ const keys = Object.keys(dimensions) as Key[];
+ const result = {} as Record;
+ for (const key of keys) {
+ const dimension = dimensions[key];
+ const counts = new Map();
+ for (const item of items) {
+ if (!keys.every((other) => other === key || passes(dimensions[other], item))) continue;
+ for (const value of new Set(dimension.values(item))) {
+ counts.set(value, (counts.get(value) ?? 0) + 1);
+ }
+ }
+ for (const value of dimension.selected) if (!counts.has(value)) counts.set(value, 0);
+ result[key] = [...counts]
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .map(([value, count]) => ({ value, count }));
+ }
+ return result;
+}
diff --git a/packages/shared/src/integrationContextRecords.ts b/packages/shared/src/integrationContextRecords.ts
index 31dddc5164365..e3440a42907c0 100644
--- a/packages/shared/src/integrationContextRecords.ts
+++ b/packages/shared/src/integrationContextRecords.ts
@@ -2,6 +2,8 @@ import type {
ComposerContextId,
GitHubIssueContext,
GitHubIssueContextRecord,
+ GranolaNoteContext,
+ GranolaNoteContextRecord,
LinearIssueContext,
LinearIssueContextRecord,
NotionPageContext,
@@ -93,6 +95,22 @@ export function notionPageContextRecord(page: NotionPageContext): NotionPageCont
};
}
+export function granolaNoteContextRecord(note: GranolaNoteContext): GranolaNoteContextRecord {
+ return {
+ version: 1,
+ kind: "granola-note",
+ // Meeting UUIDs and `not_` ids both fit the context id pattern.
+ contextId: `granola-note_${note.id}` as ComposerContextId,
+ label: sanitizeComposerContextLabel(note.title, "granola-note"),
+ noteId: note.id,
+ title: note.title.slice(0, 2048),
+ url: note.url.slice(0, 2048),
+ meetingAt: note.meetingAt?.slice(0, 2048) ?? null,
+ participants: note.participants.join(", ").slice(0, 2048),
+ markdown: note.markdown,
+ };
+}
+
/** The chip record for one repository to clone into the workspace's context folder. */
export function repositoryContextRecord(input: {
readonly nameWithOwner: string;