granola-toolkit 0.42.0 → 0.43.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 +1 -0
  2. package/dist/cli.js +264 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,6 +25,7 @@ granola sync
25
25
  granola sync --watch
26
26
  granola automation rules
27
27
  granola automation runs
28
+ granola search customer onboarding
28
29
  granola folder list
29
30
  granola meeting list --limit 10
30
31
  granola notes --folder Team
package/dist/cli.js CHANGED
@@ -2626,6 +2626,7 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2626
2626
  dataDirectory,
2627
2627
  exportJobsFile: join(dataDirectory, "export-jobs.json"),
2628
2628
  meetingIndexFile: join(dataDirectory, "meeting-index.json"),
2629
+ searchIndexFile: join(dataDirectory, "search-index.json"),
2629
2630
  sessionFile: join(dataDirectory, "session.json"),
2630
2631
  sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file",
2631
2632
  syncEventsFile: join(dataDirectory, "sync-events.jsonl"),
@@ -4352,6 +4353,128 @@ function buildSyncEvents(runId, occurredAt, changes, previousMeetings, nextMeeti
4352
4353
  }));
4353
4354
  }
4354
4355
  //#endregion
4356
+ //#region src/search-index.ts
4357
+ const SEARCH_INDEX_VERSION = 1;
4358
+ function cloneEntry(entry) {
4359
+ return {
4360
+ ...entry,
4361
+ folderIds: [...entry.folderIds],
4362
+ folderNames: [...entry.folderNames],
4363
+ tags: [...entry.tags]
4364
+ };
4365
+ }
4366
+ function noteText(document) {
4367
+ const notes = document.notesPlain.trim();
4368
+ if (notes) return notes;
4369
+ const panel = document.lastViewedPanel?.originalContent?.trim();
4370
+ if (panel) return panel;
4371
+ return document.content.trim();
4372
+ }
4373
+ function transcriptText(documentId, cacheData) {
4374
+ return (cacheData?.transcripts[documentId] ?? []).filter((segment) => segment.isFinal).map((segment) => segment.text.trim()).filter(Boolean).join("\n");
4375
+ }
4376
+ function buildSearchIndex(documents, options = {}) {
4377
+ return documents.map((document) => {
4378
+ const folders = options.foldersByDocumentId?.get(document.id) ?? [];
4379
+ const transcript = transcriptText(document.id, options.cacheData);
4380
+ return {
4381
+ createdAt: document.createdAt,
4382
+ folderIds: folders.map((folder) => folder.id),
4383
+ folderNames: folders.map((folder) => folder.name || folder.id),
4384
+ id: document.id,
4385
+ noteText: noteText(document),
4386
+ tags: [...document.tags],
4387
+ title: document.title,
4388
+ transcriptLoaded: transcript.length > 0,
4389
+ transcriptText: transcript,
4390
+ updatedAt: document.updatedAt
4391
+ };
4392
+ }).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
4393
+ }
4394
+ function searchFieldScore(value, term) {
4395
+ const lower = value.toLowerCase();
4396
+ if (!lower || !term) return 0;
4397
+ if (lower === term) return 8;
4398
+ if (lower.startsWith(term)) return 5;
4399
+ if (lower.includes(term)) return 3;
4400
+ return 0;
4401
+ }
4402
+ function combinedText(entry) {
4403
+ return [
4404
+ entry.id,
4405
+ entry.title,
4406
+ ...entry.tags,
4407
+ ...entry.folderNames,
4408
+ entry.noteText,
4409
+ entry.transcriptText
4410
+ ].join("\n").toLowerCase();
4411
+ }
4412
+ function searchEntryScore(entry, term) {
4413
+ const scoredFields = [
4414
+ searchFieldScore(entry.id, term) * 5,
4415
+ searchFieldScore(entry.title, term) * 8,
4416
+ ...entry.tags.map((tag) => searchFieldScore(tag, term) * 6),
4417
+ ...entry.folderNames.map((folderName) => searchFieldScore(folderName, term) * 4)
4418
+ ].filter((score) => score > 0);
4419
+ if (scoredFields.length > 0) return Math.max(...scoredFields);
4420
+ if (combinedText(entry).includes(term)) return 1;
4421
+ }
4422
+ function searchSearchIndex(entries, query) {
4423
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
4424
+ if (terms.length === 0) return [];
4425
+ return entries.map((entry) => {
4426
+ let score = 0;
4427
+ for (const term of terms) {
4428
+ const termScore = searchEntryScore(entry, term);
4429
+ if (termScore == null) return;
4430
+ score += termScore;
4431
+ }
4432
+ return {
4433
+ id: entry.id,
4434
+ score,
4435
+ updatedAt: entry.updatedAt
4436
+ };
4437
+ }).filter((entry) => Boolean(entry)).sort((left, right) => right.score - left.score || right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id)).map(({ id, score }) => ({
4438
+ id,
4439
+ score
4440
+ }));
4441
+ }
4442
+ var FileSearchIndexStore = class {
4443
+ constructor(filePath = defaultSearchIndexFilePath()) {
4444
+ this.filePath = filePath;
4445
+ }
4446
+ async readIndex() {
4447
+ try {
4448
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
4449
+ if (!parsed || parsed.version !== SEARCH_INDEX_VERSION || !Array.isArray(parsed.entries)) return [];
4450
+ return parsed.entries.map(cloneEntry);
4451
+ } catch {
4452
+ return [];
4453
+ }
4454
+ }
4455
+ async writeIndex(entries) {
4456
+ await mkdir(dirname(this.filePath), { recursive: true });
4457
+ const payload = {
4458
+ entries: entries.map(cloneEntry),
4459
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4460
+ version: SEARCH_INDEX_VERSION
4461
+ };
4462
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
4463
+ encoding: "utf8",
4464
+ mode: 384
4465
+ });
4466
+ }
4467
+ };
4468
+ function defaultSearchIndexFilePath() {
4469
+ return defaultGranolaToolkitPersistenceLayout().searchIndexFile;
4470
+ }
4471
+ function createDefaultSearchIndexStore() {
4472
+ return new FileSearchIndexStore();
4473
+ }
4474
+ function meetingIdsFromSearchResults(results) {
4475
+ return results.map((result) => result.id);
4476
+ }
4477
+ //#endregion
4355
4478
  //#region src/app/core.ts
4356
4479
  function transcriptCount(cacheData) {
4357
4480
  return Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
@@ -4477,6 +4600,7 @@ var GranolaApp = class {
4477
4600
  #granolaClient;
4478
4601
  #documents;
4479
4602
  #meetingIndex;
4603
+ #searchIndex;
4480
4604
  #listeners = /* @__PURE__ */ new Set();
4481
4605
  #refreshingMeetingIndex;
4482
4606
  #state;
@@ -4505,6 +4629,12 @@ var GranolaApp = class {
4505
4629
  runsFile: defaultAutomationRunsFilePath()
4506
4630
  };
4507
4631
  this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => cloneMeetingSummary(meeting));
4632
+ this.#searchIndex = (deps.searchIndex ?? []).map((entry) => ({
4633
+ ...entry,
4634
+ folderIds: [...entry.folderIds],
4635
+ folderNames: [...entry.folderNames],
4636
+ tags: [...entry.tags]
4637
+ }));
4508
4638
  this.#state.index = {
4509
4639
  available: this.#meetingIndex.length > 0,
4510
4640
  filePath: defaultMeetingIndexFilePath(),
@@ -4688,6 +4818,15 @@ var GranolaApp = class {
4688
4818
  if (this.deps.meetingIndexStore) await this.deps.meetingIndexStore.writeIndex(this.#meetingIndex);
4689
4819
  this.emitStateUpdate();
4690
4820
  }
4821
+ async persistSearchIndex(entries) {
4822
+ this.#searchIndex = entries.map((entry) => ({
4823
+ ...entry,
4824
+ folderIds: [...entry.folderIds],
4825
+ folderNames: [...entry.folderNames],
4826
+ tags: [...entry.tags]
4827
+ }));
4828
+ if (this.deps.searchIndexStore) await this.deps.searchIndexStore.writeIndex(this.#searchIndex);
4829
+ }
4691
4830
  async liveMeetingSnapshot(options = {}) {
4692
4831
  const cacheData = await this.loadCache({ forceRefresh: options.forceRefresh });
4693
4832
  const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
@@ -5155,6 +5294,10 @@ var GranolaApp = class {
5155
5294
  try {
5156
5295
  const snapshot = await this.liveMeetingSnapshot({ forceRefresh: options.forceRefresh ?? true });
5157
5296
  await this.persistMeetingIndex(snapshot.meetings);
5297
+ await this.persistSearchIndex(buildSearchIndex(snapshot.documents, {
5298
+ cacheData: snapshot.cacheData,
5299
+ foldersByDocumentId: this.buildFoldersByDocumentId(snapshot.folders)
5300
+ }));
5158
5301
  const { changes, summary } = diffMeetingSummaries(previousMeetings, snapshot.meetings, snapshot.folders?.length ?? 0);
5159
5302
  const completedAt = this.nowIso();
5160
5303
  const runId = this.createSyncRunId();
@@ -5285,10 +5428,34 @@ var GranolaApp = class {
5285
5428
  const summary = resolveFolderQuery((await this.loadFolders({ required: true }) ?? []).map((folder) => buildFolderSummary(folder)), query);
5286
5429
  return await this.getFolder(summary.id);
5287
5430
  }
5431
+ indexedMeetingsForSearch(options) {
5432
+ const rankedIds = meetingIdsFromSearchResults(searchSearchIndex(this.#searchIndex, options.search));
5433
+ const rankById = new Map(rankedIds.map((id, index) => [id, index]));
5434
+ return filterMeetingSummaries([...this.#meetingIndex.filter((meeting) => rankById.has(meeting.id))].sort((left, right) => {
5435
+ const leftRank = rankById.get(left.id) ?? Number.MAX_SAFE_INTEGER;
5436
+ const rightRank = rankById.get(right.id) ?? Number.MAX_SAFE_INTEGER;
5437
+ if (leftRank !== rightRank) return leftRank - rightRank;
5438
+ return right.updatedAt.localeCompare(left.updatedAt);
5439
+ }), {
5440
+ folderId: options.folderId,
5441
+ limit: options.limit,
5442
+ sort: options.sort,
5443
+ updatedFrom: options.updatedFrom,
5444
+ updatedTo: options.updatedTo
5445
+ });
5446
+ }
5288
5447
  async listMeetings(options = {}) {
5289
5448
  const preferIndex = options.preferIndex ?? (this.#state.ui.surface === "web" || this.#state.ui.surface === "server");
5290
- if (!options.forceRefresh && preferIndex && !this.#documents && this.#meetingIndex.length > 0) {
5291
- const meetings = filterMeetingSummaries(this.#meetingIndex, options);
5449
+ const canUseSearchIndex = Boolean(options.search?.trim()) && !options.forceRefresh && this.#searchIndex.length > 0;
5450
+ if (!options.forceRefresh && preferIndex && this.#meetingIndex.length > 0 && (canUseSearchIndex || !this.#documents)) {
5451
+ const meetings = canUseSearchIndex ? this.indexedMeetingsForSearch({
5452
+ folderId: options.folderId,
5453
+ limit: options.limit,
5454
+ search: options.search,
5455
+ sort: options.sort,
5456
+ updatedFrom: options.updatedFrom,
5457
+ updatedTo: options.updatedTo
5458
+ }) : filterMeetingSummaries(this.#meetingIndex, options);
5292
5459
  this.setUiState({
5293
5460
  folderSearch: void 0,
5294
5461
  meetingListSource: "index",
@@ -5367,7 +5534,14 @@ var GranolaApp = class {
5367
5534
  return bundle;
5368
5535
  }
5369
5536
  async findMeeting(query, options = {}) {
5370
- const bundle = await this.readMeetingBundleByQuery(query, options);
5537
+ let bundle;
5538
+ try {
5539
+ bundle = await this.readMeetingBundleByQuery(query, options);
5540
+ } catch (error) {
5541
+ const fallbackId = meetingIdsFromSearchResults(searchSearchIndex(this.#searchIndex, query))[0];
5542
+ if (!fallbackId) throw error;
5543
+ bundle = await this.readMeetingBundleById(fallbackId, options);
5544
+ }
5371
5545
  this.setUiState({
5372
5546
  selectedFolderId: bundle.meeting.meeting.folders[0]?.id,
5373
5547
  selectedMeetingId: bundle.document.id,
@@ -5531,6 +5705,8 @@ async function createGranolaApp(config, options = {}) {
5531
5705
  const exportJobs = await exportJobStore.readJobs();
5532
5706
  const meetingIndexStore = createDefaultMeetingIndexStore();
5533
5707
  const meetingIndex = await meetingIndexStore.readIndex();
5708
+ const searchIndexStore = createDefaultSearchIndexStore();
5709
+ const searchIndex = await searchIndexStore.readIndex();
5534
5710
  const syncEventStore = createDefaultSyncEventStore();
5535
5711
  const syncStateStore = createDefaultSyncStateStore();
5536
5712
  const syncState = await syncStateStore.readState();
@@ -5550,6 +5726,8 @@ async function createGranolaApp(config, options = {}) {
5550
5726
  meetingIndex,
5551
5727
  meetingIndexStore,
5552
5728
  now: options.now,
5729
+ searchIndex,
5730
+ searchIndexStore,
5553
5731
  syncEventStore,
5554
5732
  syncState,
5555
5733
  syncStateStore
@@ -5724,7 +5902,7 @@ Options:
5724
5902
  -h, --help Show help
5725
5903
  `;
5726
5904
  }
5727
- function resolveFormat(value) {
5905
+ function resolveFormat$1(value) {
5728
5906
  switch (value) {
5729
5907
  case void 0: return "text";
5730
5908
  case "json":
@@ -5733,7 +5911,7 @@ function resolveFormat(value) {
5733
5911
  default: throw new Error("invalid automation format: expected text, json, or yaml");
5734
5912
  }
5735
5913
  }
5736
- function parseLimit$3(value) {
5914
+ function parseLimit$4(value) {
5737
5915
  if (value === void 0) return 20;
5738
5916
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid automation limit: expected a positive integer");
5739
5917
  return Number(value);
@@ -5793,7 +5971,7 @@ const automationCommand = {
5793
5971
  name: "automation",
5794
5972
  async run({ commandArgs, commandFlags, globalFlags }) {
5795
5973
  const [action] = commandArgs;
5796
- const format = resolveFormat(commandFlags.format);
5974
+ const format = resolveFormat$1(commandFlags.format);
5797
5975
  const config = await loadConfig({
5798
5976
  globalFlags,
5799
5977
  subcommandFlags: commandFlags
@@ -5808,13 +5986,13 @@ const automationCommand = {
5808
5986
  return 0;
5809
5987
  }
5810
5988
  case "matches": {
5811
- const result = await app.listAutomationMatches({ limit: parseLimit$3(commandFlags.limit) });
5989
+ const result = await app.listAutomationMatches({ limit: parseLimit$4(commandFlags.limit) });
5812
5990
  console.log(renderMatches(result.matches, format).trimEnd());
5813
5991
  return 0;
5814
5992
  }
5815
5993
  case "runs": {
5816
5994
  const result = await app.listAutomationRuns({
5817
- limit: parseLimit$3(commandFlags.limit),
5995
+ limit: parseLimit$4(commandFlags.limit),
5818
5996
  status: parseRunStatus(commandFlags.status)
5819
5997
  });
5820
5998
  console.log(renderRuns(result.runs, format).trimEnd());
@@ -5976,7 +6154,7 @@ function resolveListFormat$1(value) {
5976
6154
  default: throw new Error("invalid exports format: expected text, json, or yaml");
5977
6155
  }
5978
6156
  }
5979
- function parseLimit$2(value) {
6157
+ function parseLimit$3(value) {
5980
6158
  if (value === void 0) return 20;
5981
6159
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid exports limit: expected a positive integer");
5982
6160
  const limit = Number(value);
@@ -6018,7 +6196,7 @@ const exportsCommand = {
6018
6196
  };
6019
6197
  async function list$2(commandFlags, globalFlags) {
6020
6198
  const format = resolveListFormat$1(commandFlags.format);
6021
- const limit = parseLimit$2(commandFlags.limit);
6199
+ const limit = parseLimit$3(commandFlags.limit);
6022
6200
  const config = await loadConfig({
6023
6201
  globalFlags,
6024
6202
  subcommandFlags: commandFlags
@@ -6079,7 +6257,7 @@ function resolveFolderListFormat(value) {
6079
6257
  function resolveFolderDetailFormat(value) {
6080
6258
  return resolveFolderListFormat(value);
6081
6259
  }
6082
- function parseLimit$1(value) {
6260
+ function parseLimit$2(value) {
6083
6261
  if (value === void 0) return 20;
6084
6262
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid folder limit: expected a positive integer");
6085
6263
  const limit = Number(value);
@@ -6113,7 +6291,7 @@ const folderCommand = {
6113
6291
  };
6114
6292
  async function list$1(commandFlags, globalFlags) {
6115
6293
  const format = resolveFolderListFormat(commandFlags.format);
6116
- const limit = parseLimit$1(commandFlags.limit);
6294
+ const limit = parseLimit$2(commandFlags.limit);
6117
6295
  const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
6118
6296
  const config = await loadConfig({
6119
6297
  globalFlags,
@@ -10182,7 +10360,7 @@ function resolveTranscriptFormat$1(value) {
10182
10360
  default: throw new Error("invalid meeting transcript format: expected text, json, yaml, or raw");
10183
10361
  }
10184
10362
  }
10185
- function parseLimit(value) {
10363
+ function parseLimit$1(value) {
10186
10364
  if (value === void 0) return 20;
10187
10365
  if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid meeting limit: expected a positive integer");
10188
10366
  const limit = Number(value);
@@ -10236,7 +10414,7 @@ const meetingCommand = {
10236
10414
  };
10237
10415
  async function list(commandFlags, globalFlags) {
10238
10416
  const format = resolveListFormat(commandFlags.format);
10239
- const limit = parseLimit(commandFlags.limit);
10417
+ const limit = parseLimit$1(commandFlags.limit);
10240
10418
  const folderQuery = typeof commandFlags.folder === "string" ? commandFlags.folder : void 0;
10241
10419
  const search = typeof commandFlags.search === "string" ? commandFlags.search : void 0;
10242
10420
  const config = await loadConfig({
@@ -10417,6 +10595,77 @@ function resolveNoteFormat(value) {
10417
10595
  }
10418
10596
  }
10419
10597
  //#endregion
10598
+ //#region src/commands/search.ts
10599
+ function searchHelp() {
10600
+ return `Granola search
10601
+
10602
+ Usage:
10603
+ granola search <query> [options]
10604
+
10605
+ Options:
10606
+ --folder <query> Filter search results to one folder id or name
10607
+ --format <value> text, json, yaml (default: text)
10608
+ --limit <n> Number of meetings to show (default: 20)
10609
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
10610
+ --supabase <path> Path to supabase.json
10611
+ --debug Enable debug logging
10612
+ --config <path> Path to .granola.toml
10613
+ -h, --help Show help
10614
+ `;
10615
+ }
10616
+ function resolveFormat(value) {
10617
+ switch (value) {
10618
+ case void 0: return "text";
10619
+ case "json":
10620
+ case "text":
10621
+ case "yaml": return value;
10622
+ default: throw new Error("invalid search format: expected text, json, or yaml");
10623
+ }
10624
+ }
10625
+ function parseLimit(value) {
10626
+ if (value === void 0) return 20;
10627
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid search limit: expected a positive integer");
10628
+ return Number(value);
10629
+ }
10630
+ const searchCommand = {
10631
+ description: "Search meetings across titles, notes, transcripts, folders, and tags",
10632
+ flags: {
10633
+ folder: { type: "string" },
10634
+ format: { type: "string" },
10635
+ help: { type: "boolean" },
10636
+ limit: { type: "string" },
10637
+ timeout: { type: "string" }
10638
+ },
10639
+ help: searchHelp,
10640
+ name: "search",
10641
+ async run({ commandArgs, commandFlags, globalFlags }) {
10642
+ const query = commandArgs.join(" ").trim();
10643
+ if (!query) {
10644
+ console.log(searchHelp());
10645
+ return 1;
10646
+ }
10647
+ const format = resolveFormat(commandFlags.format);
10648
+ const limit = parseLimit(commandFlags.limit);
10649
+ const folderQuery = typeof commandFlags.folder === "string" ? commandFlags.folder : void 0;
10650
+ const config = await loadConfig({
10651
+ globalFlags,
10652
+ subcommandFlags: commandFlags
10653
+ });
10654
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
10655
+ const app = await createGranolaApp(config);
10656
+ const folder = folderQuery ? await app.findFolder(folderQuery) : void 0;
10657
+ const result = await app.listMeetings({
10658
+ folderId: folder?.id,
10659
+ limit,
10660
+ preferIndex: true,
10661
+ search: query
10662
+ });
10663
+ console.log(result.source === "index" ? "Searched the local index" : "Search index unavailable, fell back to live meeting metadata");
10664
+ console.log(renderMeetingList(result.meetings, format).trimEnd());
10665
+ return 0;
10666
+ }
10667
+ };
10668
+ //#endregion
10420
10669
  //#region src/commands/serve.ts
10421
10670
  function serveHelp() {
10422
10671
  return `Granola serve
@@ -10771,6 +11020,7 @@ const commands = [
10771
11020
  folderCommand,
10772
11021
  meetingCommand,
10773
11022
  notesCommand,
11023
+ searchCommand,
10774
11024
  serveCommand,
10775
11025
  syncCommand,
10776
11026
  tuiCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",