@remnic/connector-granola 9.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Joshua Warren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @remnic/connector-granola
2
+
3
+ Granola meeting-notes connector for [Remnic](https://github.com/joshuaswarren/remnic).
4
+
5
+ Pulls your Granola meeting notes + transcripts into Remnic's wearables pipeline
6
+ as source `granola`: day transcripts at `<memoryDir>/wearables/granola/<date>.md`,
7
+ searchable via `remnic wearables search`, with trust-gated memories exactly like
8
+ the pendant connectors.
9
+
10
+ > **Cloud-service caveat.** These notes/transcripts were produced by Granola's
11
+ > cloud. Remnic ingests them but cannot make them retroactively local. Requires
12
+ > a Granola **Business/Enterprise** plan to create an API key.
13
+
14
+ ## Install
15
+
16
+ À-la-carte — install alongside `@remnic/core` only if you use Granola:
17
+
18
+ ```sh
19
+ npm install -g @remnic/connector-granola
20
+ ```
21
+
22
+ Installing `@remnic/core` alone never pulls this in; core discovers it at
23
+ runtime. If it is not installed, `remnic wearables` reports a clean install hint.
24
+
25
+ ## API key
26
+
27
+ Create a key in the Granola desktop app under **Settings → Connectors → API
28
+ keys** (choose the note access scopes). The connector resolves the key in this
29
+ order — the **config value takes precedence**, then the environment variables:
30
+
31
+ 1. `wearables.sources.granola.apiKey` (config; wins when set)
32
+ 2. `REMNIC_GRANOLA_API_KEY` (preferred env var)
33
+ 3. `GRANOLA_API_KEY` (provider-conventional env var)
34
+
35
+ On launchd/systemd daemons the process environment is isolated — set the key in
36
+ the unit, not just a shell.
37
+
38
+ ## Configure
39
+
40
+ ```jsonc
41
+ {
42
+ "wearables": {
43
+ "sources": {
44
+ "granola": {
45
+ "enabled": false, // OFF until you turn it on
46
+ "memoryMode": "smart", // off | review | auto | smart
47
+ "sourceTrust": 0.85
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ `enabled` defaults to `false`; nothing syncs until you set it `true`. Enabling
55
+ Granola neither enables nor syncs any other source.
56
+
57
+ ## Verify
58
+
59
+ ```sh
60
+ remnic wearables check granola # ok / bad-key / unreachable
61
+ remnic wearables sync --source granola --date 2026-03-10
62
+ remnic wearables search "roadmap"
63
+ ```
64
+
65
+ ## What it does
66
+
67
+ - Lists notes for the local day via `GET /v1/notes?created_after=&created_before=`
68
+ (half-open `[start, end)`, DST-aware), then fetches each note's transcript with
69
+ `GET /v1/notes/{id}?include=transcript`.
70
+ - Meeting timing prefers the linked calendar event (`scheduled_start/end_time`),
71
+ falling back to the transcript times, then the note's `created_at`.
72
+ - Speaker keys come from `speaker.source` (`microphone` = the wearer's own audio,
73
+ `speaker` = other meeting audio) or the iOS `diarization_label`. The wearables
74
+ speaker registry owns final naming.
75
+ - The notes list returns summaries; a note's detail record may lack a transcript
76
+ (the field is nullable), so a summary-only note becomes a single `note`
77
+ segment. A note that 404s on the detail fetch is skipped, not fatal.
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,144 @@
1
+ import { WearableConversation, WearableConnectorFactoryOptions, WearableSourceConnector, WearableConnectorRegistration } from '@remnic/core';
2
+
3
+ /**
4
+ * Minimal Granola public API client (raw fetch, no SDK).
5
+ *
6
+ * API verified against the official OpenAPI at
7
+ * https://docs.granola.ai/api-reference/list-notes and
8
+ * https://docs.granola.ai/api-reference/get-note (fetched 2026-07-21):
9
+ * - Base URL: https://public-api.granola.ai
10
+ * - Auth: `Authorization: Bearer grn_<key>`
11
+ * - `GET /v1/notes?created_after=&created_before=&cursor=&page_size=` →
12
+ * `{ notes: NoteSummary[], hasMore, cursor }` (page_size 1..30, default 10);
13
+ * `created_after`/`created_before` accept date or date-time. The list only
14
+ * returns notes that already have an AI summary + transcript.
15
+ * - `GET /v1/notes/{id}?include=transcript` → full Note with `calendar_event`
16
+ * (`scheduled_start_time`/`scheduled_end_time`), `attendees`, `summary_text`,
17
+ * `summary_markdown`, and `transcript` items
18
+ * `{ speaker: { source, diarization_label? }, text, start_time, end_time }`.
19
+ * - Rate limits: 5 req/s sustained, 25 burst → 429 on excess.
20
+ *
21
+ * A non-2xx or network failure throws GranolaApiError (a backend failure); an
22
+ * empty `notes` array is a real empty result, never conflated (AGENTS.md §22).
23
+ */
24
+ declare const GRANOLA_DEFAULT_BASE_URL = "https://public-api.granola.ai";
25
+ /** Hard API maximum for `page_size` on the notes list. */
26
+ declare const NOTES_MAX_PAGE_SIZE = 30;
27
+ interface GranolaSpeaker {
28
+ source?: string | null;
29
+ diarization_label?: string | null;
30
+ }
31
+ interface GranolaTranscriptItem {
32
+ speaker?: GranolaSpeaker | null;
33
+ text?: string | null;
34
+ start_time?: string | null;
35
+ end_time?: string | null;
36
+ }
37
+ interface GranolaCalendarEvent {
38
+ event_title?: string | null;
39
+ organiser?: string | null;
40
+ scheduled_start_time?: string | null;
41
+ scheduled_end_time?: string | null;
42
+ }
43
+ interface GranolaUser {
44
+ name?: string | null;
45
+ email?: string | null;
46
+ }
47
+ interface GranolaNote {
48
+ id: string;
49
+ title?: string | null;
50
+ created_at?: string | null;
51
+ updated_at?: string | null;
52
+ calendar_event?: GranolaCalendarEvent | null;
53
+ attendees?: GranolaUser[] | null;
54
+ summary_text?: string | null;
55
+ summary_markdown?: string | null;
56
+ transcript?: GranolaTranscriptItem[] | null;
57
+ }
58
+ interface NotesPage {
59
+ notes: GranolaNote[];
60
+ nextCursor: string | null;
61
+ }
62
+ interface GranolaClientOptions {
63
+ apiKey: string;
64
+ baseUrl?: string;
65
+ fetchImpl?: typeof fetch;
66
+ timeoutMs?: number;
67
+ sleep?: (ms: number) => Promise<void>;
68
+ }
69
+ declare class GranolaApiError extends Error {
70
+ readonly status?: number | undefined;
71
+ constructor(message: string, status?: number | undefined);
72
+ }
73
+ declare class GranolaClient {
74
+ private readonly apiKey;
75
+ private readonly baseUrl;
76
+ private readonly fetchImpl;
77
+ private readonly timeoutMs;
78
+ private readonly sleep;
79
+ constructor(options: GranolaClientOptions);
80
+ /** One page of note summaries in the half-open [createdAfter, createdBefore) window. */
81
+ listNotes(params: {
82
+ createdAfter: string;
83
+ createdBefore: string;
84
+ cursor?: string | null;
85
+ signal?: AbortSignal;
86
+ }): Promise<NotesPage>;
87
+ /** A single note with its transcript, calendar event, summary, and attendees. */
88
+ getNote(id: string, signal?: AbortSignal): Promise<GranolaNote>;
89
+ /** Cheap auth probe. */
90
+ verifyAuth(signal?: AbortSignal): Promise<{
91
+ ok: boolean;
92
+ detail?: string;
93
+ }>;
94
+ private requestJson;
95
+ }
96
+
97
+ /**
98
+ * Normalize Granola notes into Remnic's provider-agnostic
99
+ * `WearableConversation` shape, plus the timezone-aware day-window helpers the
100
+ * Granola `created_after`/`created_before` filters need.
101
+ *
102
+ * Granola transcript items carry absolute `start_time`/`end_time` (ISO) and a
103
+ * `speaker.source` of `microphone` (the wearer's own captured audio) or
104
+ * `speaker` (other meeting audio); iOS adds a `diarization_label`
105
+ * (`Speaker A/B/...`). The wearables speaker registry owns final naming.
106
+ * Meeting timing prefers the linked calendar event; notes with a summary but no
107
+ * transcript degrade to a single `note` segment.
108
+ */
109
+
110
+ declare const GRANOLA_SOURCE_ID = "granola";
111
+ /**
112
+ * Half-open [createdAfter, createdBefore) UTC ISO bounds of a local day — the
113
+ * window the Granola notes list filters on.
114
+ */
115
+ declare function granolaDayWindow(date: string, timezone: string): {
116
+ createdAfter: string;
117
+ createdBefore: string;
118
+ };
119
+ declare function noteToConversation(note: GranolaNote): WearableConversation;
120
+
121
+ /**
122
+ * @remnic/connector-granola — Granola meeting-notes connector.
123
+ *
124
+ * À-la-carte optional companion of @remnic/core: installing core alone
125
+ * never pulls this in; core discovers it at runtime via a
126
+ * computed-specifier dynamic import (see wearables/registry.ts) or via
127
+ * a direct import of this module, which self-registers idempotently.
128
+ *
129
+ * API key: `wearables.sources.granola.apiKey`, else the
130
+ * `REMNIC_GRANOLA_API_KEY` / `GRANOLA_API_KEY` environment variables
131
+ * (checked in that order). Create a key under Settings → Connectors →
132
+ * API keys in the Granola app (Business/Enterprise plans).
133
+ */
134
+
135
+ declare function resolveGranolaApiKey(configuredKey: string | undefined, env?: NodeJS.ProcessEnv): string | undefined;
136
+ declare function createGranolaConnector(options: WearableConnectorFactoryOptions): WearableSourceConnector;
137
+ declare const wearableConnectorRegistration: WearableConnectorRegistration;
138
+ /**
139
+ * Idempotently register the connector with the core registry. Importing this
140
+ * module registers it as a side effect; calling again is safe.
141
+ */
142
+ declare function ensureGranolaConnectorRegistered(): boolean;
143
+
144
+ export { GRANOLA_DEFAULT_BASE_URL, GRANOLA_SOURCE_ID, GranolaApiError, type GranolaCalendarEvent, GranolaClient, type GranolaClientOptions, type GranolaNote, type GranolaSpeaker, type GranolaTranscriptItem, type GranolaUser, NOTES_MAX_PAGE_SIZE, type NotesPage, createGranolaConnector, ensureGranolaConnectorRegistered, granolaDayWindow, noteToConversation, resolveGranolaApiKey, wearableConnectorRegistration };
package/dist/index.js ADDED
@@ -0,0 +1,362 @@
1
+ // openclaw-engram: Local-first memory plugin
2
+
3
+ // src/index.ts
4
+ import {
5
+ registerWearableConnector,
6
+ getWearableConnector
7
+ } from "@remnic/core";
8
+
9
+ // src/client.ts
10
+ var GRANOLA_DEFAULT_BASE_URL = "https://public-api.granola.ai";
11
+ var NOTES_MAX_PAGE_SIZE = 30;
12
+ var DEFAULT_TIMEOUT_MS = 3e4;
13
+ var MAX_RETRIES = 3;
14
+ var MAX_RETRY_DELAY_MS = 3e4;
15
+ var GranolaApiError = class extends Error {
16
+ constructor(message, status) {
17
+ super(message);
18
+ this.status = status;
19
+ this.name = "GranolaApiError";
20
+ }
21
+ status;
22
+ };
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null;
25
+ }
26
+ var GranolaClient = class {
27
+ apiKey;
28
+ baseUrl;
29
+ fetchImpl;
30
+ timeoutMs;
31
+ sleep;
32
+ constructor(options) {
33
+ if (typeof options.apiKey !== "string" || options.apiKey.trim().length === 0) {
34
+ throw new GranolaApiError(
35
+ "Granola API key is missing. Set wearables.sources.granola.apiKey or the REMNIC_GRANOLA_API_KEY / GRANOLA_API_KEY environment variable (create a key under Settings \u2192 Connectors \u2192 API keys in the Granola app; requires a Business/Enterprise plan)."
36
+ );
37
+ }
38
+ this.apiKey = options.apiKey.trim();
39
+ this.baseUrl = stripTrailingSlashes(options.baseUrl ?? GRANOLA_DEFAULT_BASE_URL);
40
+ this.fetchImpl = options.fetchImpl ?? fetch;
41
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
42
+ this.sleep = options.sleep ?? defaultSleep;
43
+ }
44
+ /** One page of note summaries in the half-open [createdAfter, createdBefore) window. */
45
+ async listNotes(params) {
46
+ const search = new URLSearchParams({
47
+ created_after: params.createdAfter,
48
+ created_before: params.createdBefore,
49
+ page_size: String(NOTES_MAX_PAGE_SIZE)
50
+ });
51
+ if (typeof params.cursor === "string" && params.cursor.length > 0) {
52
+ search.set("cursor", params.cursor);
53
+ }
54
+ const payload = await this.requestJson(`/v1/notes?${search.toString()}`, params.signal);
55
+ const notesRaw = isRecord(payload) ? payload.notes : void 0;
56
+ if (!Array.isArray(notesRaw)) {
57
+ throw new GranolaApiError("Granola API returned an unexpected /v1/notes shape (missing notes array)");
58
+ }
59
+ const notes = [];
60
+ for (const entry of notesRaw) {
61
+ if (!isRecord(entry) || typeof entry.id !== "string") {
62
+ throw new GranolaApiError("Granola API returned a note row without a string id");
63
+ }
64
+ notes.push(entry);
65
+ }
66
+ const hasMore = isRecord(payload) && payload.hasMore === true;
67
+ const cursor = isRecord(payload) && typeof payload.cursor === "string" ? payload.cursor : null;
68
+ if (hasMore && (cursor === null || cursor.length === 0)) {
69
+ throw new GranolaApiError("Granola reported hasMore=true but returned no pagination cursor");
70
+ }
71
+ return { notes, nextCursor: hasMore ? cursor : null };
72
+ }
73
+ /** A single note with its transcript, calendar event, summary, and attendees. */
74
+ async getNote(id, signal) {
75
+ const payload = await this.requestJson(
76
+ `/v1/notes/${encodeURIComponent(id)}?include=transcript`,
77
+ signal
78
+ );
79
+ if (!isRecord(payload) || typeof payload.id !== "string") {
80
+ throw new GranolaApiError("Granola API returned an unexpected note shape (missing id)");
81
+ }
82
+ return payload;
83
+ }
84
+ /** Cheap auth probe. */
85
+ async verifyAuth(signal) {
86
+ try {
87
+ await this.requestJson("/v1/notes?page_size=1", signal);
88
+ return { ok: true };
89
+ } catch (err) {
90
+ if (err instanceof GranolaApiError && (err.status === 401 || err.status === 403)) {
91
+ return {
92
+ ok: false,
93
+ detail: "Granola rejected the API key (401/403) \u2014 create a new key under Settings \u2192 Connectors \u2192 API keys"
94
+ };
95
+ }
96
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
97
+ }
98
+ }
99
+ async requestJson(pathAndQuery, signal) {
100
+ let lastError;
101
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
102
+ signal?.throwIfAborted();
103
+ const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
104
+ const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
105
+ let response;
106
+ try {
107
+ response = await this.fetchImpl(`${this.baseUrl}${pathAndQuery}`, {
108
+ method: "GET",
109
+ headers: {
110
+ Authorization: `Bearer ${this.apiKey}`,
111
+ Accept: "application/json"
112
+ },
113
+ signal: combined
114
+ });
115
+ } catch (err) {
116
+ if (signal?.aborted) throw err;
117
+ lastError = err;
118
+ if (attempt < MAX_RETRIES) {
119
+ await this.sleep(backoffMs(attempt));
120
+ continue;
121
+ }
122
+ throw new GranolaApiError(
123
+ `Granola API request failed after ${MAX_RETRIES + 1} attempts: ${describeNetworkError(err)}`
124
+ );
125
+ }
126
+ if (response.status === 429 || response.status >= 500) {
127
+ discardBody(response);
128
+ lastError = new GranolaApiError(`Granola API responded ${response.status}`, response.status);
129
+ if (attempt < MAX_RETRIES) {
130
+ await this.sleep(await retryDelayMs(response, attempt));
131
+ continue;
132
+ }
133
+ throw lastError;
134
+ }
135
+ if (!response.ok) {
136
+ discardBody(response);
137
+ throw new GranolaApiError(
138
+ `Granola API responded ${response.status} for ${pathAndQuery.split("?")[0]}`,
139
+ response.status
140
+ );
141
+ }
142
+ try {
143
+ return await response.json();
144
+ } catch {
145
+ throw new GranolaApiError("Granola API returned a non-JSON body");
146
+ }
147
+ }
148
+ throw lastError instanceof Error ? lastError : new GranolaApiError("Granola API request failed");
149
+ }
150
+ };
151
+ function defaultSleep(ms) {
152
+ const { promise, resolve } = Promise.withResolvers();
153
+ setTimeout(resolve, ms);
154
+ return promise;
155
+ }
156
+ function discardBody(response) {
157
+ void response.body?.cancel().catch(() => {
158
+ });
159
+ }
160
+ function describeNetworkError(err) {
161
+ if (isRecord(err)) {
162
+ const name = typeof err.name === "string" ? err.name : "Error";
163
+ const code = err.code;
164
+ return typeof code === "string" ? `${name} (${code})` : name;
165
+ }
166
+ return "network error";
167
+ }
168
+ function stripTrailingSlashes(value) {
169
+ let end = value.length;
170
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end--;
171
+ return value.slice(0, end);
172
+ }
173
+ function backoffMs(attempt) {
174
+ return Math.min(MAX_RETRY_DELAY_MS, 1e3 * 2 ** attempt);
175
+ }
176
+ async function retryDelayMs(response, attempt) {
177
+ const header = response.headers.get("retry-after");
178
+ if (header) {
179
+ const seconds = Number(header);
180
+ if (Number.isFinite(seconds) && seconds >= 0) {
181
+ return Math.min(MAX_RETRY_DELAY_MS, seconds * 1e3);
182
+ }
183
+ const when = Date.parse(header);
184
+ if (Number.isFinite(when)) {
185
+ return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, when - Date.now()));
186
+ }
187
+ }
188
+ return backoffMs(attempt);
189
+ }
190
+
191
+ // src/normalize.ts
192
+ var GRANOLA_SOURCE_ID = "granola";
193
+ function timezoneOffsetIso(instant, timezone) {
194
+ try {
195
+ const parts = new Intl.DateTimeFormat("en-US", {
196
+ timeZone: timezone,
197
+ timeZoneName: "longOffset"
198
+ }).formatToParts(instant);
199
+ const name = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
200
+ const match = name.match(/GMT([+-]\d{2}:\d{2})?/);
201
+ return match?.[1] ?? "+00:00";
202
+ } catch {
203
+ return "+00:00";
204
+ }
205
+ }
206
+ function zonedDayStartIso(date, timezone) {
207
+ let offset = timezoneOffsetIso(/* @__PURE__ */ new Date(`${date}T12:00:00Z`), timezone);
208
+ const candidate = /* @__PURE__ */ new Date(`${date}T00:00:00${offset}`);
209
+ const refined = timezoneOffsetIso(candidate, timezone);
210
+ if (refined !== offset) {
211
+ offset = refined;
212
+ }
213
+ return `${date}T00:00:00${offset}`;
214
+ }
215
+ function nextIsoDate(date) {
216
+ const parsed = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
217
+ parsed.setUTCDate(parsed.getUTCDate() + 1);
218
+ return parsed.toISOString().slice(0, 10);
219
+ }
220
+ function assertValidTimezone(timezone) {
221
+ try {
222
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone });
223
+ } catch {
224
+ throw new RangeError(
225
+ `Invalid IANA timezone "${timezone}" for the Granola connector \u2014 check wearables.timezone.`
226
+ );
227
+ }
228
+ }
229
+ function granolaDayWindow(date, timezone) {
230
+ assertValidTimezone(timezone);
231
+ return {
232
+ createdAfter: new Date(zonedDayStartIso(date, timezone)).toISOString(),
233
+ createdBefore: new Date(zonedDayStartIso(nextIsoDate(date), timezone)).toISOString()
234
+ };
235
+ }
236
+ function trimmed(value) {
237
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
238
+ }
239
+ function isoOrUndefined(value) {
240
+ if (typeof value !== "string" || value.trim().length === 0) return void 0;
241
+ const ms = Date.parse(value);
242
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : void 0;
243
+ }
244
+ function noteToConversation(note) {
245
+ const event = note.calendar_event ?? void 0;
246
+ const items = note.transcript ?? [];
247
+ const segments = [];
248
+ for (const item of items) {
249
+ const text = trimmed(item.text);
250
+ if (text === void 0) continue;
251
+ const source = trimmed(item.speaker?.source);
252
+ const label = trimmed(item.speaker?.diarization_label);
253
+ const startIso2 = isoOrUndefined(item.start_time);
254
+ const endIso2 = isoOrUndefined(item.end_time);
255
+ const isWearer = source === "microphone" && label === void 0;
256
+ segments.push({
257
+ text,
258
+ speakerKey: label ?? source ?? "unknown",
259
+ ...isWearer ? { isWearer: true } : {},
260
+ ...startIso2 !== void 0 ? { startIso: startIso2 } : {},
261
+ ...endIso2 !== void 0 ? { endIso: endIso2 } : {}
262
+ });
263
+ }
264
+ const summary = trimmed(note.summary_text) ?? trimmed(note.summary_markdown);
265
+ if (segments.length === 0 && summary !== void 0) {
266
+ segments.push({ text: summary, speakerKey: "note" });
267
+ }
268
+ const title = trimmed(event?.event_title) ?? trimmed(note.title);
269
+ const startIso = isoOrUndefined(event?.scheduled_start_time) ?? segments.find((segment) => segment.startIso !== void 0)?.startIso ?? isoOrUndefined(note.created_at) ?? "";
270
+ const endIso = isoOrUndefined(event?.scheduled_end_time) ?? [...segments].reverse().find((segment) => segment.endIso !== void 0)?.endIso;
271
+ return {
272
+ id: note.id,
273
+ source: GRANOLA_SOURCE_ID,
274
+ ...title !== void 0 ? { title } : {},
275
+ ...summary !== void 0 ? { summary } : {},
276
+ startIso,
277
+ ...endIso !== void 0 ? { endIso } : {},
278
+ segments
279
+ };
280
+ }
281
+
282
+ // src/index.ts
283
+ var GRANOLA_DISPLAY_NAME = "Granola";
284
+ function resolveGranolaApiKey(configuredKey, env = process.env) {
285
+ if (typeof configuredKey === "string" && configuredKey.trim().length > 0) {
286
+ return configuredKey.trim();
287
+ }
288
+ for (const name of ["REMNIC_GRANOLA_API_KEY", "GRANOLA_API_KEY"]) {
289
+ const value = env[name];
290
+ if (typeof value === "string" && value.trim().length > 0) {
291
+ return value.trim();
292
+ }
293
+ }
294
+ return void 0;
295
+ }
296
+ function createGranolaConnector(options) {
297
+ let client = null;
298
+ const getClient = () => {
299
+ if (!client) {
300
+ client = new GranolaClient({
301
+ apiKey: resolveGranolaApiKey(options.settings.apiKey) ?? "",
302
+ baseUrl: options.settings.baseUrl
303
+ });
304
+ }
305
+ return client;
306
+ };
307
+ return {
308
+ id: GRANOLA_SOURCE_ID,
309
+ displayName: GRANOLA_DISPLAY_NAME,
310
+ async verifyAuth(signal) {
311
+ return getClient().verifyAuth(signal);
312
+ },
313
+ async fetchConversations(opts) {
314
+ const { createdAfter, createdBefore } = granolaDayWindow(opts.date, opts.timezone);
315
+ const activeClient = getClient();
316
+ const page = await activeClient.listNotes({
317
+ createdAfter,
318
+ createdBefore,
319
+ cursor: opts.cursor,
320
+ signal: opts.signal
321
+ });
322
+ const conversations = [];
323
+ for (const summary of page.notes) {
324
+ let full;
325
+ try {
326
+ full = await activeClient.getNote(summary.id, opts.signal);
327
+ } catch (err) {
328
+ if (err instanceof GranolaApiError && err.status === 404) continue;
329
+ throw err;
330
+ }
331
+ const conversation = noteToConversation(full);
332
+ if (conversation.startIso !== "") conversations.push(conversation);
333
+ }
334
+ return { conversations, nextCursor: page.nextCursor };
335
+ }
336
+ };
337
+ }
338
+ var wearableConnectorRegistration = {
339
+ id: GRANOLA_SOURCE_ID,
340
+ displayName: GRANOLA_DISPLAY_NAME,
341
+ factory: createGranolaConnector
342
+ };
343
+ function ensureGranolaConnectorRegistered() {
344
+ if (getWearableConnector(GRANOLA_SOURCE_ID)) return false;
345
+ registerWearableConnector(wearableConnectorRegistration);
346
+ return true;
347
+ }
348
+ ensureGranolaConnectorRegistered();
349
+ export {
350
+ GRANOLA_DEFAULT_BASE_URL,
351
+ GRANOLA_SOURCE_ID,
352
+ GranolaApiError,
353
+ GranolaClient,
354
+ NOTES_MAX_PAGE_SIZE,
355
+ createGranolaConnector,
356
+ ensureGranolaConnectorRegistered,
357
+ granolaDayWindow,
358
+ noteToConversation,
359
+ resolveGranolaApiKey,
360
+ wearableConnectorRegistration
361
+ };
362
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/client.ts","../src/normalize.ts"],"sourcesContent":["/**\n * @remnic/connector-granola — Granola meeting-notes connector.\n *\n * À-la-carte optional companion of @remnic/core: installing core alone\n * never pulls this in; core discovers it at runtime via a\n * computed-specifier dynamic import (see wearables/registry.ts) or via\n * a direct import of this module, which self-registers idempotently.\n *\n * API key: `wearables.sources.granola.apiKey`, else the\n * `REMNIC_GRANOLA_API_KEY` / `GRANOLA_API_KEY` environment variables\n * (checked in that order). Create a key under Settings → Connectors →\n * API keys in the Granola app (Business/Enterprise plans).\n */\n\nimport {\n registerWearableConnector,\n getWearableConnector,\n type WearableConnectorFactoryOptions,\n type WearableConnectorRegistration,\n type WearableConversation,\n type WearableFetchOptions,\n type WearableFetchPage,\n type WearableSourceConnector,\n} from \"@remnic/core\";\n\nimport { GranolaApiError, GranolaClient, type GranolaNote } from \"./client.js\";\nimport { GRANOLA_SOURCE_ID, granolaDayWindow, noteToConversation } from \"./normalize.js\";\n\nexport {\n GranolaClient,\n GranolaApiError,\n GRANOLA_DEFAULT_BASE_URL,\n NOTES_MAX_PAGE_SIZE,\n} from \"./client.js\";\nexport type {\n GranolaNote,\n GranolaTranscriptItem,\n GranolaSpeaker,\n GranolaCalendarEvent,\n GranolaUser,\n NotesPage,\n GranolaClientOptions,\n} from \"./client.js\";\nexport { GRANOLA_SOURCE_ID, granolaDayWindow, noteToConversation } from \"./normalize.js\";\n\nconst GRANOLA_DISPLAY_NAME = \"Granola\";\n\nexport function resolveGranolaApiKey(\n configuredKey: string | undefined,\n env: NodeJS.ProcessEnv = process.env,\n): string | undefined {\n if (typeof configuredKey === \"string\" && configuredKey.trim().length > 0) {\n return configuredKey.trim();\n }\n for (const name of [\"REMNIC_GRANOLA_API_KEY\", \"GRANOLA_API_KEY\"]) {\n const value = env[name];\n if (typeof value === \"string\" && value.trim().length > 0) {\n return value.trim();\n }\n }\n return undefined;\n}\n\nexport function createGranolaConnector(\n options: WearableConnectorFactoryOptions,\n): WearableSourceConnector {\n let client: GranolaClient | null = null;\n const getClient = (): GranolaClient => {\n if (!client) {\n client = new GranolaClient({\n apiKey: resolveGranolaApiKey(options.settings.apiKey) ?? \"\",\n baseUrl: options.settings.baseUrl,\n });\n }\n return client;\n };\n\n return {\n id: GRANOLA_SOURCE_ID,\n displayName: GRANOLA_DISPLAY_NAME,\n async verifyAuth(signal?: AbortSignal) {\n return getClient().verifyAuth(signal);\n },\n async fetchConversations(opts: WearableFetchOptions): Promise<WearableFetchPage> {\n const { createdAfter, createdBefore } = granolaDayWindow(opts.date, opts.timezone);\n const activeClient = getClient();\n const page = await activeClient.listNotes({\n createdAfter,\n createdBefore,\n cursor: opts.cursor,\n signal: opts.signal,\n });\n // The list returns summaries only; each note's transcript/summary/timing\n // needs a per-note fetch. Fetch sequentially to respect the 5 req/s rate\n // limit (the client also backs off on 429).\n const conversations: WearableConversation[] = [];\n for (const summary of page.notes) {\n let full: GranolaNote;\n try {\n full = await activeClient.getNote(summary.id, opts.signal);\n } catch (err) {\n // A note that 404s (deleted / no longer summarized) is gone, not a\n // backend outage — skip it. Any other failure is real; rethrow so it\n // reads as a source failure, not a quiet day (AGENTS.md §22).\n if (err instanceof GranolaApiError && err.status === 404) continue;\n throw err;\n }\n const conversation = noteToConversation(full);\n // Drop a record with no resolvable start rather than emit an empty\n // startIso that fails downstream parsing silently.\n if (conversation.startIso !== \"\") conversations.push(conversation);\n }\n return { conversations, nextCursor: page.nextCursor };\n },\n };\n}\n\nexport const wearableConnectorRegistration: WearableConnectorRegistration = {\n id: GRANOLA_SOURCE_ID,\n displayName: GRANOLA_DISPLAY_NAME,\n factory: createGranolaConnector,\n};\n\n/**\n * Idempotently register the connector with the core registry. Importing this\n * module registers it as a side effect; calling again is safe.\n */\nexport function ensureGranolaConnectorRegistered(): boolean {\n if (getWearableConnector(GRANOLA_SOURCE_ID)) return false;\n registerWearableConnector(wearableConnectorRegistration);\n return true;\n}\n\nensureGranolaConnectorRegistered();\n","/**\n * Minimal Granola public API client (raw fetch, no SDK).\n *\n * API verified against the official OpenAPI at\n * https://docs.granola.ai/api-reference/list-notes and\n * https://docs.granola.ai/api-reference/get-note (fetched 2026-07-21):\n * - Base URL: https://public-api.granola.ai\n * - Auth: `Authorization: Bearer grn_<key>`\n * - `GET /v1/notes?created_after=&created_before=&cursor=&page_size=` →\n * `{ notes: NoteSummary[], hasMore, cursor }` (page_size 1..30, default 10);\n * `created_after`/`created_before` accept date or date-time. The list only\n * returns notes that already have an AI summary + transcript.\n * - `GET /v1/notes/{id}?include=transcript` → full Note with `calendar_event`\n * (`scheduled_start_time`/`scheduled_end_time`), `attendees`, `summary_text`,\n * `summary_markdown`, and `transcript` items\n * `{ speaker: { source, diarization_label? }, text, start_time, end_time }`.\n * - Rate limits: 5 req/s sustained, 25 burst → 429 on excess.\n *\n * A non-2xx or network failure throws GranolaApiError (a backend failure); an\n * empty `notes` array is a real empty result, never conflated (AGENTS.md §22).\n */\n\nexport const GRANOLA_DEFAULT_BASE_URL = \"https://public-api.granola.ai\";\n\n/** Hard API maximum for `page_size` on the notes list. */\nexport const NOTES_MAX_PAGE_SIZE = 30;\n\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst MAX_RETRIES = 3;\nconst MAX_RETRY_DELAY_MS = 30_000;\n\nexport interface GranolaSpeaker {\n source?: string | null;\n diarization_label?: string | null;\n}\n\nexport interface GranolaTranscriptItem {\n speaker?: GranolaSpeaker | null;\n text?: string | null;\n start_time?: string | null;\n end_time?: string | null;\n}\n\nexport interface GranolaCalendarEvent {\n event_title?: string | null;\n organiser?: string | null;\n scheduled_start_time?: string | null;\n scheduled_end_time?: string | null;\n}\n\nexport interface GranolaUser {\n name?: string | null;\n email?: string | null;\n}\n\nexport interface GranolaNote {\n id: string;\n title?: string | null;\n created_at?: string | null;\n updated_at?: string | null;\n calendar_event?: GranolaCalendarEvent | null;\n attendees?: GranolaUser[] | null;\n summary_text?: string | null;\n summary_markdown?: string | null;\n transcript?: GranolaTranscriptItem[] | null;\n}\n\nexport interface NotesPage {\n notes: GranolaNote[];\n nextCursor: string | null;\n}\n\nexport interface GranolaClientOptions {\n apiKey: string;\n baseUrl?: string;\n fetchImpl?: typeof fetch;\n timeoutMs?: number;\n sleep?: (ms: number) => Promise<void>;\n}\n\nexport class GranolaApiError extends Error {\n constructor(\n message: string,\n readonly status?: number,\n ) {\n super(message);\n this.name = \"GranolaApiError\";\n }\n}\n\n/** Narrow unknown JSON to a plain object so field reads are actually checked. */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\nexport class GranolaClient {\n private readonly apiKey: string;\n private readonly baseUrl: string;\n private readonly fetchImpl: typeof fetch;\n private readonly timeoutMs: number;\n private readonly sleep: (ms: number) => Promise<void>;\n\n constructor(options: GranolaClientOptions) {\n if (typeof options.apiKey !== \"string\" || options.apiKey.trim().length === 0) {\n throw new GranolaApiError(\n \"Granola API key is missing. Set wearables.sources.granola.apiKey \" +\n \"or the REMNIC_GRANOLA_API_KEY / GRANOLA_API_KEY environment variable \" +\n \"(create a key under Settings → Connectors → API keys in the Granola app; \" +\n \"requires a Business/Enterprise plan).\",\n );\n }\n this.apiKey = options.apiKey.trim();\n this.baseUrl = stripTrailingSlashes(options.baseUrl ?? GRANOLA_DEFAULT_BASE_URL);\n this.fetchImpl = options.fetchImpl ?? fetch;\n this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.sleep = options.sleep ?? defaultSleep;\n }\n\n /** One page of note summaries in the half-open [createdAfter, createdBefore) window. */\n async listNotes(params: {\n createdAfter: string;\n createdBefore: string;\n cursor?: string | null;\n signal?: AbortSignal;\n }): Promise<NotesPage> {\n const search = new URLSearchParams({\n created_after: params.createdAfter,\n created_before: params.createdBefore,\n page_size: String(NOTES_MAX_PAGE_SIZE),\n });\n if (typeof params.cursor === \"string\" && params.cursor.length > 0) {\n search.set(\"cursor\", params.cursor);\n }\n const payload = await this.requestJson(`/v1/notes?${search.toString()}`, params.signal);\n const notesRaw = isRecord(payload) ? payload.notes : undefined;\n if (!Array.isArray(notesRaw)) {\n throw new GranolaApiError(\"Granola API returned an unexpected /v1/notes shape (missing notes array)\");\n }\n // `id` is required by the List Notes schema; a row missing it is a\n // backend/schema failure. Reject the page rather than silently dropping\n // rows and advancing the cursor over an incomplete day (§22).\n const notes: GranolaNote[] = [];\n for (const entry of notesRaw) {\n if (!isRecord(entry) || typeof entry.id !== \"string\") {\n throw new GranolaApiError(\"Granola API returned a note row without a string id\");\n }\n notes.push(entry as unknown as GranolaNote);\n }\n const hasMore = isRecord(payload) && payload.hasMore === true;\n const cursor = isRecord(payload) && typeof payload.cursor === \"string\" ? payload.cursor : null;\n if (hasMore && (cursor === null || cursor.length === 0)) {\n // A `hasMore: true` page with no usable cursor is a malformed pagination\n // response; fail loudly rather than silently sync a partial day (§22).\n throw new GranolaApiError(\"Granola reported hasMore=true but returned no pagination cursor\");\n }\n return { notes, nextCursor: hasMore ? cursor : null };\n }\n\n /** A single note with its transcript, calendar event, summary, and attendees. */\n async getNote(id: string, signal?: AbortSignal): Promise<GranolaNote> {\n const payload = await this.requestJson(\n `/v1/notes/${encodeURIComponent(id)}?include=transcript`,\n signal,\n );\n if (!isRecord(payload) || typeof payload.id !== \"string\") {\n throw new GranolaApiError(\"Granola API returned an unexpected note shape (missing id)\");\n }\n // Validated boundary: `id` is confirmed a string above; every other field\n // is optional and the normalizer re-checks each. Cast through `unknown`\n // because the external JSON shape can't be proven structurally here.\n return payload as unknown as GranolaNote;\n }\n\n /** Cheap auth probe. */\n async verifyAuth(signal?: AbortSignal): Promise<{ ok: boolean; detail?: string }> {\n try {\n await this.requestJson(\"/v1/notes?page_size=1\", signal);\n return { ok: true };\n } catch (err) {\n if (err instanceof GranolaApiError && (err.status === 401 || err.status === 403)) {\n return {\n ok: false,\n detail: \"Granola rejected the API key (401/403) — create a new key under Settings → Connectors → API keys\",\n };\n }\n return { ok: false, detail: err instanceof Error ? err.message : String(err) };\n }\n }\n\n private async requestJson(pathAndQuery: string, signal?: AbortSignal): Promise<unknown> {\n let lastError: unknown;\n for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {\n signal?.throwIfAborted();\n const timeoutSignal = AbortSignal.timeout(this.timeoutMs);\n const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;\n let response: Response;\n try {\n response = await this.fetchImpl(`${this.baseUrl}${pathAndQuery}`, {\n method: \"GET\",\n headers: {\n Authorization: `Bearer ${this.apiKey}`,\n Accept: \"application/json\",\n },\n signal: combined,\n });\n } catch (err) {\n if (signal?.aborted) throw err;\n lastError = err;\n if (attempt < MAX_RETRIES) {\n await this.sleep(backoffMs(attempt));\n continue;\n }\n throw new GranolaApiError(\n `Granola API request failed after ${MAX_RETRIES + 1} attempts: ${describeNetworkError(err)}`,\n );\n }\n\n if (response.status === 429 || response.status >= 500) {\n discardBody(response);\n lastError = new GranolaApiError(`Granola API responded ${response.status}`, response.status);\n if (attempt < MAX_RETRIES) {\n await this.sleep(await retryDelayMs(response, attempt));\n continue;\n }\n throw lastError;\n }\n if (!response.ok) {\n discardBody(response);\n throw new GranolaApiError(\n `Granola API responded ${response.status} for ${pathAndQuery.split(\"?\")[0]}`,\n response.status,\n );\n }\n try {\n return await response.json();\n } catch {\n throw new GranolaApiError(\"Granola API returned a non-JSON body\");\n }\n }\n throw lastError instanceof Error ? lastError : new GranolaApiError(\"Granola API request failed\");\n }\n}\n\nfunction defaultSleep(ms: number): Promise<void> {\n const { promise, resolve } = Promise.withResolvers<void>();\n setTimeout(resolve, ms);\n return promise;\n}\n\n/**\n * Release the socket back to the pool on error paths: an unconsumed response\n * body pins the undici connection (stability review note).\n */\nfunction discardBody(response: Response): void {\n void response.body?.cancel().catch(() => {});\n}\n\nfunction describeNetworkError(err: unknown): string {\n if (isRecord(err)) {\n const name = typeof err.name === \"string\" ? err.name : \"Error\";\n const code = err.code;\n return typeof code === \"string\" ? `${name} (${code})` : name;\n }\n return \"network error\";\n}\n\n/** Loop instead of `/\\/+$/` — CodeQL js/polynomial-redos on user-set URLs. */\nfunction stripTrailingSlashes(value: string): string {\n let end = value.length;\n while (end > 0 && value.charCodeAt(end - 1) === 47) end--;\n return value.slice(0, end);\n}\n\nfunction backoffMs(attempt: number): number {\n return Math.min(MAX_RETRY_DELAY_MS, 1_000 * 2 ** attempt);\n}\n\nasync function retryDelayMs(response: Response, attempt: number): Promise<number> {\n const header = response.headers.get(\"retry-after\");\n if (header) {\n const seconds = Number(header);\n if (Number.isFinite(seconds) && seconds >= 0) {\n return Math.min(MAX_RETRY_DELAY_MS, seconds * 1_000);\n }\n const when = Date.parse(header);\n if (Number.isFinite(when)) {\n return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, when - Date.now()));\n }\n }\n return backoffMs(attempt);\n}\n","/**\n * Normalize Granola notes into Remnic's provider-agnostic\n * `WearableConversation` shape, plus the timezone-aware day-window helpers the\n * Granola `created_after`/`created_before` filters need.\n *\n * Granola transcript items carry absolute `start_time`/`end_time` (ISO) and a\n * `speaker.source` of `microphone` (the wearer's own captured audio) or\n * `speaker` (other meeting audio); iOS adds a `diarization_label`\n * (`Speaker A/B/...`). The wearables speaker registry owns final naming.\n * Meeting timing prefers the linked calendar event; notes with a summary but no\n * transcript degrade to a single `note` segment.\n */\n\nimport type {\n WearableConversation,\n WearableTranscriptSegment,\n} from \"@remnic/core\";\n\nimport type { GranolaNote } from \"./client.js\";\n\nexport const GRANOLA_SOURCE_ID = \"granola\";\n\n/** \"GMT+05:30\" → \"+05:30\"; plain \"GMT\" → \"+00:00\". */\nexport function timezoneOffsetIso(instant: Date, timezone: string): string {\n try {\n const parts = new Intl.DateTimeFormat(\"en-US\", {\n timeZone: timezone,\n timeZoneName: \"longOffset\",\n }).formatToParts(instant);\n const name = parts.find((part) => part.type === \"timeZoneName\")?.value ?? \"GMT\";\n const match = name.match(/GMT([+-]\\d{2}:\\d{2})?/);\n return match?.[1] ?? \"+00:00\";\n } catch {\n return \"+00:00\";\n }\n}\n\n/** ISO instant for local midnight of `date` in `timezone`. */\nexport function zonedDayStartIso(date: string, timezone: string): string {\n let offset = timezoneOffsetIso(new Date(`${date}T12:00:00Z`), timezone);\n const candidate = new Date(`${date}T00:00:00${offset}`);\n const refined = timezoneOffsetIso(candidate, timezone);\n if (refined !== offset) {\n offset = refined;\n }\n return `${date}T00:00:00${offset}`;\n}\n\nexport function nextIsoDate(date: string): string {\n const parsed = new Date(`${date}T00:00:00Z`);\n parsed.setUTCDate(parsed.getUTCDate() + 1);\n return parsed.toISOString().slice(0, 10);\n}\n\n/**\n * Reject an invalid/unsupported IANA timezone loudly instead of silently\n * coercing to UTC and shifting every meeting's day window (AGENTS.md §39).\n */\nfunction assertValidTimezone(timezone: string): void {\n try {\n new Intl.DateTimeFormat(\"en-US\", { timeZone: timezone });\n } catch {\n throw new RangeError(\n `Invalid IANA timezone \"${timezone}\" for the Granola connector — check wearables.timezone.`,\n );\n }\n}\n\n/**\n * Half-open [createdAfter, createdBefore) UTC ISO bounds of a local day — the\n * window the Granola notes list filters on.\n */\nexport function granolaDayWindow(\n date: string,\n timezone: string,\n): { createdAfter: string; createdBefore: string } {\n assertValidTimezone(timezone);\n return {\n createdAfter: new Date(zonedDayStartIso(date, timezone)).toISOString(),\n createdBefore: new Date(zonedDayStartIso(nextIsoDate(date), timezone)).toISOString(),\n };\n}\n\nfunction trimmed(value: string | null | undefined): string | undefined {\n return typeof value === \"string\" && value.trim().length > 0 ? value.trim() : undefined;\n}\n\nfunction isoOrUndefined(value: string | null | undefined): string | undefined {\n if (typeof value !== \"string\" || value.trim().length === 0) return undefined;\n const ms = Date.parse(value);\n return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;\n}\n\nexport function noteToConversation(note: GranolaNote): WearableConversation {\n const event = note.calendar_event ?? undefined;\n const items = note.transcript ?? [];\n\n const segments: WearableTranscriptSegment[] = [];\n for (const item of items) {\n const text = trimmed(item.text);\n if (text === undefined) continue;\n const source = trimmed(item.speaker?.source);\n const label = trimmed(item.speaker?.diarization_label);\n const startIso = isoOrUndefined(item.start_time);\n const endIso = isoOrUndefined(item.end_time);\n // macOS marks the wearer's own audio as `microphone` (vs `speaker` for\n // other meeting audio). When a diarization_label is present (iOS single\n // stream) the source no longer distinguishes the wearer, so don't guess.\n const isWearer = source === \"microphone\" && label === undefined;\n segments.push({\n text,\n speakerKey: label ?? source ?? \"unknown\",\n ...(isWearer ? { isWearer: true } : {}),\n ...(startIso !== undefined ? { startIso } : {}),\n ...(endIso !== undefined ? { endIso } : {}),\n });\n }\n\n const summary = trimmed(note.summary_text) ?? trimmed(note.summary_markdown);\n\n // Note-only fallback: a summarized meeting with no transcript still anchors\n // recall for the day.\n if (segments.length === 0 && summary !== undefined) {\n segments.push({ text: summary, speakerKey: \"note\" });\n }\n\n const title = trimmed(event?.event_title) ?? trimmed(note.title);\n const startIso =\n isoOrUndefined(event?.scheduled_start_time) ??\n segments.find((segment) => segment.startIso !== undefined)?.startIso ??\n isoOrUndefined(note.created_at) ??\n \"\";\n const endIso =\n isoOrUndefined(event?.scheduled_end_time) ??\n [...segments].reverse().find((segment) => segment.endIso !== undefined)?.endIso;\n\n return {\n id: note.id,\n source: GRANOLA_SOURCE_ID,\n ...(title !== undefined ? { title } : {}),\n ...(summary !== undefined ? { summary } : {}),\n startIso,\n ...(endIso !== undefined ? { endIso } : {}),\n segments,\n };\n}\n"],"mappings":"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","names":["startIso","endIso"]}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@remnic/connector-granola",
3
+ "version": "9.15.0",
4
+ "description": "Granola meeting-notes connector for Remnic — pull, clean, and remember meeting transcripts and notes",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "provenance": true
20
+ },
21
+ "peerDependencies": {
22
+ "@remnic/core": "^9.15.0"
23
+ },
24
+ "devDependencies": {
25
+ "tsup": "^8.0.0",
26
+ "typescript": "^5.7.0",
27
+ "tsx": "^4.0.0",
28
+ "@remnic/core": "9.15.0"
29
+ },
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/joshuaswarren/remnic.git",
34
+ "directory": "packages/connector-granola"
35
+ },
36
+ "keywords": [
37
+ "remnic",
38
+ "memory",
39
+ "granola",
40
+ "meeting",
41
+ "transcript",
42
+ "wearable"
43
+ ],
44
+ "scripts": {
45
+ "build": "tsup src/index.ts --format esm --dts",
46
+ "precheck-types": "node ../../scripts/ensure-bench-build-deps.mjs",
47
+ "check-types": "tsc --noEmit",
48
+ "test": "NODE_OPTIONS=\"${NODE_OPTIONS:+$NODE_OPTIONS }--conditions=remnic-source\" tsx --test src/client.test.ts src/normalize.test.ts src/index.test.ts"
49
+ }
50
+ }