granola-toolkit 0.17.0 → 0.19.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 +42 -0
  2. package/dist/cli.js +937 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,7 +35,9 @@ granola --help
35
35
  granola auth login
36
36
  granola meeting --help
37
37
  granola notes --help
38
+ granola serve --help
38
39
  granola transcripts --help
40
+ granola web --help
39
41
  ```
40
42
 
41
43
  The published package exposes both `granola` and `granola-toolkit` as executable names.
@@ -47,7 +49,9 @@ vp pack
47
49
  node dist/cli.js --help
48
50
  node dist/cli.js meeting --help
49
51
  node dist/cli.js notes --help
52
+ node dist/cli.js serve --help
50
53
  node dist/cli.js transcripts --help
54
+ node dist/cli.js web --help
51
55
  ```
52
56
 
53
57
  You can also use the package scripts:
@@ -89,6 +93,17 @@ granola meeting transcript 1234abcd --format json
89
93
  granola meeting export 1234abcd --format yaml
90
94
  ```
91
95
 
96
+ Run the local API server:
97
+
98
+ ```bash
99
+ granola serve
100
+ granola serve --port 4096
101
+ granola serve --hostname 0.0.0.0 --port 4096
102
+
103
+ granola web
104
+ granola web --open=false --port 4096
105
+ ```
106
+
92
107
  ## How It Works
93
108
 
94
109
  ### Notes
@@ -167,6 +182,33 @@ The machine-readable `export` command includes:
167
182
  - structured note data plus rendered Markdown
168
183
  - structured transcript data plus rendered transcript text when available
169
184
 
185
+ ### Server
186
+
187
+ `serve` starts a long-lived local `Granola Toolkit` server on one shared app instance.
188
+
189
+ The initial server API includes:
190
+
191
+ - `GET /health`
192
+ - `GET /state`
193
+ - `GET /events` for server-sent state updates
194
+ - `GET /meetings`
195
+ - `GET /meetings/:id`
196
+ - `POST /exports/notes`
197
+ - `POST /exports/transcripts`
198
+
199
+ This is the foundation for the future `granola web` client and any attachable TUI flows.
200
+
201
+ ### Web
202
+
203
+ `web` starts the same local server as `serve`, enables the browser client at `/`, and opens that workspace in your default browser unless you pass `--open=false`.
204
+
205
+ The initial browser client includes:
206
+
207
+ - a searchable meeting list
208
+ - a meeting detail view with notes and transcript panes
209
+ - app-state status from the shared core
210
+ - note and transcript export actions backed by the same local API
211
+
170
212
  ## Auth
171
213
 
172
214
  If you do not want to keep passing `--supabase`, import the desktop app session once:
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { NodeHtmlMarkdown } from "node-html-markdown";
9
9
  import { createHash } from "node:crypto";
10
+ import { createServer } from "node:http";
10
11
  //#region src/utils.ts
11
12
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
12
13
  const CONTROL_CHARACTERS = /\p{Cc}/gu;
@@ -153,7 +154,7 @@ function transcriptSpeakerLabel(segment) {
153
154
  }
154
155
  //#endregion
155
156
  //#region src/client/auth.ts
156
- const execFileAsync = promisify(execFile);
157
+ const execFileAsync$1 = promisify(execFile);
157
158
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
158
159
  const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
159
160
  const KEYCHAIN_ACCOUNT_NAME = "session";
@@ -243,7 +244,7 @@ var FileSessionStore = class {
243
244
  var KeychainSessionStore = class {
244
245
  async clearSession() {
245
246
  try {
246
- await execFileAsync("security", [
247
+ await execFileAsync$1("security", [
247
248
  "delete-generic-password",
248
249
  "-s",
249
250
  KEYCHAIN_SERVICE_NAME,
@@ -254,7 +255,7 @@ var KeychainSessionStore = class {
254
255
  }
255
256
  async readSession() {
256
257
  try {
257
- const { stdout } = await execFileAsync("security", [
258
+ const { stdout } = await execFileAsync$1("security", [
258
259
  "find-generic-password",
259
260
  "-s",
260
261
  KEYCHAIN_SERVICE_NAME,
@@ -269,7 +270,7 @@ var KeychainSessionStore = class {
269
270
  }
270
271
  }
271
272
  async writeSession(session) {
272
- await execFileAsync("security", [
273
+ await execFileAsync$1("security", [
273
274
  "add-generic-password",
274
275
  "-U",
275
276
  "-s",
@@ -1517,6 +1518,7 @@ var GranolaApp = class {
1517
1518
  #cacheResolved = false;
1518
1519
  #granolaClient;
1519
1520
  #documents;
1521
+ #listeners = /* @__PURE__ */ new Set();
1520
1522
  #state;
1521
1523
  constructor(config, deps, options = {}) {
1522
1524
  this.config = config;
@@ -1526,16 +1528,31 @@ var GranolaApp = class {
1526
1528
  getState() {
1527
1529
  return cloneState(this.#state);
1528
1530
  }
1531
+ subscribe(listener) {
1532
+ this.#listeners.add(listener);
1533
+ return () => {
1534
+ this.#listeners.delete(listener);
1535
+ };
1536
+ }
1529
1537
  setUiState(patch) {
1530
1538
  this.#state.ui = {
1531
1539
  ...this.#state.ui,
1532
1540
  ...patch
1533
1541
  };
1542
+ this.emitStateUpdate();
1534
1543
  return this.getState();
1535
1544
  }
1536
1545
  nowIso() {
1537
1546
  return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1538
1547
  }
1548
+ emitStateUpdate() {
1549
+ const event = {
1550
+ state: this.getState(),
1551
+ timestamp: this.nowIso(),
1552
+ type: "state.updated"
1553
+ };
1554
+ for (const listener of this.#listeners) listener(event);
1555
+ }
1539
1556
  async getGranolaClient() {
1540
1557
  if (this.#granolaClient) return this.#granolaClient;
1541
1558
  if (this.deps.granolaClient) {
@@ -1546,6 +1563,7 @@ var GranolaApp = class {
1546
1563
  const runtime = await this.deps.createGranolaClient();
1547
1564
  this.#granolaClient = runtime.client;
1548
1565
  this.#state.auth = { ...runtime.auth };
1566
+ this.emitStateUpdate();
1549
1567
  return this.#granolaClient;
1550
1568
  }
1551
1569
  missingCacheError() {
@@ -1560,6 +1578,7 @@ var GranolaApp = class {
1560
1578
  loaded: true,
1561
1579
  loadedAt: this.nowIso()
1562
1580
  };
1581
+ this.emitStateUpdate();
1563
1582
  return documents;
1564
1583
  }
1565
1584
  async loadCache(options = {}) {
@@ -1585,6 +1604,7 @@ var GranolaApp = class {
1585
1604
  loadedAt: cacheData ? this.nowIso() : void 0,
1586
1605
  transcriptCount: cacheData ? transcriptCount(cacheData) : 0
1587
1606
  };
1607
+ this.emitStateUpdate();
1588
1608
  if (options.required && !cacheData) throw this.missingCacheError();
1589
1609
  return cacheData;
1590
1610
  }
@@ -1626,6 +1646,7 @@ var GranolaApp = class {
1626
1646
  ranAt: this.nowIso(),
1627
1647
  written
1628
1648
  };
1649
+ this.emitStateUpdate();
1629
1650
  this.setUiState({ view: "notes-export" });
1630
1651
  return {
1631
1652
  documentCount: documents.length,
@@ -1647,6 +1668,7 @@ var GranolaApp = class {
1647
1668
  ranAt: this.nowIso(),
1648
1669
  written
1649
1670
  };
1671
+ this.emitStateUpdate();
1650
1672
  this.setUiState({ view: "transcripts-export" });
1651
1673
  return {
1652
1674
  cacheData,
@@ -1745,6 +1767,41 @@ async function loadConfig(options) {
1745
1767
  function debug(enabled, ...values) {
1746
1768
  if (enabled) console.error("[debug]", ...values);
1747
1769
  }
1770
+ function parsePort(value) {
1771
+ if (value === void 0) return;
1772
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
1773
+ const port = Number(value);
1774
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
1775
+ return port;
1776
+ }
1777
+ function pickHostname(value, fallback = "127.0.0.1") {
1778
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
1779
+ }
1780
+ async function waitForShutdown(close) {
1781
+ await new Promise((resolve, reject) => {
1782
+ let closing = false;
1783
+ const cleanup = () => {
1784
+ process.off("SIGINT", handleSignal);
1785
+ process.off("SIGTERM", handleSignal);
1786
+ };
1787
+ const finish = async () => {
1788
+ if (closing) return;
1789
+ closing = true;
1790
+ cleanup();
1791
+ try {
1792
+ await close();
1793
+ resolve();
1794
+ } catch (error) {
1795
+ reject(error);
1796
+ }
1797
+ };
1798
+ const handleSignal = () => {
1799
+ finish();
1800
+ };
1801
+ process.on("SIGINT", handleSignal);
1802
+ process.on("SIGTERM", handleSignal);
1803
+ });
1804
+ }
1748
1805
  //#endregion
1749
1806
  //#region src/commands/meeting.ts
1750
1807
  function meetingHelp() {
@@ -2010,6 +2067,778 @@ function resolveNoteFormat(value) {
2010
2067
  }
2011
2068
  }
2012
2069
  //#endregion
2070
+ //#region src/server/web.ts
2071
+ function renderGranolaWebPage() {
2072
+ return `<!doctype html>
2073
+ <html lang="en">
2074
+ <head>
2075
+ <meta charset="utf-8" />
2076
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2077
+ <title>Granola Toolkit</title>
2078
+ <style>
2079
+ :root {
2080
+ --bg: #f2ede2;
2081
+ --panel: rgba(255, 252, 247, 0.86);
2082
+ --panel-strong: #fffaf2;
2083
+ --line: rgba(36, 39, 44, 0.12);
2084
+ --ink: #1d242c;
2085
+ --muted: #5d6b77;
2086
+ --accent: #0d6a6d;
2087
+ --accent-soft: rgba(13, 106, 109, 0.12);
2088
+ --warm: #a34f2f;
2089
+ --ok: #246b4f;
2090
+ --error: #9d2c2c;
2091
+ --shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
2092
+ --radius: 24px;
2093
+ --mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
2094
+ --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
2095
+ --sans: "Avenir Next", "Segoe UI", sans-serif;
2096
+ }
2097
+
2098
+ * { box-sizing: border-box; }
2099
+
2100
+ body {
2101
+ margin: 0;
2102
+ min-height: 100vh;
2103
+ font-family: var(--sans);
2104
+ color: var(--ink);
2105
+ background:
2106
+ radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
2107
+ radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
2108
+ linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
2109
+ }
2110
+
2111
+ .shell {
2112
+ display: grid;
2113
+ grid-template-columns: 320px minmax(0, 1fr);
2114
+ gap: 18px;
2115
+ min-height: 100vh;
2116
+ padding: 24px;
2117
+ }
2118
+
2119
+ .pane {
2120
+ background: var(--panel);
2121
+ backdrop-filter: blur(18px);
2122
+ border: 1px solid var(--line);
2123
+ border-radius: var(--radius);
2124
+ box-shadow: var(--shadow);
2125
+ }
2126
+
2127
+ .sidebar {
2128
+ display: grid;
2129
+ grid-template-rows: auto auto 1fr;
2130
+ overflow: hidden;
2131
+ }
2132
+
2133
+ .hero, .toolbar, .detail-head {
2134
+ padding: 22px 24px;
2135
+ border-bottom: 1px solid var(--line);
2136
+ }
2137
+
2138
+ .hero h1 {
2139
+ margin: 0;
2140
+ font-family: var(--serif);
2141
+ font-size: clamp(2rem, 3vw, 2.8rem);
2142
+ font-weight: 600;
2143
+ letter-spacing: -0.04em;
2144
+ }
2145
+
2146
+ .hero p, .toolbar p {
2147
+ margin: 8px 0 0;
2148
+ color: var(--muted);
2149
+ line-height: 1.5;
2150
+ }
2151
+
2152
+ .search {
2153
+ width: 100%;
2154
+ margin-top: 16px;
2155
+ padding: 12px 14px;
2156
+ border: 1px solid var(--line);
2157
+ border-radius: 999px;
2158
+ background: rgba(255, 255, 255, 0.7);
2159
+ color: var(--ink);
2160
+ font: inherit;
2161
+ }
2162
+
2163
+ .meeting-list {
2164
+ padding: 14px;
2165
+ overflow: auto;
2166
+ }
2167
+
2168
+ .meeting-row {
2169
+ width: 100%;
2170
+ display: grid;
2171
+ gap: 4px;
2172
+ text-align: left;
2173
+ margin: 0 0 10px;
2174
+ padding: 14px 16px;
2175
+ border: 1px solid transparent;
2176
+ border-radius: 18px;
2177
+ background: rgba(255, 255, 255, 0.72);
2178
+ color: inherit;
2179
+ cursor: pointer;
2180
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
2181
+ }
2182
+
2183
+ .meeting-row:hover,
2184
+ .meeting-row[data-selected="true"] {
2185
+ transform: translateY(-1px);
2186
+ border-color: rgba(13, 106, 109, 0.25);
2187
+ background: var(--panel-strong);
2188
+ }
2189
+
2190
+ .meeting-row__title {
2191
+ font-weight: 600;
2192
+ }
2193
+
2194
+ .meeting-row__meta {
2195
+ color: var(--muted);
2196
+ font-size: 0.92rem;
2197
+ }
2198
+
2199
+ .meeting-empty {
2200
+ padding: 18px;
2201
+ color: var(--muted);
2202
+ }
2203
+
2204
+ .detail {
2205
+ display: grid;
2206
+ grid-template-rows: auto auto 1fr;
2207
+ min-width: 0;
2208
+ }
2209
+
2210
+ .detail-head {
2211
+ display: flex;
2212
+ align-items: center;
2213
+ justify-content: space-between;
2214
+ gap: 18px;
2215
+ }
2216
+
2217
+ .detail-head h2 {
2218
+ margin: 0;
2219
+ font-family: var(--serif);
2220
+ font-size: clamp(1.8rem, 2.4vw, 2.4rem);
2221
+ font-weight: 600;
2222
+ }
2223
+
2224
+ .state-badge {
2225
+ padding: 10px 14px;
2226
+ border-radius: 999px;
2227
+ background: var(--accent-soft);
2228
+ color: var(--accent);
2229
+ font-size: 0.92rem;
2230
+ font-weight: 700;
2231
+ }
2232
+
2233
+ .state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
2234
+ .state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
2235
+ .state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
2236
+
2237
+ .toolbar {
2238
+ display: flex;
2239
+ flex-wrap: wrap;
2240
+ align-items: center;
2241
+ justify-content: space-between;
2242
+ gap: 14px;
2243
+ }
2244
+
2245
+ .toolbar-actions {
2246
+ display: flex;
2247
+ flex-wrap: wrap;
2248
+ gap: 10px;
2249
+ }
2250
+
2251
+ .button {
2252
+ border: 0;
2253
+ border-radius: 999px;
2254
+ padding: 12px 16px;
2255
+ font: inherit;
2256
+ font-weight: 700;
2257
+ cursor: pointer;
2258
+ }
2259
+
2260
+ .button--primary {
2261
+ background: var(--ink);
2262
+ color: white;
2263
+ }
2264
+
2265
+ .button--secondary {
2266
+ background: rgba(255, 255, 255, 0.72);
2267
+ color: var(--ink);
2268
+ border: 1px solid var(--line);
2269
+ }
2270
+
2271
+ .status-grid {
2272
+ display: grid;
2273
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
2274
+ gap: 14px;
2275
+ }
2276
+
2277
+ .status-label {
2278
+ display: block;
2279
+ margin-bottom: 6px;
2280
+ color: var(--muted);
2281
+ font-size: 0.78rem;
2282
+ letter-spacing: 0.08em;
2283
+ text-transform: uppercase;
2284
+ }
2285
+
2286
+ .detail-meta {
2287
+ display: flex;
2288
+ flex-wrap: wrap;
2289
+ gap: 10px;
2290
+ padding: 0 24px 18px;
2291
+ }
2292
+
2293
+ .detail-chip {
2294
+ padding: 10px 12px;
2295
+ border-radius: 999px;
2296
+ background: rgba(255, 255, 255, 0.72);
2297
+ border: 1px solid var(--line);
2298
+ color: var(--muted);
2299
+ font-size: 0.88rem;
2300
+ }
2301
+
2302
+ .detail-body {
2303
+ padding: 0 24px 24px;
2304
+ overflow: auto;
2305
+ }
2306
+
2307
+ .detail-section {
2308
+ margin-bottom: 20px;
2309
+ padding: 20px;
2310
+ background: rgba(255, 255, 255, 0.72);
2311
+ border: 1px solid var(--line);
2312
+ border-radius: 20px;
2313
+ }
2314
+
2315
+ .detail-section h2 {
2316
+ margin: 0 0 14px;
2317
+ font-size: 1rem;
2318
+ letter-spacing: 0.08em;
2319
+ text-transform: uppercase;
2320
+ }
2321
+
2322
+ .detail-pre {
2323
+ margin: 0;
2324
+ white-space: pre-wrap;
2325
+ word-break: break-word;
2326
+ font-family: var(--mono);
2327
+ line-height: 1.55;
2328
+ }
2329
+
2330
+ .empty {
2331
+ margin: 24px;
2332
+ padding: 24px;
2333
+ border-radius: 20px;
2334
+ background: rgba(255, 255, 255, 0.72);
2335
+ border: 1px dashed rgba(36, 39, 44, 0.2);
2336
+ color: var(--muted);
2337
+ }
2338
+
2339
+ @media (max-width: 900px) {
2340
+ .shell {
2341
+ grid-template-columns: 1fr;
2342
+ }
2343
+ }
2344
+ </style>
2345
+ </head>
2346
+ <body>
2347
+ <div class="shell">
2348
+ <aside class="pane sidebar">
2349
+ <section class="hero">
2350
+ <h1>Granola Toolkit</h1>
2351
+ <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
2352
+ <input class="search" data-search placeholder="Search meetings, ids, or tags" />
2353
+ </section>
2354
+ <section class="toolbar">
2355
+ <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
2356
+ </section>
2357
+ <section class="meeting-list" data-meeting-list></section>
2358
+ </aside>
2359
+ <main class="pane detail">
2360
+ <section class="detail-head">
2361
+ <div>
2362
+ <h2>Meeting Workspace</h2>
2363
+ <div data-app-state></div>
2364
+ </div>
2365
+ <div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
2366
+ </section>
2367
+ <section class="toolbar">
2368
+ <div class="toolbar-actions">
2369
+ <button class="button button--primary" data-refresh>Refresh</button>
2370
+ <button class="button button--secondary" data-export-notes>Export Notes</button>
2371
+ <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
2372
+ </div>
2373
+ <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2374
+ </section>
2375
+ <div class="detail-meta" data-detail-meta></div>
2376
+ <div class="detail-body" data-detail-body>
2377
+ <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
2378
+ </div>
2379
+ </main>
2380
+ </div>
2381
+ <script type="module">
2382
+ ${String.raw`
2383
+ const state = {
2384
+ meetings: [],
2385
+ selectedMeetingId: null,
2386
+ selectedMeeting: null,
2387
+ appState: null,
2388
+ search: "",
2389
+ };
2390
+
2391
+ const els = {
2392
+ appState: document.querySelector("[data-app-state]"),
2393
+ detailBody: document.querySelector("[data-detail-body]"),
2394
+ detailMeta: document.querySelector("[data-detail-meta]"),
2395
+ empty: document.querySelector("[data-empty]"),
2396
+ list: document.querySelector("[data-meeting-list]"),
2397
+ noteButton: document.querySelector("[data-export-notes]"),
2398
+ refreshButton: document.querySelector("[data-refresh]"),
2399
+ search: document.querySelector("[data-search]"),
2400
+ stateBadge: document.querySelector("[data-state-badge]"),
2401
+ transcriptButton: document.querySelector("[data-export-transcripts]"),
2402
+ };
2403
+
2404
+ function escapeHtml(value) {
2405
+ return value
2406
+ .replaceAll("&", "&amp;")
2407
+ .replaceAll("<", "&lt;")
2408
+ .replaceAll(">", "&gt;")
2409
+ .replaceAll('"', "&quot;");
2410
+ }
2411
+
2412
+ function setStatus(label, tone = "idle") {
2413
+ els.stateBadge.textContent = label;
2414
+ els.stateBadge.dataset.tone = tone;
2415
+ }
2416
+
2417
+ function renderAppState() {
2418
+ if (!state.appState) {
2419
+ els.appState.innerHTML = "<p>Waiting for server state…</p>";
2420
+ return;
2421
+ }
2422
+
2423
+ const appState = state.appState;
2424
+ const authMode = appState.auth.mode === "stored-session" ? "Stored session" : "supabase.json";
2425
+ const docs = appState.documents.loaded ? String(appState.documents.count) : "not loaded";
2426
+ const cache = appState.cache.loaded
2427
+ ? appState.cache.transcriptCount + " transcript sets"
2428
+ : appState.cache.configured
2429
+ ? "configured"
2430
+ : "not configured";
2431
+
2432
+ els.appState.innerHTML = [
2433
+ '<div class="status-grid">',
2434
+ '<div><span class="status-label">Surface</span><strong>' + escapeHtml(appState.ui.surface) + "</strong></div>",
2435
+ '<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
2436
+ '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
2437
+ '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
2438
+ '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2439
+ "</div>",
2440
+ ].join("");
2441
+ }
2442
+
2443
+ function renderMeetingList() {
2444
+ if (state.meetings.length === 0) {
2445
+ state.selectedMeetingId = null;
2446
+ state.selectedMeeting = null;
2447
+ els.list.innerHTML = '<div class="meeting-empty">No meetings yet. Try Refresh.</div>';
2448
+ renderMeetingDetail();
2449
+ return;
2450
+ }
2451
+
2452
+ const visibleIds = new Set(state.meetings.map((meeting) => meeting.id));
2453
+ if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
2454
+ state.selectedMeetingId = state.meetings[0]?.id || null;
2455
+ }
2456
+
2457
+ els.list.innerHTML = state.meetings
2458
+ .map((meeting) => {
2459
+ const selected = meeting.id === state.selectedMeetingId ? ' data-selected="true"' : "";
2460
+ const tags = meeting.tags.length ? meeting.tags.map((tag) => "#" + tag).join(" ") : "untagged";
2461
+ return [
2462
+ '<button class="meeting-row"' + selected + ' data-meeting-id="' + escapeHtml(meeting.id) + '">',
2463
+ '<span class="meeting-row__title">' + escapeHtml(meeting.title || meeting.id) + "</span>",
2464
+ '<span class="meeting-row__meta">' + escapeHtml(tags) + "</span>",
2465
+ '<span class="meeting-row__meta">' + escapeHtml(meeting.updatedAt.slice(0, 10) || "unknown") + "</span>",
2466
+ "</button>",
2467
+ ].join("");
2468
+ })
2469
+ .join("");
2470
+ }
2471
+
2472
+ function renderMeetingDetail() {
2473
+ const record = state.selectedMeeting;
2474
+ if (!record) {
2475
+ els.empty.hidden = false;
2476
+ els.detailMeta.innerHTML = "";
2477
+ els.detailBody.innerHTML = "";
2478
+ return;
2479
+ }
2480
+
2481
+ els.empty.hidden = true;
2482
+ els.detailMeta.innerHTML = [
2483
+ '<div class="detail-chip">ID: ' + escapeHtml(record.meeting.id) + "</div>",
2484
+ '<div class="detail-chip">Source: ' + escapeHtml(record.meeting.noteContentSource) + "</div>",
2485
+ '<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
2486
+ ].join("");
2487
+
2488
+ els.detailBody.innerHTML = [
2489
+ '<section class="detail-section">',
2490
+ "<h2>Notes</h2>",
2491
+ '<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
2492
+ "</section>",
2493
+ '<section class="detail-section">',
2494
+ "<h2>Transcript</h2>",
2495
+ '<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
2496
+ "</section>",
2497
+ ].join("");
2498
+ }
2499
+
2500
+ async function fetchJson(path, init) {
2501
+ const response = await fetch(path, init);
2502
+ const payload = await response.json().catch(() => ({}));
2503
+ if (!response.ok) {
2504
+ throw new Error(payload.error || response.statusText || "Request failed");
2505
+ }
2506
+ return payload;
2507
+ }
2508
+
2509
+ async function loadMeetings() {
2510
+ const query = state.search ? "?search=" + encodeURIComponent(state.search) + "&limit=50" : "?limit=50";
2511
+ const payload = await fetchJson("/meetings" + query);
2512
+ state.meetings = payload.meetings || [];
2513
+ if (!state.selectedMeetingId && state.meetings[0]) {
2514
+ state.selectedMeetingId = state.meetings[0].id;
2515
+ }
2516
+ renderMeetingList();
2517
+ if (state.selectedMeetingId) {
2518
+ await loadMeeting(state.selectedMeetingId);
2519
+ } else {
2520
+ renderMeetingDetail();
2521
+ }
2522
+ }
2523
+
2524
+ async function loadMeeting(id) {
2525
+ state.selectedMeetingId = id;
2526
+ renderMeetingList();
2527
+ const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
2528
+ state.selectedMeeting = payload.meeting || null;
2529
+ renderMeetingDetail();
2530
+ }
2531
+
2532
+ async function refreshAll() {
2533
+ setStatus("Refreshing…", "busy");
2534
+ const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
2535
+ state.appState = appState;
2536
+ renderAppState();
2537
+ setStatus("Connected", "ok");
2538
+ }
2539
+
2540
+ async function exportNotes() {
2541
+ setStatus("Exporting notes…", "busy");
2542
+ await fetchJson("/exports/notes", {
2543
+ body: JSON.stringify({ format: "markdown" }),
2544
+ headers: { "content-type": "application/json" },
2545
+ method: "POST",
2546
+ });
2547
+ await refreshAll();
2548
+ }
2549
+
2550
+ async function exportTranscripts() {
2551
+ setStatus("Exporting transcripts…", "busy");
2552
+ await fetchJson("/exports/transcripts", {
2553
+ body: JSON.stringify({ format: "text" }),
2554
+ headers: { "content-type": "application/json" },
2555
+ method: "POST",
2556
+ });
2557
+ await refreshAll();
2558
+ }
2559
+
2560
+ els.list.addEventListener("click", (event) => {
2561
+ if (!(event.target instanceof Element)) {
2562
+ return;
2563
+ }
2564
+
2565
+ const button = event.target.closest("[data-meeting-id]");
2566
+ if (!button) return;
2567
+ void loadMeeting(button.dataset.meetingId);
2568
+ });
2569
+
2570
+ els.refreshButton.addEventListener("click", () => {
2571
+ void refreshAll();
2572
+ });
2573
+
2574
+ els.noteButton.addEventListener("click", () => {
2575
+ void exportNotes();
2576
+ });
2577
+
2578
+ els.transcriptButton.addEventListener("click", () => {
2579
+ void exportTranscripts();
2580
+ });
2581
+
2582
+ els.search.addEventListener("input", (event) => {
2583
+ if (!(event.target instanceof HTMLInputElement)) {
2584
+ return;
2585
+ }
2586
+
2587
+ state.search = event.target.value.trim();
2588
+ void loadMeetings();
2589
+ });
2590
+
2591
+ const events = new EventSource("/events");
2592
+ events.addEventListener("state.updated", (event) => {
2593
+ const payload = JSON.parse(event.data);
2594
+ state.appState = payload.state;
2595
+ renderAppState();
2596
+ });
2597
+ events.addEventListener("error", () => {
2598
+ setStatus("Disconnected", "error");
2599
+ });
2600
+
2601
+ void refreshAll().catch((error) => {
2602
+ setStatus("Error", "error");
2603
+ els.empty.hidden = false;
2604
+ els.empty.textContent = error.message;
2605
+ });
2606
+ `}
2607
+ <\/script>
2608
+ </body>
2609
+ </html>`;
2610
+ }
2611
+ //#endregion
2612
+ //#region src/server/http.ts
2613
+ function parseInteger(value) {
2614
+ if (!value?.trim()) return;
2615
+ if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
2616
+ const parsed = Number(value);
2617
+ if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
2618
+ return parsed;
2619
+ }
2620
+ function sendJson(response, body, init = {}) {
2621
+ const payload = `${JSON.stringify(body, null, 2)}\n`;
2622
+ response.writeHead(init.status ?? 200, {
2623
+ "content-length": Buffer.byteLength(payload),
2624
+ "content-type": "application/json; charset=utf-8"
2625
+ });
2626
+ response.end(payload);
2627
+ }
2628
+ function sendText(response, body, status = 200) {
2629
+ response.writeHead(status, {
2630
+ "content-length": Buffer.byteLength(body),
2631
+ "content-type": "text/plain; charset=utf-8"
2632
+ });
2633
+ response.end(body);
2634
+ }
2635
+ function sendHtml(response, body, status = 200) {
2636
+ response.writeHead(status, {
2637
+ "content-length": Buffer.byteLength(body),
2638
+ "content-type": "text/html; charset=utf-8"
2639
+ });
2640
+ response.end(body);
2641
+ }
2642
+ async function readJsonBody(request) {
2643
+ const chunks = [];
2644
+ for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2645
+ if (chunks.length === 0) return {};
2646
+ try {
2647
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2648
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
2649
+ return parsed;
2650
+ } catch (error) {
2651
+ throw new Error(error instanceof Error ? error.message : "failed to parse JSON body");
2652
+ }
2653
+ }
2654
+ function formatSseEvent(event) {
2655
+ return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
2656
+ }
2657
+ function noteFormatFromBody(value) {
2658
+ switch (value) {
2659
+ case void 0:
2660
+ case "markdown": return "markdown";
2661
+ case "json":
2662
+ case "raw":
2663
+ case "yaml": return value;
2664
+ default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
2665
+ }
2666
+ }
2667
+ function transcriptFormatFromBody(value) {
2668
+ switch (value) {
2669
+ case void 0:
2670
+ case "text": return "text";
2671
+ case "json":
2672
+ case "raw":
2673
+ case "yaml": return value;
2674
+ default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
2675
+ }
2676
+ }
2677
+ async function startGranolaServer(app, options = {}) {
2678
+ const enableWebClient = options.enableWebClient ?? false;
2679
+ const hostname = options.hostname ?? "127.0.0.1";
2680
+ const port = options.port ?? 0;
2681
+ const server = createServer(async (request, response) => {
2682
+ const method = request.method ?? "GET";
2683
+ const url = new URL(request.url ?? "/", `http://${hostname}`);
2684
+ const path = url.pathname;
2685
+ try {
2686
+ if (method === "GET" && path === "/" && enableWebClient) {
2687
+ sendHtml(response, renderGranolaWebPage());
2688
+ return;
2689
+ }
2690
+ if (method === "GET" && path === "/health") {
2691
+ sendJson(response, {
2692
+ ok: true,
2693
+ service: "granola-toolkit",
2694
+ version: app.config ? void 0 : void 0
2695
+ });
2696
+ return;
2697
+ }
2698
+ if (method === "GET" && path === "/state") {
2699
+ sendJson(response, app.getState());
2700
+ return;
2701
+ }
2702
+ if (method === "GET" && path === "/events") {
2703
+ response.writeHead(200, {
2704
+ "cache-control": "no-cache, no-transform",
2705
+ connection: "keep-alive",
2706
+ "content-type": "text/event-stream; charset=utf-8"
2707
+ });
2708
+ response.write(formatSseEvent({
2709
+ state: app.getState(),
2710
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2711
+ type: "state.updated"
2712
+ }));
2713
+ const unsubscribe = app.subscribe((event) => {
2714
+ response.write(formatSseEvent(event));
2715
+ });
2716
+ request.on("close", () => {
2717
+ unsubscribe();
2718
+ response.end();
2719
+ });
2720
+ return;
2721
+ }
2722
+ if (method === "GET" && path === "/meetings") {
2723
+ const limit = parseInteger(url.searchParams.get("limit"));
2724
+ const search = url.searchParams.get("search")?.trim() || void 0;
2725
+ sendJson(response, {
2726
+ meetings: await app.listMeetings({
2727
+ limit,
2728
+ search
2729
+ }),
2730
+ search
2731
+ });
2732
+ return;
2733
+ }
2734
+ if (method === "GET" && path.startsWith("/meetings/")) {
2735
+ const id = decodeURIComponent(path.slice(10));
2736
+ if (!id) throw new Error("meeting id is required");
2737
+ sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
2738
+ return;
2739
+ }
2740
+ if (method === "POST" && path === "/exports/notes") {
2741
+ const body = await readJsonBody(request);
2742
+ sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
2743
+ return;
2744
+ }
2745
+ if (method === "POST" && path === "/exports/transcripts") {
2746
+ const body = await readJsonBody(request);
2747
+ sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
2748
+ return;
2749
+ }
2750
+ sendText(response, "Not found\n", 404);
2751
+ } catch (error) {
2752
+ sendJson(response, { error: error instanceof Error ? error.message : String(error) }, { status: 400 });
2753
+ }
2754
+ });
2755
+ await new Promise((resolve, reject) => {
2756
+ server.once("error", reject);
2757
+ server.listen(port, hostname, () => {
2758
+ server.off("error", reject);
2759
+ resolve();
2760
+ });
2761
+ });
2762
+ const address = server.address();
2763
+ if (!address || typeof address === "string") throw new Error("failed to resolve server address");
2764
+ const resolved = address;
2765
+ const url = new URL(`http://${hostname}:${resolved.port}`);
2766
+ return {
2767
+ app,
2768
+ async close() {
2769
+ await new Promise((resolve, reject) => {
2770
+ server.close((error) => {
2771
+ if (error) {
2772
+ reject(error);
2773
+ return;
2774
+ }
2775
+ resolve();
2776
+ });
2777
+ });
2778
+ },
2779
+ hostname,
2780
+ port: resolved.port,
2781
+ server,
2782
+ url
2783
+ };
2784
+ }
2785
+ //#endregion
2786
+ //#region src/commands/serve.ts
2787
+ function serveHelp() {
2788
+ return `Granola serve
2789
+
2790
+ Usage:
2791
+ granola serve [options]
2792
+
2793
+ Options:
2794
+ --hostname <value> Hostname to bind (default: 127.0.0.1)
2795
+ --port <value> Port to bind (default: 0 for any available port)
2796
+ --cache <path> Path to Granola cache JSON
2797
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
2798
+ --supabase <path> Path to supabase.json
2799
+ --debug Enable debug logging
2800
+ --config <path> Path to .granola.toml
2801
+ -h, --help Show help
2802
+ `;
2803
+ }
2804
+ const serveCommand = {
2805
+ description: "Start a local Granola API server",
2806
+ flags: {
2807
+ cache: { type: "string" },
2808
+ help: { type: "boolean" },
2809
+ hostname: { type: "string" },
2810
+ port: { type: "string" },
2811
+ timeout: { type: "string" }
2812
+ },
2813
+ help: serveHelp,
2814
+ name: "serve",
2815
+ async run({ commandFlags, globalFlags }) {
2816
+ const config = await loadConfig({
2817
+ globalFlags,
2818
+ subcommandFlags: commandFlags
2819
+ });
2820
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2821
+ debug(config.debug, "supabase", config.supabase);
2822
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2823
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2824
+ const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
2825
+ hostname: pickHostname(commandFlags.hostname),
2826
+ port: parsePort(commandFlags.port)
2827
+ });
2828
+ console.log(`Granola server listening on ${server.url.href}`);
2829
+ console.log("Endpoints:");
2830
+ console.log(" GET /health");
2831
+ console.log(" GET /state");
2832
+ console.log(" GET /events");
2833
+ console.log(" GET /meetings");
2834
+ console.log(" GET /meetings/:id");
2835
+ console.log(" POST /exports/notes");
2836
+ console.log(" POST /exports/transcripts");
2837
+ await waitForShutdown(async () => await server.close());
2838
+ return 0;
2839
+ }
2840
+ };
2841
+ //#endregion
2013
2842
  //#region src/commands/transcripts.ts
2014
2843
  function transcriptsHelp() {
2015
2844
  return `Granola transcripts
@@ -2065,12 +2894,115 @@ function resolveTranscriptFormat(value) {
2065
2894
  }
2066
2895
  }
2067
2896
  //#endregion
2897
+ //#region src/browser.ts
2898
+ const execFileAsync = promisify(execFile);
2899
+ function getBrowserOpenCommand(url, platform = process.platform) {
2900
+ const href = String(url);
2901
+ switch (platform) {
2902
+ case "darwin": return {
2903
+ args: [href],
2904
+ file: "open"
2905
+ };
2906
+ case "win32": return {
2907
+ args: [
2908
+ "/c",
2909
+ "start",
2910
+ "",
2911
+ href
2912
+ ],
2913
+ file: "cmd"
2914
+ };
2915
+ default: return {
2916
+ args: [href],
2917
+ file: "xdg-open"
2918
+ };
2919
+ }
2920
+ }
2921
+ async function openExternalUrl(url, options = {}) {
2922
+ const command = getBrowserOpenCommand(url, options.platform);
2923
+ await (options.run ?? (async (file, args) => {
2924
+ await execFileAsync(file, args);
2925
+ }))(command.file, command.args);
2926
+ }
2927
+ //#endregion
2928
+ //#region src/commands/web.ts
2929
+ function webHelp() {
2930
+ return `Granola web
2931
+
2932
+ Usage:
2933
+ granola web [options]
2934
+
2935
+ Options:
2936
+ --hostname <value> Hostname to bind (default: 127.0.0.1)
2937
+ --port <value> Port to bind (default: 0 for any available port)
2938
+ --cache <path> Path to Granola cache JSON
2939
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
2940
+ --supabase <path> Path to supabase.json
2941
+ --open[=true|false] Open the browser automatically (default: true)
2942
+ --debug Enable debug logging
2943
+ --config <path> Path to .granola.toml
2944
+ -h, --help Show help
2945
+ `;
2946
+ }
2947
+ //#endregion
2068
2948
  //#region src/commands/index.ts
2069
2949
  const commands = [
2070
2950
  authCommand,
2071
2951
  meetingCommand,
2072
2952
  notesCommand,
2073
- transcriptsCommand
2953
+ serveCommand,
2954
+ transcriptsCommand,
2955
+ {
2956
+ description: "Start the Granola Toolkit web workspace",
2957
+ flags: {
2958
+ cache: { type: "string" },
2959
+ help: { type: "boolean" },
2960
+ hostname: { type: "string" },
2961
+ open: { type: "boolean" },
2962
+ port: { type: "string" },
2963
+ timeout: { type: "string" }
2964
+ },
2965
+ help: webHelp,
2966
+ name: "web",
2967
+ async run({ commandFlags, globalFlags }) {
2968
+ const config = await loadConfig({
2969
+ globalFlags,
2970
+ subcommandFlags: commandFlags
2971
+ });
2972
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2973
+ debug(config.debug, "supabase", config.supabase);
2974
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2975
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2976
+ const app = await createGranolaApp(config, { surface: "web" });
2977
+ const hostname = pickHostname(commandFlags.hostname);
2978
+ const port = parsePort(commandFlags.port);
2979
+ const openBrowser = commandFlags.open !== false;
2980
+ const server = await startGranolaServer(app, {
2981
+ enableWebClient: true,
2982
+ hostname,
2983
+ port
2984
+ });
2985
+ console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
2986
+ console.log("Routes:");
2987
+ console.log(" GET /");
2988
+ console.log(" GET /health");
2989
+ console.log(" GET /state");
2990
+ console.log(" GET /events");
2991
+ console.log(" GET /meetings");
2992
+ console.log(" GET /meetings/:id");
2993
+ console.log(" POST /exports/notes");
2994
+ console.log(" POST /exports/transcripts");
2995
+ if (openBrowser) try {
2996
+ await openExternalUrl(server.url);
2997
+ } catch (error) {
2998
+ const message = error instanceof Error ? error.message : String(error);
2999
+ console.error(`failed to open browser automatically: ${message}`);
3000
+ console.error(`open ${server.url.href} manually`);
3001
+ }
3002
+ await waitForShutdown(async () => await server.close());
3003
+ return 0;
3004
+ }
3005
+ }
2074
3006
  ];
2075
3007
  const commandMap = new Map(commands.map((command) => [command.name, command]));
2076
3008
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",