granola-toolkit 0.35.0 → 0.37.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 +301 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,6 +22,7 @@ The published package exposes both `granola` and `granola-toolkit` as executable
22
22
  ```bash
23
23
  granola auth login
24
24
  granola sync
25
+ granola sync --watch
25
26
  granola folder list
26
27
  granola meeting list --limit 10
27
28
  granola notes --folder Team
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { appendFile, mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { existsSync } from "node:fs";
7
7
  import { homedir, platform } from "node:os";
@@ -30,6 +30,7 @@ const granolaTransportPaths = {
30
30
  root: "/",
31
31
  serverInfo: "/server/info",
32
32
  syncRun: "/sync",
33
+ syncEvents: "/sync/events",
33
34
  state: "/state"
34
35
  };
35
36
  function appendSearchParams(path, params) {
@@ -181,6 +182,10 @@ var GranolaServerClient = class GranolaServerClient {
181
182
  async inspectSync() {
182
183
  return cloneValue(this.#state.sync);
183
184
  }
185
+ async listSyncEvents(options = {}) {
186
+ const path = options.limit ? `${granolaTransportPaths.syncEvents}?limit=${encodeURIComponent(String(options.limit))}` : granolaTransportPaths.syncEvents;
187
+ return await this.requestJson(path);
188
+ }
184
189
  async loginAuth(options = {}) {
185
190
  return await this.requestJson(granolaTransportPaths.authLogin, {
186
191
  body: JSON.stringify(options),
@@ -1378,7 +1383,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1378
1383
  }
1379
1384
  }
1380
1385
  function buildGranolaTuiSummary(state, meetingSource) {
1381
- return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
1386
+ return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | ${state.sync.running ? "sync running" : state.sync.lastError ? "sync error" : state.sync.lastCompletedAt ? `sync ${state.sync.lastCompletedAt.slice(11, 16)}` : "sync idle"} | list ${meetingSource}`;
1382
1387
  }
1383
1388
  //#endregion
1384
1389
  //#region src/tui/theme.ts
@@ -1849,6 +1854,10 @@ var GranolaTuiWorkspace = class {
1849
1854
  }
1850
1855
  async refresh(forceRefresh) {
1851
1856
  try {
1857
+ if (forceRefresh) {
1858
+ this.setStatus("Syncing…");
1859
+ await this.app.sync();
1860
+ }
1852
1861
  await this.loadFolders({
1853
1862
  forceRefresh,
1854
1863
  setStatus: false
@@ -1858,7 +1867,9 @@ var GranolaTuiWorkspace = class {
1858
1867
  preferredMeetingId: this.#selectedMeetingId
1859
1868
  });
1860
1869
  if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1861
- } catch {}
1870
+ } catch (error) {
1871
+ if (error instanceof Error && error.message) this.setStatus(error.message, "error");
1872
+ }
1862
1873
  }
1863
1874
  async moveMeetingSelection(delta) {
1864
1875
  if (this.#meetings.length === 0) return;
@@ -2245,7 +2256,7 @@ var GranolaTuiWorkspace = class {
2245
2256
  const bodyLines = [];
2246
2257
  for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
2247
2258
  const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
2248
- const footerHints = padLine(granolaTuiTheme.dim("h/l or Tab pane j/k move / quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2259
+ const footerHints = padLine(granolaTuiTheme.dim("h/l or Tab pane j/k move / quick open a auth r sync 1-4 tabs PgUp/PgDn scroll q quit"), width);
2249
2260
  return [
2250
2261
  headerTitle,
2251
2262
  headerSummary,
@@ -2263,7 +2274,7 @@ async function runGranolaTui(app, options = {}) {
2263
2274
  onExit: () => {
2264
2275
  workspace.dispose();
2265
2276
  tui.stop();
2266
- Promise.resolve(app.close?.()).catch(() => {}).finally(() => {
2277
+ Promise.resolve(options.onClose?.()).then(() => Promise.resolve(app.close?.())).catch(() => {}).finally(() => {
2267
2278
  resolve(0);
2268
2279
  });
2269
2280
  }
@@ -2273,7 +2284,7 @@ async function runGranolaTui(app, options = {}) {
2273
2284
  await workspace.initialise();
2274
2285
  } catch (error) {
2275
2286
  workspace.dispose();
2276
- await Promise.resolve(app.close?.()).catch(() => {});
2287
+ await Promise.resolve(options.onClose?.()).then(() => Promise.resolve(app.close?.())).catch(() => {});
2277
2288
  reject(error);
2278
2289
  return;
2279
2290
  }
@@ -2382,6 +2393,7 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2382
2393
  meetingIndexFile: join(dataDirectory, "meeting-index.json"),
2383
2394
  sessionFile: join(dataDirectory, "session.json"),
2384
2395
  sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file",
2396
+ syncEventsFile: join(dataDirectory, "sync-events.jsonl"),
2385
2397
  syncStateFile: join(dataDirectory, "sync-state.json")
2386
2398
  };
2387
2399
  }
@@ -3275,14 +3287,17 @@ function cloneSyncSummary(summary) {
3275
3287
  }
3276
3288
  function normaliseSyncState(filePath, file) {
3277
3289
  return {
3290
+ eventCount: file?.eventCount ?? 0,
3291
+ eventsFile: file?.eventsFile ?? defaultSyncEventsFilePath$1(),
3278
3292
  filePath,
3279
3293
  lastChanges: (file?.lastChanges ?? []).slice(0, MAX_STORED_CHANGES).map(cloneSyncChange$1),
3280
- lastCompletedAt: file?.lastCompletedAt,
3281
- lastError: file?.lastError,
3282
- lastFailedAt: file?.lastFailedAt,
3283
- lastStartedAt: file?.lastStartedAt,
3284
3294
  running: false,
3285
- summary: cloneSyncSummary(file?.summary)
3295
+ ...file?.lastCompletedAt ? { lastCompletedAt: file.lastCompletedAt } : {},
3296
+ ...file?.lastError ? { lastError: file.lastError } : {},
3297
+ ...file?.lastFailedAt ? { lastFailedAt: file.lastFailedAt } : {},
3298
+ ...file?.lastRunId ? { lastRunId: file.lastRunId } : {},
3299
+ ...file?.lastStartedAt ? { lastStartedAt: file.lastStartedAt } : {},
3300
+ ...file?.summary ? { summary: cloneSyncSummary(file.summary) } : {}
3286
3301
  };
3287
3302
  }
3288
3303
  var FileSyncStateStore = class {
@@ -3301,10 +3316,13 @@ var FileSyncStateStore = class {
3301
3316
  async writeState(state) {
3302
3317
  await mkdir(dirname(this.filePath), { recursive: true });
3303
3318
  const payload = {
3319
+ eventCount: state.eventCount,
3320
+ eventsFile: state.eventsFile,
3304
3321
  lastChanges: state.lastChanges.slice(0, MAX_STORED_CHANGES).map(cloneSyncChange$1),
3305
3322
  lastCompletedAt: state.lastCompletedAt,
3306
3323
  lastError: state.lastError,
3307
3324
  lastFailedAt: state.lastFailedAt,
3325
+ lastRunId: state.lastRunId,
3308
3326
  lastStartedAt: state.lastStartedAt,
3309
3327
  summary: cloneSyncSummary(state.summary),
3310
3328
  version: SYNC_STATE_VERSION
@@ -3318,10 +3336,45 @@ var FileSyncStateStore = class {
3318
3336
  function defaultSyncStateFilePath() {
3319
3337
  return defaultGranolaToolkitPersistenceLayout().syncStateFile;
3320
3338
  }
3339
+ function defaultSyncEventsFilePath$1() {
3340
+ return defaultGranolaToolkitPersistenceLayout().syncEventsFile;
3341
+ }
3321
3342
  function createDefaultSyncStateStore() {
3322
3343
  return new FileSyncStateStore();
3323
3344
  }
3324
3345
  //#endregion
3346
+ //#region src/sync-events.ts
3347
+ function cloneSyncEvent$1(event) {
3348
+ return { ...event };
3349
+ }
3350
+ var FileSyncEventStore = class {
3351
+ constructor(filePath = defaultSyncEventsFilePath()) {
3352
+ this.filePath = filePath;
3353
+ }
3354
+ async appendEvents(events) {
3355
+ if (events.length === 0) return;
3356
+ await mkdir(dirname(this.filePath), { recursive: true });
3357
+ const payload = events.map((event) => JSON.stringify(event)).join("\n");
3358
+ await appendFile(this.filePath, `${payload}\n`, {
3359
+ encoding: "utf8",
3360
+ mode: 384
3361
+ });
3362
+ }
3363
+ async readEvents(limit = 50) {
3364
+ try {
3365
+ return (await readFile(this.filePath, "utf8")).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => parseJsonString(line)).filter((event) => Boolean(event)).map(cloneSyncEvent$1).slice(-limit).reverse();
3366
+ } catch {
3367
+ return [];
3368
+ }
3369
+ }
3370
+ };
3371
+ function defaultSyncEventsFilePath() {
3372
+ return defaultGranolaToolkitPersistenceLayout().syncEventsFile;
3373
+ }
3374
+ function createDefaultSyncEventStore() {
3375
+ return new FileSyncEventStore();
3376
+ }
3377
+ //#endregion
3325
3378
  //#region src/sync.ts
3326
3379
  function normaliseMeeting(meeting) {
3327
3380
  return {
@@ -3419,6 +3472,18 @@ function diffMeetingSummaries(previous, next, folderCount) {
3419
3472
  }
3420
3473
  };
3421
3474
  }
3475
+ function buildSyncEvents(runId, occurredAt, changes) {
3476
+ return changes.map((change, index) => ({
3477
+ id: `${runId}:${index + 1}`,
3478
+ kind: change.kind === "created" ? "meeting.created" : change.kind === "changed" ? "meeting.changed" : change.kind === "removed" ? "meeting.removed" : "transcript.ready",
3479
+ meetingId: change.meetingId,
3480
+ occurredAt,
3481
+ previousUpdatedAt: change.previousUpdatedAt,
3482
+ runId,
3483
+ title: change.title,
3484
+ updatedAt: change.updatedAt
3485
+ }));
3486
+ }
3422
3487
  //#endregion
3423
3488
  //#region src/app/core.ts
3424
3489
  function transcriptCount(cacheData) {
@@ -3449,6 +3514,9 @@ function cloneSyncState(state) {
3449
3514
  summary: state.summary ? { ...state.summary } : void 0
3450
3515
  };
3451
3516
  }
3517
+ function cloneSyncEvent(event) {
3518
+ return { ...event };
3519
+ }
3452
3520
  function cloneMeetingSummary(meeting) {
3453
3521
  return {
3454
3522
  ...meeting,
@@ -3508,6 +3576,8 @@ function defaultState(config, auth, surface) {
3508
3576
  meetingCount: 0
3509
3577
  },
3510
3578
  sync: {
3579
+ eventCount: 0,
3580
+ eventsFile: defaultSyncEventsFilePath$1(),
3511
3581
  filePath: defaultSyncStateFilePath(),
3512
3582
  lastChanges: [],
3513
3583
  running: false
@@ -3544,6 +3614,8 @@ var GranolaApp = class {
3544
3614
  this.#state.sync = {
3545
3615
  ...this.#state.sync,
3546
3616
  ...cloneSyncState(deps.syncState ?? {
3617
+ eventCount: 0,
3618
+ eventsFile: defaultSyncEventsFilePath$1(),
3547
3619
  filePath: defaultSyncStateFilePath(),
3548
3620
  lastChanges: [],
3549
3621
  running: false
@@ -3603,6 +3675,9 @@ var GranolaApp = class {
3603
3675
  if (!this.deps.syncStateStore) return;
3604
3676
  await this.deps.syncStateStore.writeState(this.#state.sync);
3605
3677
  }
3678
+ createSyncRunId() {
3679
+ return `sync-${this.nowIso().replaceAll(/[-:.]/g, "").replace("T", "").replace("Z", "")}`;
3680
+ }
3606
3681
  applyAuthState(auth, options = {}) {
3607
3682
  if (options.resetDocuments) this.resetRemoteState();
3608
3683
  this.#state.auth = { ...auth };
@@ -3787,6 +3862,10 @@ var GranolaApp = class {
3787
3862
  async inspectSync() {
3788
3863
  return cloneSyncState(this.#state.sync);
3789
3864
  }
3865
+ async listSyncEvents(options = {}) {
3866
+ if (!this.deps.syncEventStore) return { events: [] };
3867
+ return { events: (await this.deps.syncEventStore.readEvents(options.limit)).map(cloneSyncEvent) };
3868
+ }
3790
3869
  async loginAuth(options = {}) {
3791
3870
  const controller = this.requireAuthController();
3792
3871
  try {
@@ -3850,11 +3929,17 @@ var GranolaApp = class {
3850
3929
  const snapshot = await this.liveMeetingSnapshot({ forceRefresh: options.forceRefresh ?? true });
3851
3930
  await this.persistMeetingIndex(snapshot.meetings);
3852
3931
  const { changes, summary } = diffMeetingSummaries(previousMeetings, snapshot.meetings, snapshot.folders?.length ?? 0);
3932
+ const completedAt = this.nowIso();
3933
+ const runId = this.createSyncRunId();
3934
+ const events = buildSyncEvents(runId, completedAt, changes);
3935
+ if (events.length > 0 && this.deps.syncEventStore) await this.deps.syncEventStore.appendEvents(events);
3853
3936
  this.#state.sync = {
3854
3937
  ...this.#state.sync,
3938
+ eventCount: this.#state.sync.eventCount + events.length,
3855
3939
  lastChanges: changes.slice(0, 50).map(cloneSyncChange),
3856
- lastCompletedAt: this.nowIso(),
3940
+ lastCompletedAt: completedAt,
3857
3941
  lastError: void 0,
3942
+ lastRunId: runId,
3858
3943
  running: false,
3859
3944
  summary: { ...summary }
3860
3945
  };
@@ -3880,7 +3965,7 @@ var GranolaApp = class {
3880
3965
  async sync(options = {}) {
3881
3966
  return await this.runSync({
3882
3967
  forceRefresh: options.forceRefresh,
3883
- foreground: true
3968
+ foreground: options.foreground ?? true
3884
3969
  });
3885
3970
  }
3886
3971
  async listDocuments(options = {}) {
@@ -4203,6 +4288,7 @@ async function createGranolaApp(config, options = {}) {
4203
4288
  const exportJobs = await exportJobStore.readJobs();
4204
4289
  const meetingIndexStore = createDefaultMeetingIndexStore();
4205
4290
  const meetingIndex = await meetingIndexStore.readIndex();
4291
+ const syncEventStore = createDefaultSyncEventStore();
4206
4292
  const syncStateStore = createDefaultSyncStateStore();
4207
4293
  const syncState = await syncStateStore.readState();
4208
4294
  return new GranolaApp(config, {
@@ -4215,6 +4301,7 @@ async function createGranolaApp(config, options = {}) {
4215
4301
  meetingIndex,
4216
4302
  meetingIndexStore,
4217
4303
  now: options.now,
4304
+ syncEventStore,
4218
4305
  syncState,
4219
4306
  syncStateStore
4220
4307
  }, { surface: options.surface });
@@ -4325,6 +4412,14 @@ function parseTrustedOrigins(value) {
4325
4412
  if (typeof value !== "string" || !value.trim()) return [];
4326
4413
  return value.split(",").map((origin) => origin.trim()).filter(Boolean);
4327
4414
  }
4415
+ function parseSyncInterval(value, fallbackMs = 6e4) {
4416
+ if (value === void 0) return fallbackMs;
4417
+ if (typeof value !== "string" || !value.trim()) throw new Error("invalid sync interval: expected a duration like 60s or 5m");
4418
+ return parseDuration(value);
4419
+ }
4420
+ function syncEnabled(commandFlags) {
4421
+ return commandFlags["no-sync"] !== true;
4422
+ }
4328
4423
  async function waitForShutdown(close) {
4329
4424
  await new Promise((resolve, reject) => {
4330
4425
  let closing = false;
@@ -4903,12 +4998,20 @@ function renderAppState() {
4903
4998
  const folderStatus = appState.folders.loaded
4904
4999
  ? appState.folders.count + " folders"
4905
5000
  : "not loaded";
5001
+ const syncStatus = appState.sync.running
5002
+ ? "running"
5003
+ : appState.sync.lastError
5004
+ ? "error"
5005
+ : appState.sync.lastCompletedAt
5006
+ ? "last " + appState.sync.lastCompletedAt.slice(11, 19)
5007
+ : "idle";
4906
5008
 
4907
5009
  els.appState.innerHTML = [
4908
5010
  '<div class="status-grid">',
4909
5011
  '<div><span class="status-label">Surface</span><strong>' + escapeHtml(appState.ui.surface) + "</strong></div>",
4910
5012
  '<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
4911
5013
  '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
5014
+ '<div><span class="status-label">Sync</span><strong>' + escapeHtml(syncStatus) + "</strong></div>",
4912
5015
  '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
4913
5016
  '<div><span class="status-label">Folders</span><strong>' + escapeHtml(folderStatus) + "</strong></div>",
4914
5017
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
@@ -5055,7 +5158,7 @@ function renderMeetingList() {
5055
5158
  });
5056
5159
  const message = filterSummary
5057
5160
  ? "No meetings match " + filterSummary + "."
5058
- : "No meetings yet. Try Refresh.";
5161
+ : "No meetings yet. Try Sync now.";
5059
5162
  els.list.innerHTML = '<div class="meeting-empty">' + escapeHtml(message) + "</div>";
5060
5163
  renderMeetingDetail();
5061
5164
  return;
@@ -5334,8 +5437,16 @@ async function quickOpenMeeting() {
5334
5437
  }
5335
5438
 
5336
5439
  async function refreshAll(forceLiveMeetings = false) {
5337
- setStatus("Refreshing…", "busy");
5440
+ setStatus(forceLiveMeetings ? "Syncing…" : "Refreshing…", "busy");
5338
5441
  try {
5442
+ if (forceLiveMeetings) {
5443
+ await fetchJson("/sync", {
5444
+ body: JSON.stringify({ forceRefresh: true }),
5445
+ headers: { "content-type": "application/json" },
5446
+ method: "POST",
5447
+ });
5448
+ }
5449
+
5339
5450
  await loadFolders({ refresh: forceLiveMeetings });
5340
5451
  const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
5341
5452
  await loadMeetings({ refresh: forceLiveMeetings });
@@ -5345,7 +5456,14 @@ async function refreshAll(forceLiveMeetings = false) {
5345
5456
  auth: authState,
5346
5457
  };
5347
5458
  renderAppState();
5348
- setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "Connected", "ok");
5459
+ setStatus(
5460
+ forceLiveMeetings
5461
+ ? "Sync complete"
5462
+ : state.meetingSource === "index"
5463
+ ? "Loaded from index"
5464
+ : "Connected",
5465
+ "ok",
5466
+ );
5349
5467
  } catch (error) {
5350
5468
  if (error.authRequired) {
5351
5469
  setStatus("Server locked", "error");
@@ -5790,7 +5908,7 @@ const granolaWebMarkup = String.raw`
5790
5908
  </section>
5791
5909
  <section class="toolbar">
5792
5910
  <div class="toolbar-actions">
5793
- <button class="button button--primary" data-refresh>Refresh</button>
5911
+ <button class="button button--primary" data-refresh>Sync now</button>
5794
5912
  <button class="button button--secondary" data-export-notes>Export Notes</button>
5795
5913
  <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
5796
5914
  </div>
@@ -6566,6 +6684,7 @@ async function startGranolaServer(app, options = {}) {
6566
6684
  exportJobs: true,
6567
6685
  meetingIndex: true,
6568
6686
  sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind,
6687
+ syncEvents: true,
6569
6688
  syncState: true
6570
6689
  },
6571
6690
  product: "granola-toolkit",
@@ -6664,7 +6783,14 @@ async function startGranolaServer(app, options = {}) {
6664
6783
  }
6665
6784
  if (method === "POST" && path === granolaTransportPaths.syncRun) {
6666
6785
  const body = await readJsonBody(request);
6667
- sendJson(response, await app.sync({ forceRefresh: typeof body.forceRefresh === "boolean" ? body.forceRefresh : void 0 }), { headers: originHeaders });
6786
+ sendJson(response, await app.sync({
6787
+ foreground: typeof body.foreground === "boolean" ? body.foreground : void 0,
6788
+ forceRefresh: typeof body.forceRefresh === "boolean" ? body.forceRefresh : void 0
6789
+ }), { headers: originHeaders });
6790
+ return;
6791
+ }
6792
+ if (method === "GET" && path === granolaTransportPaths.syncEvents) {
6793
+ sendJson(response, await app.listSyncEvents({ limit: parseInteger(url.searchParams.get("limit")) ?? 20 }), { headers: originHeaders });
6668
6794
  return;
6669
6795
  }
6670
6796
  if (method === "POST" && path === granolaTransportPaths.authLock) {
@@ -6851,6 +6977,59 @@ async function startGranolaServer(app, options = {}) {
6851
6977
  };
6852
6978
  }
6853
6979
  //#endregion
6980
+ //#region src/sync-loop.ts
6981
+ function createGranolaSyncLoop(options) {
6982
+ const clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout;
6983
+ const setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
6984
+ let inFlight;
6985
+ let stopped = true;
6986
+ let timer;
6987
+ const schedule = () => {
6988
+ if (stopped) return;
6989
+ timer = setTimeoutImpl(() => {
6990
+ runCycle();
6991
+ }, options.intervalMs);
6992
+ };
6993
+ const runCycle = async () => {
6994
+ if (stopped || inFlight) return;
6995
+ inFlight = (async () => {
6996
+ try {
6997
+ const result = await options.app.sync({
6998
+ forceRefresh: true,
6999
+ foreground: false
7000
+ });
7001
+ await options.onSynced?.(result);
7002
+ } catch (error) {
7003
+ options.logger?.warn?.(`background sync failed: ${error instanceof Error ? error.message : String(error)}`);
7004
+ await options.onError?.(error);
7005
+ } finally {
7006
+ inFlight = void 0;
7007
+ schedule();
7008
+ }
7009
+ })();
7010
+ await inFlight;
7011
+ };
7012
+ return {
7013
+ start(loopOptions = {}) {
7014
+ if (!stopped) return;
7015
+ stopped = false;
7016
+ if (loopOptions.immediate === false) {
7017
+ schedule();
7018
+ return;
7019
+ }
7020
+ runCycle();
7021
+ },
7022
+ async stop() {
7023
+ stopped = true;
7024
+ if (timer !== void 0) {
7025
+ clearTimeoutImpl(timer);
7026
+ timer = void 0;
7027
+ }
7028
+ await inFlight;
7029
+ }
7030
+ };
7031
+ }
7032
+ //#endregion
6854
7033
  //#region src/web-url.ts
6855
7034
  function buildGranolaMeetingUrl(baseUrl, meetingId) {
6856
7035
  const url = new URL(baseUrl);
@@ -6869,6 +7048,8 @@ function resolveGranolaWebWorkspaceOptions(commandFlags) {
6869
7048
  openBrowser: commandFlags.open !== false,
6870
7049
  password: typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password.trim() : void 0,
6871
7050
  port,
7051
+ syncEnabled: syncEnabled(commandFlags),
7052
+ syncIntervalMs: parseSyncInterval(commandFlags["sync-interval"]),
6872
7053
  trustedOrigins: parseTrustedOrigins(commandFlags["trusted-origins"])
6873
7054
  };
6874
7055
  }
@@ -6896,6 +7077,7 @@ function printWebRoutes() {
6896
7077
  console.log(" POST /exports/notes");
6897
7078
  console.log(" POST /exports/jobs/:id/rerun");
6898
7079
  console.log(" POST /exports/transcripts");
7080
+ console.log(" GET /sync/events");
6899
7081
  console.log(" POST /sync");
6900
7082
  }
6901
7083
  async function runGranolaWebWorkspace(app, options) {
@@ -6908,6 +7090,12 @@ async function runGranolaWebWorkspace(app, options) {
6908
7090
  trustedOrigins: options.trustedOrigins
6909
7091
  }
6910
7092
  });
7093
+ const syncLoop = options.syncEnabled ? createGranolaSyncLoop({
7094
+ app,
7095
+ intervalMs: options.syncIntervalMs,
7096
+ logger: console
7097
+ }) : void 0;
7098
+ syncLoop?.start();
6911
7099
  const targetUrl = options.targetMeetingId ? buildGranolaMeetingUrl(server.url, options.targetMeetingId) : new URL(server.url);
6912
7100
  console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
6913
7101
  if (targetUrl.href !== server.url.href) console.log(`Focused meeting URL: ${targetUrl.href}`);
@@ -6915,6 +7103,7 @@ async function runGranolaWebWorkspace(app, options) {
6915
7103
  if (options.password) console.log("Server password protection: enabled");
6916
7104
  else if (options.networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
6917
7105
  if (options.trustedOrigins.length > 0) console.log(`Trusted origins: ${options.trustedOrigins.join(", ")}`);
7106
+ console.log(options.syncEnabled ? `Background sync: enabled (${options.syncIntervalMs}ms)` : "Background sync: disabled");
6918
7107
  printWebRoutes();
6919
7108
  console.log(`Attach: granola attach ${server.url.href}`);
6920
7109
  if (options.password) console.log("Attach password: add --password <value>");
@@ -6925,7 +7114,10 @@ async function runGranolaWebWorkspace(app, options) {
6925
7114
  console.error(`failed to open browser automatically: ${message}`);
6926
7115
  console.error(`open ${targetUrl.href} manually`);
6927
7116
  }
6928
- await waitForShutdown(async () => await server.close());
7117
+ await waitForShutdown(async () => {
7118
+ await syncLoop?.stop();
7119
+ await server.close();
7120
+ });
6929
7121
  return 0;
6930
7122
  }
6931
7123
  //#endregion
@@ -7256,6 +7448,8 @@ Options:
7256
7448
  --hostname <value> Hostname to bind (overrides network default)
7257
7449
  --port <value> Port to bind (default: 0 for any available port)
7258
7450
  --password <value> Optional server password for API and browser access
7451
+ --sync-interval <value> Background sync interval, e.g. 60s or 5m (default: 60s)
7452
+ --no-sync Disable the background sync loop
7259
7453
  --trusted-origins <v> Comma-separated extra browser origins to trust
7260
7454
  --cache <path> Path to Granola cache JSON
7261
7455
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
@@ -7272,8 +7466,10 @@ const serveCommand = {
7272
7466
  help: { type: "boolean" },
7273
7467
  hostname: { type: "string" },
7274
7468
  network: { type: "string" },
7469
+ "no-sync": { type: "boolean" },
7275
7470
  password: { type: "string" },
7276
7471
  port: { type: "string" },
7472
+ "sync-interval": { type: "string" },
7277
7473
  timeout: { type: "string" },
7278
7474
  "trusted-origins": { type: "string" }
7279
7475
  },
@@ -7293,6 +7489,8 @@ const serveCommand = {
7293
7489
  const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
7294
7490
  const port = parsePort(commandFlags.port);
7295
7491
  const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
7492
+ const backgroundSyncEnabled = syncEnabled(commandFlags);
7493
+ const syncIntervalMs = parseSyncInterval(commandFlags["sync-interval"]);
7296
7494
  const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
7297
7495
  const server = await startGranolaServer(app, {
7298
7496
  hostname,
@@ -7302,11 +7500,18 @@ const serveCommand = {
7302
7500
  trustedOrigins
7303
7501
  }
7304
7502
  });
7503
+ const syncLoop = backgroundSyncEnabled ? createGranolaSyncLoop({
7504
+ app,
7505
+ intervalMs: syncIntervalMs,
7506
+ logger: console
7507
+ }) : void 0;
7508
+ syncLoop?.start();
7305
7509
  console.log(`Granola server listening on ${server.url.href}`);
7306
7510
  console.log(`Network mode: ${networkMode}`);
7307
7511
  if (password) console.log("Server password protection: enabled");
7308
7512
  else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
7309
7513
  if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
7514
+ console.log(backgroundSyncEnabled ? `Background sync: enabled (${syncIntervalMs}ms)` : "Background sync: disabled");
7310
7515
  console.log("Endpoints:");
7311
7516
  console.log(" GET /health");
7312
7517
  console.log(" GET /server/info");
@@ -7325,10 +7530,14 @@ const serveCommand = {
7325
7530
  console.log(" POST /exports/notes");
7326
7531
  console.log(" POST /exports/jobs/:id/rerun");
7327
7532
  console.log(" POST /exports/transcripts");
7533
+ console.log(" GET /sync/events");
7328
7534
  console.log(" POST /sync");
7329
7535
  console.log(`Attach: granola attach ${server.url.href}`);
7330
7536
  if (password) console.log("Attach password: add --password <value>");
7331
- await waitForShutdown(async () => await server.close());
7537
+ await waitForShutdown(async () => {
7538
+ await syncLoop?.stop();
7539
+ await server.close();
7540
+ });
7332
7541
  return 0;
7333
7542
  }
7334
7543
  };
@@ -7339,8 +7548,12 @@ function syncHelp() {
7339
7548
 
7340
7549
  Usage:
7341
7550
  granola sync [options]
7551
+ granola sync events [options]
7342
7552
 
7343
7553
  Options:
7554
+ --watch Keep syncing in the background until interrupted
7555
+ --interval <value> Poll interval for --watch, e.g. 60s or 5m (default: 60s)
7556
+ --limit <value> Event count for sync events output (default: 20)
7344
7557
  --cache <path> Path to Granola cache JSON
7345
7558
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
7346
7559
  --supabase <path> Path to supabase.json
@@ -7352,16 +7565,27 @@ Options:
7352
7565
  function pluralise(count, singular, plural = singular) {
7353
7566
  return `${count} ${count === 1 ? singular : plural}`;
7354
7567
  }
7568
+ function printSyncResult(result, log = console.log) {
7569
+ log(`✓ Synced ${pluralise(result.summary.meetingCount, "meeting", "meetings")} across ${pluralise(result.summary.folderCount, "folder", "folders")} (${pluralise(result.summary.createdCount, "created")}, ${pluralise(result.summary.changedCount, "updated")}, ${pluralise(result.summary.removedCount, "removed")}, ${pluralise(result.summary.transcriptReadyCount, "transcript ready", "transcripts ready")})`);
7570
+ const lines = result.changes.slice(0, 10).map((change) => {
7571
+ return ` ${change.kind.padEnd(16)} ${change.title} (${change.meetingId})`;
7572
+ });
7573
+ for (const line of lines) log(line);
7574
+ if (result.changes.length > lines.length) log(` ...and ${result.changes.length - lines.length} more change(s)`);
7575
+ }
7355
7576
  const syncCommand = {
7356
7577
  description: "Refresh the local meeting index and sync state",
7357
7578
  flags: {
7358
7579
  cache: { type: "string" },
7359
7580
  help: { type: "boolean" },
7360
- timeout: { type: "string" }
7581
+ interval: { type: "string" },
7582
+ limit: { type: "string" },
7583
+ timeout: { type: "string" },
7584
+ watch: { type: "boolean" }
7361
7585
  },
7362
7586
  help: syncHelp,
7363
7587
  name: "sync",
7364
- async run({ commandFlags, globalFlags }) {
7588
+ async run({ commandArgs, commandFlags, globalFlags }) {
7365
7589
  const config = await loadConfig({
7366
7590
  globalFlags,
7367
7591
  subcommandFlags: commandFlags
@@ -7372,14 +7596,38 @@ const syncCommand = {
7372
7596
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
7373
7597
  const app = await createGranolaApp(config);
7374
7598
  debug(config.debug, "authMode", app.getState().auth.mode);
7599
+ if (commandArgs[0] === "events") {
7600
+ const limit = typeof commandFlags.limit === "string" && /^\d+$/.test(commandFlags.limit) ? Number(commandFlags.limit) : 20;
7601
+ const result = await app.listSyncEvents({ limit });
7602
+ if (result.events.length === 0) {
7603
+ console.log("No sync events yet.");
7604
+ return 0;
7605
+ }
7606
+ for (const event of result.events) console.log(`${event.occurredAt} ${event.kind.padEnd(18)} ${event.title} (${event.meetingId})`);
7607
+ return 0;
7608
+ }
7375
7609
  const result = await app.sync();
7376
- console.log(`✓ Synced ${pluralise(result.summary.meetingCount, "meeting", "meetings")} across ${pluralise(result.summary.folderCount, "folder", "folders")} (${pluralise(result.summary.createdCount, "created")}, ${pluralise(result.summary.changedCount, "updated")}, ${pluralise(result.summary.removedCount, "removed")}, ${pluralise(result.summary.transcriptReadyCount, "transcript ready", "transcripts ready")})`);
7377
- const lines = result.changes.slice(0, 10).map((change) => {
7378
- return ` ${change.kind.padEnd(16)} ${change.title} (${change.meetingId})`;
7379
- });
7380
- for (const line of lines) console.log(line);
7381
- if (result.changes.length > lines.length) console.log(` ...and ${result.changes.length - lines.length} more change(s)`);
7610
+ printSyncResult(result);
7382
7611
  if (result.state.lastCompletedAt) debug(config.debug, "syncCompletedAt", result.state.lastCompletedAt);
7612
+ if (commandFlags.watch === true) {
7613
+ const intervalMs = parseSyncInterval(commandFlags.interval);
7614
+ const syncLoop = createGranolaSyncLoop({
7615
+ app,
7616
+ intervalMs,
7617
+ logger: console,
7618
+ onError: async (error) => {
7619
+ console.error(error instanceof Error ? error.message : String(error));
7620
+ },
7621
+ onSynced: async (nextResult) => {
7622
+ printSyncResult(nextResult);
7623
+ }
7624
+ });
7625
+ syncLoop.start({ immediate: false });
7626
+ console.log(`Watching for Granola changes every ${intervalMs}ms. Press Ctrl+C to stop.`);
7627
+ await waitForShutdown(async () => {
7628
+ await syncLoop.stop();
7629
+ });
7630
+ }
7383
7631
  return 0;
7384
7632
  }
7385
7633
  };
@@ -7393,6 +7641,8 @@ Usage:
7393
7641
 
7394
7642
  Options:
7395
7643
  --meeting <id> Open the workspace focused on a specific meeting
7644
+ --sync-interval <value> Background sync interval, e.g. 60s or 5m (default: 60s)
7645
+ --no-sync Disable the background sync loop
7396
7646
  --cache <path> Path to Granola cache JSON
7397
7647
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
7398
7648
  --supabase <path> Path to supabase.json
@@ -7407,6 +7657,8 @@ const tuiCommand = {
7407
7657
  cache: { type: "string" },
7408
7658
  help: { type: "boolean" },
7409
7659
  meeting: { type: "string" },
7660
+ "no-sync": { type: "boolean" },
7661
+ "sync-interval": { type: "string" },
7410
7662
  timeout: { type: "string" }
7411
7663
  },
7412
7664
  help: tuiHelp,
@@ -7420,7 +7672,23 @@ const tuiCommand = {
7420
7672
  debug(config.debug, "supabase", config.supabase);
7421
7673
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
7422
7674
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
7423
- return await runGranolaTui(await createGranolaApp(config, { surface: "tui" }), { initialMeetingId: typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0 });
7675
+ const app = await createGranolaApp(config, { surface: "tui" });
7676
+ const initialMeetingId = typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0;
7677
+ const backgroundSyncEnabled = syncEnabled(commandFlags);
7678
+ const syncIntervalMs = parseSyncInterval(commandFlags["sync-interval"]);
7679
+ const syncLoop = backgroundSyncEnabled ? createGranolaSyncLoop({
7680
+ app,
7681
+ intervalMs: syncIntervalMs,
7682
+ logger: console
7683
+ }) : void 0;
7684
+ syncLoop?.start();
7685
+ debug(config.debug, "backgroundSync", backgroundSyncEnabled ? `${syncIntervalMs}ms` : "disabled");
7686
+ return await runGranolaTui(app, {
7687
+ initialMeetingId,
7688
+ onClose: async () => {
7689
+ await syncLoop?.stop();
7690
+ }
7691
+ });
7424
7692
  }
7425
7693
  };
7426
7694
  //#endregion
@@ -7500,6 +7768,8 @@ Options:
7500
7768
  --hostname <value> Hostname to bind (overrides network default)
7501
7769
  --port <value> Port to bind (default: 0 for any available port)
7502
7770
  --password <value> Optional server password for API and browser access
7771
+ --sync-interval <value> Background sync interval, e.g. 60s or 5m (default: 60s)
7772
+ --no-sync Disable the background sync loop
7503
7773
  --trusted-origins <v> Comma-separated extra browser origins to trust
7504
7774
  --cache <path> Path to Granola cache JSON
7505
7775
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
@@ -7531,9 +7801,11 @@ const commands = [
7531
7801
  hostname: { type: "string" },
7532
7802
  meeting: { type: "string" },
7533
7803
  network: { type: "string" },
7804
+ "no-sync": { type: "boolean" },
7534
7805
  open: { type: "boolean" },
7535
7806
  password: { type: "string" },
7536
7807
  port: { type: "string" },
7808
+ "sync-interval": { type: "string" },
7537
7809
  timeout: { type: "string" },
7538
7810
  "trusted-origins": { type: "string" }
7539
7811
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",