diff --git a/README.md b/README.md index 8400187..6474aef 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,199 @@ # @corbits/granola -Granola meeting-notes tools for Corbits agents, plus an optional webhook ingress -extension. Skeleton package — no Granola API calls are implemented yet; this is the -foundation the real client and dispatch logic land into. +Granola meeting-notes client and agent tools for Corbits hosts, plus optional +webhook ingress and ingest extensions. The base entry point is a plain REST +client (`getNote` / `listNotes` / `listFolders`) and two grantable tool +definitions; `/ingress` receives Granola webhooks, verifies signatures, and +keeps the Granola-side webhook registration converged; `/ingest` is the +host-agnostic pipeline that turns an acked webhook event into a persisted +transcript and dispatched bucket handler. -## Two entry points, one dependency direction +## Three entry points, one dependency direction | Entry point | What it is | Depends on | | --- | --- | --- | -| `@corbits/granola` | The Granola API client and the tools an agent calls (fetch a note, search notes) | nothing hub-shaped | -| `@corbits/granola/ingress` | The extension that receives Granola webhooks, verifies signatures, and dispatches notes to handlers | `@corbits/granola` | - -**The tools are usable standalone, without the ingress extension.** `@corbits/granola` -carries zero dependency on any hub, mounting, extension or webhook machinery — import it -and grant its tools to any agent exactly like any other plain Interchange tool, and -nothing hub-shaped comes along for the ride. `@corbits/granola/ingress` is the one -direction of coupling: it depends on the tools to do its job. The tools never depend on -it, and that direction is enforced structurally, not just by convention — see +| `@corbits/granola` | Granola API client, note/folder types, agent tool definitions | nothing hub-shaped | +| `@corbits/granola/ingress` | Webhook mount, signature verification, folder-binding store, webhook registration | `@corbits/granola`, `hono` (peer) | +| `@corbits/granola/ingest` | Webhook event → note fetch → bucket dispatch → knowledge capture pipeline | `@corbits/granola` | + +The tools are usable standalone. `/ingress` and `/ingest` each depend on the +tools, never the reverse — enforced structurally, see [ARCHITECTURE.md](./ARCHITECTURE.md). ## Install ```bash -# From git (Bun) bun add github:corbitsdev/corbits-granola - -# npm / pack -npm install @corbits/granola +# or pin a commit: +bun add github:corbitsdev/corbits-granola# ``` -> **Not on npm yet.** Until the first release, consume it from git or an `npm pack` -> tarball. This repository root *is* the package, so git installs resolve cleanly. +Not on npm yet; consume from git or an `npm pack` tarball. The repository root +*is* the package. `@corbits/granola/ingress` additionally requires `hono` ^4 as +a peer dependency. -## Use +## Configuration + +The package takes explicit options — it never reads `process.env` itself. A +host supplies: + +| Option | Meaning | Typical env var (host-defined) | +| --- | --- | --- | +| `apiKey` | Granola API key, sent as `Authorization: Bearer` | `GRANOLA_API_KEY` | +| `baseUrl` | Granola REST base URL; defaults to `https://public-api.granola.ai/v1` | `GRANOLA_API_BASE_URL` | +| `publicUrl` | Public HTTPS origin your host is reachable at; used to compute the webhook delivery URL. `undefined` disables webhook registration/reconciliation | `GRANOLA_PUBLIC_URL` | +| `envSecret` (ingress) | Pre-provisioned webhook signing secret, if you have one; otherwise `ensureGranolaWebhook` provisions one | `GRANOLA_WEBHOOK_SECRET` | +| seed bindings (ingress) | Initial folder → bucket-type → channel bindings, `[{folderId, type, channel}]` with `type` one of `"diligence" | "internal"` | `GRANOLA_BUCKETS` (JSON) | + +## Quickstart: client and tools (no webhook) ```ts -import { GranolaClient, GRANOLA_TOOL_DEFINITIONS } from "@corbits/granola"; +import { + createGranolaClient, + transcriptText, + GRANOLA_TOOL_DEFINITIONS, +} from "@corbits/granola"; + +const client = createGranolaClient({ apiKey: process.env.GRANOLA_API_KEY! }); -const client = new GranolaClient({ apiKey: process.env.GRANOLA_API_KEY! }); -// Grant GRANOLA_TOOL_DEFINITIONS to any agent, no hub required. +const { notes } = await client.listNotes({ limit: 10 }); +const note = await client.getNote(notes[0].id); +console.log(note.title, transcriptText(note)); + +// Grant to any agent like any other Interchange tool — no hub required. +// Tool names: granola_fetch_note, granola_search_notes. +for (const tool of GRANOLA_TOOL_DEFINITIONS) grant(tool); ``` +## Quickstart: webhook ingress + +Mounting is three steps: build a binding store, converge the Granola-side +webhook registration, then mount the webhook route on a Hono app. This is the +pattern Scout uses in production, simplified: + ```ts -// Only if you also want the webhook extension. -import { verifyGranolaSignature } from "@corbits/granola/ingress"; +import { Hono } from "hono"; +import { createGranolaClient } from "@corbits/granola"; +import { + createGranolaBindingStore, + ensureGranolaWebhook, + mountGranolaWebhook, + reconcileGranolaWebhookFolders, +} from "@corbits/granola/ingress"; + +const apiKey = process.env.GRANOLA_API_KEY!; +const baseUrl = "https://public-api.granola.ai/v1"; +const publicUrl = process.env.GRANOLA_PUBLIC_URL; // https:// origin, or undefined + +// 1. Durable folder -> bucket-type -> channel bindings. `port` is your +// persistence adapter (implement GranolaBindingsPort over your own store). +const bindingStore = createGranolaBindingStore({ + port: myBindingsPort, + tenantId, + principalId, + seedBindings: [], // or parsed GRANOLA_BUCKETS + onChange: (bindings) => + publicUrl === undefined + ? undefined + : reconcileGranolaWebhookFolders({ + apiKey, + baseUrl, + publicUrl, + folderIds: bindings.map((b) => b.folderId), + }), +}); + +// 2. Register (or verify) the webhook endpoint with Granola. Returns the +// signing secret; undefined means registration could not happen (e.g. no +// publicUrl and no envSecret) — skip mounting in that case. +const secret = await ensureGranolaWebhook({ + apiKey, + baseUrl, + publicUrl, + bindingStore, + envSecret: process.env.GRANOLA_WEBHOOK_SECRET, +}); +if (secret === undefined) return; + +// 3. Mount. POSTs land at /api/granola/webhook; signatures are verified +// before your handler runs, and events are acked before dispatch. +const app = new Hono(); +mountGranolaWebhook(app, { + secret, + onEvent: async (payload) => { + const note = await createGranolaClient({ apiKey, baseUrl }).getNote(payload.noteId); + // route by bindingStore lookup on the note's folder… + }, +}); ``` +The webhook route is `POST /api/granola/webhook` on whatever app you pass in. +A failed `onEvent` after ack is not redelivered by Granola; a later event for +the same note (or a manual re-drop) is the recovery path. + +## Quickstart: ingest pipeline + +`/ingress` mounts the webhook, verifies it, and acks; `/ingest` is the +processing pipeline behind it — everything downstream of that ack: fetch the +note, resolve its bucket, persist the transcript, capture knowledge, then +dispatch to a bucket-type handler. It's chat- and host-agnostic, generic over +`TRef` (whatever your transcript store's `persist` returns) and `TAnchor` +(whatever your `lifecycle.onProcessingStarted` returns) — both threaded +through unexamined. + +```ts +import { createGranolaClient } from "@corbits/granola"; +import { mountGranolaWebhook } from "@corbits/granola/ingress"; +import { createGranolaIngest } from "@corbits/granola/ingest"; + +const ingest = createGranolaIngest({ + client: createGranolaClient({ apiKey, baseUrl }), + bindingStore, + transcripts: myTranscriptStore, // { hasTranscript, persist } -> TRef + captureKnowledge: myKnowledgeCapture, + lifecycle: myLifecycle, // onProcessingStarted, onTranscriptReady, ... -> TAnchor + handlers: { diligence: myDiligenceHandler, internal: myInternalHandler }, +}); + +mountGranolaWebhook(app, { secret, onEvent: ingest }); + +// A host's "reprocess" affordance re-enters bypassing the already-processed gate. +await ingest.reprocess(noteId); +``` + +## API surface + +`@corbits/granola` + +- `createGranolaClient(options)` → `{ getNote, listNotes, listFolders }` +- `transcriptText(note)` / `speakerLabel(speaker)` — transcript helpers +- `GranolaNote`, `GranolaBucket`, `GranolaBucketType`, `GranolaBucketsArray` — validated data shapes (arktype) +- `fetchNoteTool`, `searchNotesTool`, `GRANOLA_TOOL_DEFINITIONS` — agent tool definitions +- `GranolaApiError` — thrown on non-2xx API responses + +`@corbits/granola/ingress` + +- `mountGranolaWebhook(app, {secret, onEvent})` — mounts `POST /api/granola/webhook` +- `ensureGranolaWebhook(options)` — registers/verifies the webhook with Granola, returns the signing secret +- `reconcileGranolaWebhookFolders(options)` — keeps the registration's `folder_ids` matching your bindings +- `createGranolaBindingStore(options)` — durable folder bindings over a host-supplied `GranolaBindingsPort` +- `verifyGranolaSignature` / `signGranolaPayload` / `parseGranolaPayload` — signature primitives, if you mount by hand + +`@corbits/granola/ingest` + +- `createGranolaIngest(options)` → `GranolaIngest` — the webhook `onEvent` handler, plus `.reprocess(noteId)` / `.reprocessPinned(noteId, companies)` re-entry +- `GranolaIngestHandlers`, `GranolaBucketHandler`, `GranolaBucketHandlerContext`, `GranolaThreadAnchor` — the per-bucket-type handler contract +- `GranolaIngestLifecycle` — the human-visible-event hooks a host implements to render its own copy +- `GranolaTranscriptStore`, `GranolaKnowledgeCapture` — the persistence and enrichment ports a host supplies + +## Used in production + +Scout mounts this package end to end — client construction, binding store, +webhook registration, and event dispatch — in +`packages/scout/src/granola/mount.ts` and reads its env in +`packages/scout/src/granola/config.ts` of the Scout repository. That wiring is +the reference consumer for everything above. + ## Working on it ```sh @@ -55,8 +203,7 @@ bun run test bun run build ``` -See [CONTRIBUTING.md](./CONTRIBUTING.md) for the dependency rule and test conventions, -and [ARCHITECTURE.md](./ARCHITECTURE.md) for the design. +See [CONTRIBUTING.md](./CONTRIBUTING.md) and [ARCHITECTURE.md](./ARCHITECTURE.md). ## License