granola-toolkit 0.23.0 → 0.24.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/cli.js +234 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -197,6 +197,7 @@ The initial server API includes:
197
197
  - `GET /state`
198
198
  - `GET /events` for server-sent state updates
199
199
  - `GET /meetings`
200
+ - `GET /meetings?refresh=true` to bypass the local meeting index and force a live refresh
200
201
  - `GET /meetings/resolve?q=<query>`
201
202
  - `GET /meetings/:id`
202
203
  - `GET /exports/jobs`
@@ -217,6 +218,7 @@ This is the foundation for the future `granola web` client and any attachable TU
217
218
  The initial browser client includes:
218
219
 
219
220
  - a searchable meeting list
221
+ - a fast local-index warm start for meeting browsing before live documents finish loading
220
222
  - sort and updated-date filters
221
223
  - quick open by meeting id or title
222
224
  - a focused meeting workspace with notes, transcript, metadata, and raw tabs
@@ -227,6 +229,18 @@ The initial browser client includes:
227
229
  - a recent export-jobs panel with rerun actions
228
230
  - stronger empty and error states for list/detail failures
229
231
 
232
+ ### Local Meeting Index
233
+
234
+ Interactive meeting browsing now keeps a local index of meeting summaries and metadata.
235
+
236
+ That index is used to:
237
+
238
+ - make the web meeting list available quickly on startup
239
+ - keep search, sort, and date filtering useful before every live document payload is fetched again
240
+ - refresh itself after successful live loads so the next run starts warm
241
+
242
+ The web client uses the index as a fast path and upgrades to live data automatically when the background refresh completes. The manual Refresh button bypasses the index and forces a live meeting fetch immediately.
243
+
230
244
  ### Export Jobs
231
245
 
232
246
  Exports are now tracked as jobs with:
package/dist/cli.js CHANGED
@@ -1419,6 +1419,20 @@ function compareMeetingDocumentsBySort(left, right, sort) {
1419
1419
  default: return compareMeetingDocuments(left, right);
1420
1420
  }
1421
1421
  }
1422
+ function compareMeetingSummariesByUpdated(left, right) {
1423
+ return compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
1424
+ }
1425
+ function compareMeetingSummariesByTitle(left, right) {
1426
+ return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareStrings(left.id, right.id);
1427
+ }
1428
+ function compareMeetingSummariesBySort(left, right, sort) {
1429
+ switch (sort) {
1430
+ case "title-asc": return compareMeetingSummariesByTitle(left, right);
1431
+ case "title-desc": return -compareMeetingSummariesByTitle(left, right);
1432
+ case "updated-asc": return -compareMeetingSummariesByUpdated(left, right);
1433
+ default: return compareMeetingSummariesByUpdated(left, right);
1434
+ }
1435
+ }
1422
1436
  function serialiseNote(note) {
1423
1437
  return {
1424
1438
  content: note.content,
@@ -1482,6 +1496,15 @@ function matchesMeetingSearch(document, search) {
1482
1496
  ...document.tags
1483
1497
  ].some((value) => value.toLowerCase().includes(query));
1484
1498
  }
1499
+ function matchesMeetingSummarySearch(meeting, search) {
1500
+ const query = search.trim().toLowerCase();
1501
+ if (!query) return true;
1502
+ return [
1503
+ meeting.id,
1504
+ meeting.title,
1505
+ ...meeting.tags
1506
+ ].some((value) => value.toLowerCase().includes(query));
1507
+ }
1485
1508
  function parseDateFilter(value, label) {
1486
1509
  const trimmed = value?.trim();
1487
1510
  if (!trimmed) return;
@@ -1499,6 +1522,15 @@ function matchesUpdatedRange(document, updatedFrom, updatedTo) {
1499
1522
  if (to != null && updatedAt > to) return false;
1500
1523
  return true;
1501
1524
  }
1525
+ function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1526
+ const from = parseDateFilter(updatedFrom, "updatedFrom");
1527
+ const to = parseDateFilter(updatedTo, "updatedTo");
1528
+ const updatedAt = parseTimestamp(meeting.updatedAt || meeting.createdAt);
1529
+ if (updatedAt == null) return from == null && to == null;
1530
+ if (from != null && updatedAt < from) return false;
1531
+ if (to != null && updatedAt > to) return false;
1532
+ return true;
1533
+ }
1502
1534
  function truncate(value, width) {
1503
1535
  if (value.length <= width) return value.padEnd(width);
1504
1536
  return `${value.slice(0, Math.max(0, width - 1))}…`;
@@ -1554,6 +1586,14 @@ function listMeetings(documents, options = {}) {
1554
1586
  const sort = options.sort ?? "updated-desc";
1555
1587
  return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1556
1588
  }
1589
+ function filterMeetingSummaries(meetings, options = {}) {
1590
+ const limit = options.limit ?? 20;
1591
+ const sort = options.sort ?? "updated-desc";
1592
+ return meetings.filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1593
+ ...meeting,
1594
+ tags: [...meeting.tags]
1595
+ }));
1596
+ }
1557
1597
  function resolveMeetingQuery(documents, query) {
1558
1598
  const trimmed = query.trim();
1559
1599
  if (!trimmed) throw new Error("meeting query is required");
@@ -1640,6 +1680,48 @@ function renderMeetingTranscript(document, cacheData, format = "text") {
1640
1680
  return renderTranscriptExport(transcript, format);
1641
1681
  }
1642
1682
  //#endregion
1683
+ //#region src/meeting-index.ts
1684
+ const MEETING_INDEX_VERSION = 1;
1685
+ var FileMeetingIndexStore = class {
1686
+ constructor(filePath = defaultMeetingIndexFilePath()) {
1687
+ this.filePath = filePath;
1688
+ }
1689
+ async readIndex() {
1690
+ try {
1691
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
1692
+ if (!parsed || parsed.version !== MEETING_INDEX_VERSION || !Array.isArray(parsed.meetings)) return [];
1693
+ return parsed.meetings.map((meeting) => ({
1694
+ ...meeting,
1695
+ tags: [...meeting.tags]
1696
+ }));
1697
+ } catch {
1698
+ return [];
1699
+ }
1700
+ }
1701
+ async writeIndex(meetings) {
1702
+ await mkdir(dirname(this.filePath), { recursive: true });
1703
+ const payload = {
1704
+ meetings: meetings.map((meeting) => ({
1705
+ ...meeting,
1706
+ tags: [...meeting.tags]
1707
+ })),
1708
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1709
+ version: MEETING_INDEX_VERSION
1710
+ };
1711
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
1712
+ encoding: "utf8",
1713
+ mode: 384
1714
+ });
1715
+ }
1716
+ };
1717
+ function defaultMeetingIndexFilePath() {
1718
+ const home = homedir();
1719
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "meeting-index.json") : join(home, ".config", "granola-toolkit", "meeting-index.json");
1720
+ }
1721
+ function createDefaultMeetingIndexStore() {
1722
+ return new FileMeetingIndexStore();
1723
+ }
1724
+ //#endregion
1643
1725
  //#region src/app/core.ts
1644
1726
  function transcriptCount(cacheData) {
1645
1727
  return Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
@@ -1665,6 +1747,7 @@ function cloneState(state) {
1665
1747
  notes: cloneExportState(state.exports.notes),
1666
1748
  transcripts: cloneExportState(state.exports.transcripts)
1667
1749
  },
1750
+ index: { ...state.index },
1668
1751
  ui: { ...state.ui }
1669
1752
  };
1670
1753
  }
@@ -1688,6 +1771,12 @@ function defaultState(config, auth, surface) {
1688
1771
  loaded: false
1689
1772
  },
1690
1773
  exports: { jobs: [] },
1774
+ index: {
1775
+ available: false,
1776
+ filePath: defaultMeetingIndexFilePath(),
1777
+ loaded: false,
1778
+ meetingCount: 0
1779
+ },
1691
1780
  ui: {
1692
1781
  surface,
1693
1782
  view: "idle"
@@ -1699,13 +1788,26 @@ var GranolaApp = class {
1699
1788
  #cacheResolved = false;
1700
1789
  #granolaClient;
1701
1790
  #documents;
1791
+ #meetingIndex;
1702
1792
  #listeners = /* @__PURE__ */ new Set();
1793
+ #refreshingMeetingIndex;
1703
1794
  #state;
1704
1795
  constructor(config, deps, options = {}) {
1705
1796
  this.config = config;
1706
1797
  this.deps = deps;
1707
1798
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
1708
1799
  this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
1800
+ this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => ({
1801
+ ...meeting,
1802
+ tags: [...meeting.tags]
1803
+ }));
1804
+ this.#state.index = {
1805
+ available: this.#meetingIndex.length > 0,
1806
+ filePath: defaultMeetingIndexFilePath(),
1807
+ loaded: this.#meetingIndex.length > 0,
1808
+ loadedAt: this.#meetingIndex.length > 0 ? this.nowIso() : void 0,
1809
+ meetingCount: this.#meetingIndex.length
1810
+ };
1709
1811
  }
1710
1812
  getState() {
1711
1813
  return cloneState(this.#state);
@@ -1742,6 +1844,41 @@ var GranolaApp = class {
1742
1844
  this.emitStateUpdate();
1743
1845
  return { ...auth };
1744
1846
  }
1847
+ async persistMeetingIndex(meetings) {
1848
+ this.#meetingIndex = meetings.map((meeting) => ({
1849
+ ...meeting,
1850
+ tags: [...meeting.tags]
1851
+ }));
1852
+ this.#state.index = {
1853
+ available: this.#meetingIndex.length > 0,
1854
+ filePath: this.#state.index.filePath,
1855
+ loaded: this.#meetingIndex.length > 0,
1856
+ loadedAt: this.#meetingIndex.length > 0 ? this.nowIso() : void 0,
1857
+ meetingCount: this.#meetingIndex.length
1858
+ };
1859
+ if (this.deps.meetingIndexStore) await this.deps.meetingIndexStore.writeIndex(this.#meetingIndex);
1860
+ this.emitStateUpdate();
1861
+ }
1862
+ async refreshMeetingIndexFromLiveData() {
1863
+ const cacheData = await this.loadCache();
1864
+ const documents = await this.listDocuments();
1865
+ const meetings = listMeetings(documents, {
1866
+ cacheData,
1867
+ limit: documents.length || 1,
1868
+ sort: "updated-desc"
1869
+ });
1870
+ await this.persistMeetingIndex(meetings);
1871
+ }
1872
+ triggerMeetingIndexRefresh() {
1873
+ if (this.#refreshingMeetingIndex) return;
1874
+ this.#refreshingMeetingIndex = (async () => {
1875
+ try {
1876
+ await this.refreshMeetingIndexFromLiveData();
1877
+ } catch {} finally {
1878
+ this.#refreshingMeetingIndex = void 0;
1879
+ }
1880
+ })();
1881
+ }
1745
1882
  nowIso() {
1746
1883
  return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1747
1884
  }
@@ -1875,7 +2012,16 @@ var GranolaApp = class {
1875
2012
  throw error;
1876
2013
  }
1877
2014
  }
1878
- async listDocuments() {
2015
+ async listDocuments(options = {}) {
2016
+ if (options.forceRefresh) {
2017
+ this.#granolaClient = void 0;
2018
+ this.#documents = void 0;
2019
+ this.#state.documents = {
2020
+ count: 0,
2021
+ loaded: false
2022
+ };
2023
+ this.emitStateUpdate();
2024
+ }
1879
2025
  if (this.#documents) return this.#documents;
1880
2026
  const documents = await (await this.getGranolaClient()).listDocuments({ timeoutMs: this.config.notes.timeoutMs });
1881
2027
  this.#documents = documents;
@@ -1915,15 +2061,41 @@ var GranolaApp = class {
1915
2061
  return cacheData;
1916
2062
  }
1917
2063
  async listMeetings(options = {}) {
1918
- const meetings = listMeetings(await this.listDocuments(), {
1919
- cacheData: await this.loadCache(),
2064
+ const preferIndex = options.preferIndex ?? (this.#state.ui.surface === "web" || this.#state.ui.surface === "server");
2065
+ if (!options.forceRefresh && preferIndex && !this.#documents && this.#meetingIndex.length > 0) {
2066
+ const meetings = filterMeetingSummaries(this.#meetingIndex, options);
2067
+ this.setUiState({
2068
+ meetingListSource: "index",
2069
+ meetingSearch: options.search,
2070
+ meetingSort: options.sort,
2071
+ meetingUpdatedFrom: options.updatedFrom,
2072
+ meetingUpdatedTo: options.updatedTo,
2073
+ selectedMeetingId: void 0,
2074
+ view: "meeting-list"
2075
+ });
2076
+ this.triggerMeetingIndexRefresh();
2077
+ return {
2078
+ meetings,
2079
+ source: "index"
2080
+ };
2081
+ }
2082
+ const cacheData = await this.loadCache();
2083
+ const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
2084
+ const meetings = listMeetings(documents, {
2085
+ cacheData,
1920
2086
  limit: options.limit,
1921
2087
  search: options.search,
1922
2088
  sort: options.sort,
1923
2089
  updatedFrom: options.updatedFrom,
1924
2090
  updatedTo: options.updatedTo
1925
2091
  });
2092
+ await this.persistMeetingIndex(listMeetings(documents, {
2093
+ cacheData,
2094
+ limit: Math.max(documents.length, 1),
2095
+ sort: "updated-desc"
2096
+ }));
1926
2097
  this.setUiState({
2098
+ meetingListSource: "live",
1927
2099
  meetingSearch: options.search,
1928
2100
  meetingSort: options.sort,
1929
2101
  meetingUpdatedFrom: options.updatedFrom,
@@ -1931,7 +2103,10 @@ var GranolaApp = class {
1931
2103
  selectedMeetingId: void 0,
1932
2104
  view: "meeting-list"
1933
2105
  });
1934
- return meetings;
2106
+ return {
2107
+ meetings,
2108
+ source: "live"
2109
+ };
1935
2110
  }
1936
2111
  async getMeeting(id, options = {}) {
1937
2112
  const documents = await this.listDocuments();
@@ -2076,13 +2251,17 @@ async function createGranolaApp(config, options = {}) {
2076
2251
  const auth = await inspectDefaultGranolaAuth(config);
2077
2252
  const authController = createDefaultGranolaAuthController(config);
2078
2253
  const exportJobStore = createDefaultExportJobStore();
2254
+ const exportJobs = await exportJobStore.readJobs();
2255
+ const meetingIndexStore = createDefaultMeetingIndexStore();
2079
2256
  return new GranolaApp(config, {
2080
2257
  auth,
2081
2258
  authController,
2082
2259
  cacheLoader: loadOptionalGranolaCache,
2083
2260
  createGranolaClient: async (mode) => await createDefaultGranolaRuntime(config, options.logger, { preferredMode: mode }),
2084
- exportJobs: await exportJobStore.readJobs(),
2261
+ exportJobs,
2085
2262
  exportJobStore,
2263
+ meetingIndex: await meetingIndexStore.readIndex(),
2264
+ meetingIndexStore,
2086
2265
  now: options.now
2087
2266
  }, { surface: options.surface });
2088
2267
  }
@@ -2527,12 +2706,13 @@ async function list(commandFlags, globalFlags) {
2527
2706
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2528
2707
  const app = await createGranolaApp(config);
2529
2708
  debug(config.debug, "authMode", app.getState().auth.mode);
2530
- console.log("Fetching meetings from Granola API...");
2531
- const meetings = await app.listMeetings({
2709
+ console.log("Loading meetings...");
2710
+ const result = await app.listMeetings({
2532
2711
  limit,
2533
2712
  search
2534
2713
  });
2535
- console.log(renderMeetingList(meetings, format).trimEnd());
2714
+ console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
2715
+ console.log(renderMeetingList(result.meetings, format).trimEnd());
2536
2716
  return 0;
2537
2717
  }
2538
2718
  async function view(id, commandFlags, globalFlags) {
@@ -2675,6 +2855,7 @@ const state = {
2675
2855
  selectedMeeting: null,
2676
2856
  selectedMeetingBundle: null,
2677
2857
  selectedMeetingId: null,
2858
+ meetingSource: "live",
2678
2859
  sort: "updated-desc",
2679
2860
  updatedFrom: "",
2680
2861
  updatedTo: "",
@@ -2762,6 +2943,11 @@ function renderAppState() {
2762
2943
  : appState.cache.configured
2763
2944
  ? "configured"
2764
2945
  : "not configured";
2946
+ const indexStatus = appState.index.loaded
2947
+ ? appState.index.meetingCount + " meetings"
2948
+ : appState.index.available
2949
+ ? "available"
2950
+ : "not built";
2765
2951
 
2766
2952
  els.appState.innerHTML = [
2767
2953
  '<div class="status-grid">',
@@ -2770,6 +2956,7 @@ function renderAppState() {
2770
2956
  '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
2771
2957
  '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
2772
2958
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2959
+ '<div><span class="status-label">Index</span><strong>' + escapeHtml(indexStatus) + "</strong></div>",
2773
2960
  "</div>",
2774
2961
  ].join("");
2775
2962
 
@@ -2999,7 +3186,7 @@ async function fetchJson(path, init) {
2999
3186
  return payload;
3000
3187
  }
3001
3188
 
3002
- function buildMeetingsQuery(limit = 100) {
3189
+ function buildMeetingsQuery(limit = 100, refresh = false) {
3003
3190
  const params = new URLSearchParams();
3004
3191
  params.set("limit", String(limit));
3005
3192
  params.set("sort", state.sort);
@@ -3016,16 +3203,22 @@ function buildMeetingsQuery(limit = 100) {
3016
3203
  params.set("updatedTo", state.updatedTo);
3017
3204
  }
3018
3205
 
3206
+ if (refresh) {
3207
+ params.set("refresh", "true");
3208
+ }
3209
+
3019
3210
  return "?" + params.toString();
3020
3211
  }
3021
3212
 
3022
3213
  async function loadMeetings(options = {}) {
3023
3214
  const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
3215
+ const refresh = options.refresh === true;
3024
3216
 
3025
3217
  try {
3026
3218
  state.listError = "";
3027
- const payload = await fetchJson("/meetings" + buildMeetingsQuery());
3219
+ const payload = await fetchJson("/meetings" + buildMeetingsQuery(100, refresh));
3028
3220
  state.meetings = payload.meetings || [];
3221
+ state.meetingSource = payload.source || "live";
3029
3222
 
3030
3223
  if (preferredMeetingId && state.meetings.some((meeting) => meeting.id === preferredMeetingId)) {
3031
3224
  state.selectedMeetingId = preferredMeetingId;
@@ -3094,15 +3287,19 @@ async function quickOpenMeeting() {
3094
3287
  }
3095
3288
  }
3096
3289
 
3097
- async function refreshAll() {
3290
+ async function refreshAll(forceLiveMeetings = false) {
3098
3291
  setStatus("Refreshing…", "busy");
3099
- const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status"), loadMeetings()]);
3292
+ const [appState, authState] = await Promise.all([
3293
+ fetchJson("/state"),
3294
+ fetchJson("/auth/status"),
3295
+ loadMeetings({ refresh: forceLiveMeetings }),
3296
+ ]);
3100
3297
  state.appState = {
3101
3298
  ...appState,
3102
3299
  auth: authState,
3103
3300
  };
3104
3301
  renderAppState();
3105
- setStatus("Connected", "ok");
3302
+ setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "Connected", "ok");
3106
3303
  }
3107
3304
 
3108
3305
  async function syncAuthState() {
@@ -3258,7 +3455,7 @@ els.authPanel.addEventListener("click", (event) => {
3258
3455
  });
3259
3456
 
3260
3457
  els.refreshButton.addEventListener("click", () => {
3261
- void refreshAll();
3458
+ void refreshAll(true);
3262
3459
  });
3263
3460
 
3264
3461
  els.noteButton.addEventListener("click", () => {
@@ -3383,9 +3580,20 @@ document.addEventListener("keydown", (event) => {
3383
3580
 
3384
3581
  const events = new EventSource("/events");
3385
3582
  events.addEventListener("state.updated", (event) => {
3583
+ const previousLoadedAt = state.appState?.documents?.loadedAt;
3386
3584
  const payload = JSON.parse(event.data);
3387
3585
  state.appState = payload.state;
3388
3586
  renderAppState();
3587
+
3588
+ if (
3589
+ state.meetingSource === "index" &&
3590
+ payload.state.documents?.loadedAt &&
3591
+ payload.state.documents.loadedAt !== previousLoadedAt
3592
+ ) {
3593
+ void loadMeetings({
3594
+ preferredMeetingId: state.selectedMeetingId,
3595
+ });
3596
+ }
3389
3597
  });
3390
3598
  events.addEventListener("error", () => {
3391
3599
  setStatus("Disconnected", "error");
@@ -4115,19 +4323,24 @@ async function startGranolaServer(app, options = {}) {
4115
4323
  }
4116
4324
  if (method === "GET" && path === "/meetings") {
4117
4325
  const limit = parseInteger(url.searchParams.get("limit"));
4326
+ const refresh = url.searchParams.get("refresh") === "true";
4118
4327
  const search = url.searchParams.get("search")?.trim() || void 0;
4119
4328
  const sort = parseMeetingSort(url.searchParams.get("sort"));
4120
4329
  const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
4121
4330
  const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
4331
+ const result = await app.listMeetings({
4332
+ forceRefresh: refresh,
4333
+ limit,
4334
+ search,
4335
+ sort,
4336
+ updatedFrom,
4337
+ updatedTo
4338
+ });
4122
4339
  sendJson(response, {
4123
- meetings: await app.listMeetings({
4124
- limit,
4125
- search,
4126
- sort,
4127
- updatedFrom,
4128
- updatedTo
4129
- }),
4340
+ meetings: result.meetings,
4341
+ refresh,
4130
4342
  search,
4343
+ source: result.source,
4131
4344
  sort,
4132
4345
  updatedFrom,
4133
4346
  updatedTo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",