granola-toolkit 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/cli.js +267 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ 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
|
|
39
40
|
```
|
|
40
41
|
|
|
@@ -47,6 +48,7 @@ vp pack
|
|
|
47
48
|
node dist/cli.js --help
|
|
48
49
|
node dist/cli.js meeting --help
|
|
49
50
|
node dist/cli.js notes --help
|
|
51
|
+
node dist/cli.js serve --help
|
|
50
52
|
node dist/cli.js transcripts --help
|
|
51
53
|
```
|
|
52
54
|
|
|
@@ -89,6 +91,14 @@ granola meeting transcript 1234abcd --format json
|
|
|
89
91
|
granola meeting export 1234abcd --format yaml
|
|
90
92
|
```
|
|
91
93
|
|
|
94
|
+
Run the local API server:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
granola serve
|
|
98
|
+
granola serve --port 4096
|
|
99
|
+
granola serve --hostname 0.0.0.0 --port 4096
|
|
100
|
+
```
|
|
101
|
+
|
|
92
102
|
## How It Works
|
|
93
103
|
|
|
94
104
|
### Notes
|
|
@@ -167,6 +177,22 @@ The machine-readable `export` command includes:
|
|
|
167
177
|
- structured note data plus rendered Markdown
|
|
168
178
|
- structured transcript data plus rendered transcript text when available
|
|
169
179
|
|
|
180
|
+
### Server
|
|
181
|
+
|
|
182
|
+
`serve` starts a long-lived local `Granola Toolkit` server on one shared app instance.
|
|
183
|
+
|
|
184
|
+
The initial server API includes:
|
|
185
|
+
|
|
186
|
+
- `GET /health`
|
|
187
|
+
- `GET /state`
|
|
188
|
+
- `GET /events` for server-sent state updates
|
|
189
|
+
- `GET /meetings`
|
|
190
|
+
- `GET /meetings/:id`
|
|
191
|
+
- `POST /exports/notes`
|
|
192
|
+
- `POST /exports/transcripts`
|
|
193
|
+
|
|
194
|
+
This is the foundation for the future `granola web` client and any attachable TUI flows.
|
|
195
|
+
|
|
170
196
|
## Auth
|
|
171
197
|
|
|
172
198
|
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;
|
|
@@ -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,
|
|
@@ -2010,6 +2032,250 @@ function resolveNoteFormat(value) {
|
|
|
2010
2032
|
}
|
|
2011
2033
|
}
|
|
2012
2034
|
//#endregion
|
|
2035
|
+
//#region src/server/http.ts
|
|
2036
|
+
function parseInteger(value) {
|
|
2037
|
+
if (!value?.trim()) return;
|
|
2038
|
+
if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
|
|
2039
|
+
const parsed = Number(value);
|
|
2040
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
|
|
2041
|
+
return parsed;
|
|
2042
|
+
}
|
|
2043
|
+
function sendJson(response, body, init = {}) {
|
|
2044
|
+
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
2045
|
+
response.writeHead(init.status ?? 200, {
|
|
2046
|
+
"content-length": Buffer.byteLength(payload),
|
|
2047
|
+
"content-type": "application/json; charset=utf-8"
|
|
2048
|
+
});
|
|
2049
|
+
response.end(payload);
|
|
2050
|
+
}
|
|
2051
|
+
function sendText(response, body, status = 200) {
|
|
2052
|
+
response.writeHead(status, {
|
|
2053
|
+
"content-length": Buffer.byteLength(body),
|
|
2054
|
+
"content-type": "text/plain; charset=utf-8"
|
|
2055
|
+
});
|
|
2056
|
+
response.end(body);
|
|
2057
|
+
}
|
|
2058
|
+
async function readJsonBody(request) {
|
|
2059
|
+
const chunks = [];
|
|
2060
|
+
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2061
|
+
if (chunks.length === 0) return {};
|
|
2062
|
+
try {
|
|
2063
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2064
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
|
|
2065
|
+
return parsed;
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
throw new Error(error instanceof Error ? error.message : "failed to parse JSON body");
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
function formatSseEvent(event) {
|
|
2071
|
+
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
2072
|
+
}
|
|
2073
|
+
function noteFormatFromBody(value) {
|
|
2074
|
+
switch (value) {
|
|
2075
|
+
case void 0:
|
|
2076
|
+
case "markdown": return "markdown";
|
|
2077
|
+
case "json":
|
|
2078
|
+
case "raw":
|
|
2079
|
+
case "yaml": return value;
|
|
2080
|
+
default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
function transcriptFormatFromBody(value) {
|
|
2084
|
+
switch (value) {
|
|
2085
|
+
case void 0:
|
|
2086
|
+
case "text": return "text";
|
|
2087
|
+
case "json":
|
|
2088
|
+
case "raw":
|
|
2089
|
+
case "yaml": return value;
|
|
2090
|
+
default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
async function startGranolaServer(app, options = {}) {
|
|
2094
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
2095
|
+
const port = options.port ?? 0;
|
|
2096
|
+
const server = createServer(async (request, response) => {
|
|
2097
|
+
const method = request.method ?? "GET";
|
|
2098
|
+
const url = new URL(request.url ?? "/", `http://${hostname}`);
|
|
2099
|
+
const path = url.pathname;
|
|
2100
|
+
try {
|
|
2101
|
+
if (method === "GET" && path === "/health") {
|
|
2102
|
+
sendJson(response, {
|
|
2103
|
+
ok: true,
|
|
2104
|
+
service: "granola-toolkit",
|
|
2105
|
+
version: app.config ? void 0 : void 0
|
|
2106
|
+
});
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
if (method === "GET" && path === "/state") {
|
|
2110
|
+
sendJson(response, app.getState());
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
if (method === "GET" && path === "/events") {
|
|
2114
|
+
response.writeHead(200, {
|
|
2115
|
+
"cache-control": "no-cache, no-transform",
|
|
2116
|
+
connection: "keep-alive",
|
|
2117
|
+
"content-type": "text/event-stream; charset=utf-8"
|
|
2118
|
+
});
|
|
2119
|
+
response.write(formatSseEvent({
|
|
2120
|
+
state: app.getState(),
|
|
2121
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2122
|
+
type: "state.updated"
|
|
2123
|
+
}));
|
|
2124
|
+
const unsubscribe = app.subscribe((event) => {
|
|
2125
|
+
response.write(formatSseEvent(event));
|
|
2126
|
+
});
|
|
2127
|
+
request.on("close", () => {
|
|
2128
|
+
unsubscribe();
|
|
2129
|
+
response.end();
|
|
2130
|
+
});
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
if (method === "GET" && path === "/meetings") {
|
|
2134
|
+
const limit = parseInteger(url.searchParams.get("limit"));
|
|
2135
|
+
const search = url.searchParams.get("search")?.trim() || void 0;
|
|
2136
|
+
sendJson(response, {
|
|
2137
|
+
meetings: await app.listMeetings({
|
|
2138
|
+
limit,
|
|
2139
|
+
search
|
|
2140
|
+
}),
|
|
2141
|
+
search
|
|
2142
|
+
});
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (method === "GET" && path.startsWith("/meetings/")) {
|
|
2146
|
+
const id = decodeURIComponent(path.slice(10));
|
|
2147
|
+
if (!id) throw new Error("meeting id is required");
|
|
2148
|
+
sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
if (method === "POST" && path === "/exports/notes") {
|
|
2152
|
+
const body = await readJsonBody(request);
|
|
2153
|
+
sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
if (method === "POST" && path === "/exports/transcripts") {
|
|
2157
|
+
const body = await readJsonBody(request);
|
|
2158
|
+
sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
sendText(response, "Not found\n", 404);
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
sendJson(response, { error: error instanceof Error ? error.message : String(error) }, { status: 400 });
|
|
2164
|
+
}
|
|
2165
|
+
});
|
|
2166
|
+
await new Promise((resolve, reject) => {
|
|
2167
|
+
server.once("error", reject);
|
|
2168
|
+
server.listen(port, hostname, () => {
|
|
2169
|
+
server.off("error", reject);
|
|
2170
|
+
resolve();
|
|
2171
|
+
});
|
|
2172
|
+
});
|
|
2173
|
+
const address = server.address();
|
|
2174
|
+
if (!address || typeof address === "string") throw new Error("failed to resolve server address");
|
|
2175
|
+
const resolved = address;
|
|
2176
|
+
const url = new URL(`http://${hostname}:${resolved.port}`);
|
|
2177
|
+
return {
|
|
2178
|
+
app,
|
|
2179
|
+
async close() {
|
|
2180
|
+
await new Promise((resolve, reject) => {
|
|
2181
|
+
server.close((error) => {
|
|
2182
|
+
if (error) {
|
|
2183
|
+
reject(error);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
resolve();
|
|
2187
|
+
});
|
|
2188
|
+
});
|
|
2189
|
+
},
|
|
2190
|
+
hostname,
|
|
2191
|
+
port: resolved.port,
|
|
2192
|
+
server,
|
|
2193
|
+
url
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
//#endregion
|
|
2197
|
+
//#region src/commands/serve.ts
|
|
2198
|
+
function serveHelp() {
|
|
2199
|
+
return `Granola serve
|
|
2200
|
+
|
|
2201
|
+
Usage:
|
|
2202
|
+
granola serve [options]
|
|
2203
|
+
|
|
2204
|
+
Options:
|
|
2205
|
+
--hostname <value> Hostname to bind (default: 127.0.0.1)
|
|
2206
|
+
--port <value> Port to bind (default: 0 for any available port)
|
|
2207
|
+
--cache <path> Path to Granola cache JSON
|
|
2208
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
2209
|
+
--supabase <path> Path to supabase.json
|
|
2210
|
+
--debug Enable debug logging
|
|
2211
|
+
--config <path> Path to .granola.toml
|
|
2212
|
+
-h, --help Show help
|
|
2213
|
+
`;
|
|
2214
|
+
}
|
|
2215
|
+
function parsePort(value) {
|
|
2216
|
+
if (value === void 0) return;
|
|
2217
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
|
|
2218
|
+
const port = Number(value);
|
|
2219
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
|
|
2220
|
+
return port;
|
|
2221
|
+
}
|
|
2222
|
+
const serveCommand = {
|
|
2223
|
+
description: "Start a local Granola API server",
|
|
2224
|
+
flags: {
|
|
2225
|
+
cache: { type: "string" },
|
|
2226
|
+
help: { type: "boolean" },
|
|
2227
|
+
hostname: { type: "string" },
|
|
2228
|
+
port: { type: "string" },
|
|
2229
|
+
timeout: { type: "string" }
|
|
2230
|
+
},
|
|
2231
|
+
help: serveHelp,
|
|
2232
|
+
name: "serve",
|
|
2233
|
+
async run({ commandFlags, globalFlags }) {
|
|
2234
|
+
const config = await loadConfig({
|
|
2235
|
+
globalFlags,
|
|
2236
|
+
subcommandFlags: commandFlags
|
|
2237
|
+
});
|
|
2238
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
2239
|
+
debug(config.debug, "supabase", config.supabase);
|
|
2240
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
2241
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
2242
|
+
const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
|
|
2243
|
+
hostname: typeof commandFlags.hostname === "string" && commandFlags.hostname.trim() ? commandFlags.hostname.trim() : "127.0.0.1",
|
|
2244
|
+
port: parsePort(commandFlags.port)
|
|
2245
|
+
});
|
|
2246
|
+
console.log(`Granola server listening on ${server.url.href}`);
|
|
2247
|
+
console.log("Endpoints:");
|
|
2248
|
+
console.log(" GET /health");
|
|
2249
|
+
console.log(" GET /state");
|
|
2250
|
+
console.log(" GET /events");
|
|
2251
|
+
console.log(" GET /meetings");
|
|
2252
|
+
console.log(" GET /meetings/:id");
|
|
2253
|
+
console.log(" POST /exports/notes");
|
|
2254
|
+
console.log(" POST /exports/transcripts");
|
|
2255
|
+
await new Promise((resolve, reject) => {
|
|
2256
|
+
let closing = false;
|
|
2257
|
+
const close = async () => {
|
|
2258
|
+
if (closing) return;
|
|
2259
|
+
closing = true;
|
|
2260
|
+
process.off("SIGINT", handleSignal);
|
|
2261
|
+
process.off("SIGTERM", handleSignal);
|
|
2262
|
+
try {
|
|
2263
|
+
await server.close();
|
|
2264
|
+
resolve();
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
reject(error);
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
const handleSignal = () => {
|
|
2270
|
+
close();
|
|
2271
|
+
};
|
|
2272
|
+
process.on("SIGINT", handleSignal);
|
|
2273
|
+
process.on("SIGTERM", handleSignal);
|
|
2274
|
+
});
|
|
2275
|
+
return 0;
|
|
2276
|
+
}
|
|
2277
|
+
};
|
|
2278
|
+
//#endregion
|
|
2013
2279
|
//#region src/commands/transcripts.ts
|
|
2014
2280
|
function transcriptsHelp() {
|
|
2015
2281
|
return `Granola transcripts
|
|
@@ -2070,6 +2336,7 @@ const commands = [
|
|
|
2070
2336
|
authCommand,
|
|
2071
2337
|
meetingCommand,
|
|
2072
2338
|
notesCommand,
|
|
2339
|
+
serveCommand,
|
|
2073
2340
|
transcriptsCommand
|
|
2074
2341
|
];
|
|
2075
2342
|
const commandMap = new Map(commands.map((command) => [command.name, command]));
|