diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ef6485b..59455bf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,48 +1,159 @@ -# Architecture +# @corbits/granola — Architecture -## Three faces, one dependency direction +## Shape -This package has three independent faces, each with its own source directory: +Three faces, one dependency direction. -- `src/tools/` — the Granola API client and the tools an agent calls (fetch a note, - search notes). Published as the package root, `@corbits/granola`. -- `src/ingress/` — the extension that receives Granola webhooks, verifies signatures, - and dispatches notes to handlers. Published as the subpath export +``` +host + │ + ├─ grant tools ──▶ @corbits/granola src/tools + │ ▲ + │ │ client + note/bucket shapes + ├─ mount webhook ─▶ @corbits/granola/ingress src/ingress + │ ▲ + │ │ binding store + webhook payload + └─ after ack ─────▶ @corbits/granola/ingest src/ingest +``` + +- `src/tools/` — Granola REST client and the two grantable tool definitions. + Published as the package root, `@corbits/granola`. +- `src/ingress/` — signature verification, Hono mount, durable folder + bindings, and webhook-endpoint reconciliation. Published as `@corbits/granola/ingress`. -- `src/ingest/` — the host-agnostic pipeline that turns an acked webhook event into a - persisted transcript and a dispatched bucket handler: fetch note, resolve bucket, - persist, capture knowledge, hand off. Published as the subpath export - `@corbits/granola/ingest`. - -`src/ingress` depends on `src/tools` for the client. `src/ingest` depends on both — -`src/tools` for the client and note shapes, `src/ingress` for the binding store and -webhook payload it processes. `src/tools` must never depend on `src/ingress` or -`src/ingest`, or on any hub, mounting, extension or webhook machinery at all — the -operator requirement is that someone can import the tools and grant them to any agent -like any other plain Interchange tool, and nothing hub-shaped comes along for the ride. +- `src/ingest/` — host-agnostic pipeline after ack: fetch note, resolve + bucket, persist transcript, capture knowledge, dispatch handler. + Published as `@corbits/granola/ingest`. + +`src/ingress` depends on `src/tools` for the client and bucket shapes. +`src/ingest` depends on both — `src/tools` for the client and note shapes, +`src/ingress` for the binding store and the webhook payload it processes. +`src/tools` must never depend on `src/ingress` or `src/ingest`, or on any +hub, mounting, extension, or webhook machinery. Someone can import the +tools and grant them like any other Interchange tool; nothing hub-shaped +comes along. ## The rule is enforced, not just documented -A convention nobody checks gets violated. `scripts/check-deps.ts` greps `src/tools` for -any reference to `ingress` and fails the build if it finds one. It runs in CI as its own -job, separate from typecheck and test, so a dependency-direction leak fails loudly and -specifically rather than getting buried in an unrelated job's log. +`scripts/check-deps.ts` greps `src/tools` for any import of `ingress` and +fails the build if it finds one. It runs in CI as its own job, separate +from typecheck and test. + +The intended rule is stronger: tools must not import ingest either. +`check-deps` does not yet grep for `ingest`. Reviewing a change to +`src/tools/`: if it needs anything from `src/ingress/` or `src/ingest/`, +the boundary is wrong, not a reason to import across it. + +## Why subpath exports, not three packages + +The client, the webhook, and the pipeline version and release together — a +Granola API surface change affects all three. Separate packages would let +them drift for no benefit here. One package with three `exports` entries +keeps them versioned together while still letting a consumer install +`@corbits/granola` and pull nothing from ingress or ingest into the import +graph until they ask for those subpaths. + +## Tools + +The client is Bearer-auth `fetch` against Granola's public API. Responses +are arktype-validated. Folder `name` from the API is published as `title` +so notes, summaries, and folders share field names. `folder_membership` is +left `unknown`; ingest, not the client, interprets it. + +Tool definitions are plain `{ name, description, handler }`. Handlers are +placeholders (`not implemented`). The catalog is `GRANOLA_TOOL_DEFINITIONS`; +callers dispatch on `name`. + +## Ingress + +Verification is pure: Standard Webhooks HMAC-SHA256 over +`{webhook-id}.{webhook-timestamp}.{raw body}`, keyed on the base64-decoded +signing secret (`whsec_` prefix stripped). Multiple space-separated `v1,` +signatures are accepted (rotation). Stale timestamps and short keys fail +closed. + +`mountGranolaWebhook` is the only Hono concretion. Granola is not a +principal; the signature is the only authentication. No secret, or an +unusable secret, means the route is not mounted — that is a valid +configuration, not an error. + +Granola gives the endpoint 15 seconds and retries only on non-2xx. The +route acks `202` before `onEvent` resolves. A post-ack failure is logged, +not retried by the vendor. In-memory `event_id` dedupe (bounded FIFO) +guards Granola's at-least-once duplicates; it remembers an id only after +`onEvent` succeeds. A host restart forgets the set. + +`GranolaBindingsPort` is the persistence seam. `createGranolaBindingStore` +is seed-on-first-write: `list()` never writes; seed bindings remain until +an explicit `replaceAll`. Cache is process-local — a binding edit on one +replica is invisible to others until restart. + +`ensureGranolaWebhook` reconciles the vendor subscription at boot. +`signing_secret` is returned only on create and cannot be fetched later. +A matching endpoint with no env secret leaves the webhook unmounted rather +than delete-and-recreate (which would loop every restart). A redacted URL +in the list with no exact match does not create (duplicate risk) and falls +back to the env secret. API failures never crash host boot. + +`reconcileGranolaWebhookFolders` is the `onChange` target so a binding +edit can converge `folder_ids` without a reboot. + +## Ingest + +`createGranolaIngest` returns the `onEvent` function the mount calls after +verify, dedupe, and ack. The pipeline is generic over the host: + +| Seam | Role | +| --- | --- | +| `client` | Fetch the note (with transcript). | +| `bindingStore` | Folder id → `{ type, channel }`. | +| `transcripts` | `hasTranscript` / `persist`; `TRef` is opaque. | +| `captureKnowledge` | Enrichment after persist; failures are swallowed. | +| `lifecycle` | One hook per human-visible event; `TAnchor` is opaque. | +| `handlers` | Per-bucket-type behavior (`diligence` / `internal`). | + +``` +event ─▶ fetch note ─▶ resolve bucket ─▶ already-processed? + │ no + ▼ + onProcessingStarted (anchor) + │ + not ready? ──retry once ── give up + │ ready + ▼ + persist ─▶ capture ─▶ handler +``` + +A note with no bound folder is ignored (nowhere to notify). Multiple +folder matches take the first. `folder_membership` is tolerated as an id, +a list of ids, or objects with `folderId` / `id`; unexpected shapes become +"no bucket" rather than a crash. + +The durable already-processed gate is a persisted transcript, not the +in-flight map. A re-added note does not silently skip or blindly rerun; +`onAlreadyProcessed` asks the human. `reprocess` / `reprocessPinned` +bypass that gate. Concurrent events for the same `note_id` drop with +`onDuplicateEvent` rather than queue. -Reviewing a change to `src/tools/`: if it needs anything from `src/ingress/`, that is a -sign the boundary is wrong, not a reason to import across it. +In-flight state and the not-ready retry timer are process-local. A restart +inside the retry window loses the timer; the next webhook event starts +from scratch. -## Why subpath exports, not two packages +## Failure modes -The tools and the ingress extension version and release together — a Granola API -surface change affects both. Two `package.json` files (as some sibling repositories use -for genuinely independent packages) would let them drift out of sync for no benefit -here. One package with two `exports` entries keeps them versioned together while still -letting a consumer install `@corbits/granola` alone and get nothing from `src/ingress/` -in their dependency graph. +- Hung Granola HTTP must not hold ingest's in-flight guard forever (client + timeout) or wedge host boot (registration timeout). +- `note.access_granted` + 404 means the note is not generated yet, not a + fetch failure. Recovery is `note.generated` after a human generates + notes in Granola. +- Knowledge-capture errors must not skip the bucket handler. +- Handler errors are reported through `onHandlerFailed`; they do not + un-ack the webhook. +- Lifecycle hooks that throw are logged; they do not rewrite pipeline + control flow except `onProcessingStarted`, which aborts the run if it + cannot establish an anchor. -## What's not here yet +## What is still placeholder -This is a skeleton. `src/tools/client.ts` and `src/tools/tools.ts` have no real Granola -API calls; `src/ingress/webhook.ts` has no real signature verification or dispatch. That -extraction is scoped and lands separately — this repository establishes the structure -and the CI gate it has to pass. +Tool handlers. The client, webhook verification, registration, binding +store, and ingest pipeline are real. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac5fd2c..dc09250 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ A small, deliberately boring codebase: strict TypeScript, no magic. -Setup and the commands are in the [README](./README.md#working-on-it). `bun run +Setup and the commands are in the [README](./README.md#development). `bun run typecheck` must be clean — it is its own CI step, and `any` is not a way past it. ## Dependency rule diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..034e81d --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,155 @@ +# @corbits/granola — Implementation + +## Package + +- Name: `@corbits/granola` `0.1.0` +- License: LGPL-2.1-only +- Engines: Node `>= 24` consumes built `dist/`. Bun loads TypeScript source + via the `bun` export condition. +- Public exports: + + | Subpath | Bun | Node / types | + | --- | --- | --- | + | `.` | `src/tools/index.ts` | `dist/tools/index.js` + `.d.ts` | + | `./ingress` | `src/ingress/index.ts` | `dist/ingress/index.js` + `.d.ts` | + | `./ingest` | `src/ingest/index.ts` | `dist/ingest/index.js` + `.d.ts` | + +- npm tarball: `dist/`, `src/` (tests excluded), `LICENSE`, `README.md`. + These design docs live at the repository root and are not in `files`. +- `sideEffects: false`. The package never reads `process.env`. + +## Runtime dependencies + +- `arktype` — response, payload, and binding validation +- `@intx/log` — ingress mount, registration, binding store, ingest +- `hono` `^4` — **peer**, required only for `@corbits/granola/ingress` +- `node:crypto` — HMAC-SHA256 and `timingSafeEqual` in webhook verify + +## Install + +```sh +npm add @corbits/granola +pnpm add @corbits/granola +yarn add @corbits/granola +bun add @corbits/granola +``` + +Ingress also needs `hono` in the host. + +## Public surface + +From `@corbits/granola`: + +- `createGranolaClient({ apiKey, baseUrl?, fetchImpl? })` — + `{ getNote, listNotes, listFolders }` +- `transcriptText`, `speakerLabel`, `GranolaApiError`, `GranolaNote` +- `GranolaBucketType`, `GranolaBucketsArray` +- `fetchNoteTool`, `searchNotesTool`, `GRANOLA_TOOL_DEFINITIONS` + +From `@corbits/granola/ingress`: + +- `verifyGranolaSignature`, `decodeSigningSecret`, `signGranolaPayload`, + `parseGranolaPayload`, `MIN_SIGNING_KEY_BYTES`, `KNOWN_GRANOLA_EVENT_TYPES` +- `createGranolaBindingStore` +- `ensureGranolaWebhook`, `reconcileGranolaWebhookFolders` +- `mountGranolaWebhook` + +From `@corbits/granola/ingest`: + +- `createGranolaIngest` — callable `onEvent` plus `reprocess` / + `reprocessPinned` + +## Client + +Default `baseUrl`: `https://public-api.granola.ai/v1`. Docs: +https://docs.granola.ai (OpenAPI at +https://docs.granola.ai/api-reference/openapi.json). + +| Method | HTTP | +| --- | --- | +| `getNote(id, { includeTranscript? })` | `GET /notes/:id` (`include=transcript`) | +| `listNotes({ folderId, cursor?, pageSize? })` | `GET /notes?folder_id&page_size&cursor` | +| `listFolders({ cursor?, pageSize? })` | `GET /folders?page_size&cursor` | + +Auth: `Authorization: Bearer `. Also sends `Accept` and +`Content-Type: application/json` on GET (Granola otherwise answers +non-JSON on some routes). Never sets `User-Agent`. + +Timeout: `AbortSignal.timeout(30_000)` on every client request. Non-2xx +throws `GranolaApiError` with `status` and raw `body`. `null` cursors +normalize to absent. Default `pageSize` is 30. Folder API `name` maps to +public `title`. + +## Tools + +| Name | Args (intended) | Handler today | +| --- | --- | --- | +| `granola_fetch_note` | `{ noteId }` | throws `not implemented` | +| `granola_search_notes` | `{ query }` | throws `not implemented` | + +## Webhook protocol + +Standard Webhooks (https://docs.granola.ai/webhooks). Signed content: +`{webhook-id}.{webhook-timestamp}.{raw body}`. Secret is base64, commonly +prefixed `whsec_`; HMAC key is the decoded bytes. Reject if decoded key is +shorter than `MIN_SIGNING_KEY_BYTES` (16). Timestamp skew: +`SIGNATURE_TOLERANCE_SECONDS` = 300. + +Payload (arktype): `event_id`, `event_type` (any string), `note_id`, +`occurred_at`, optional `data.changed_fields`. Known event names (not a +closed union on the wire): `note.generated`, `note.regenerated`, +`note.edited`, `note.access_granted`. + +## Mount + +Absolute route: `POST /api/granola/webhook`. Body cap: 1 MiB (413 +`payload_too_large`). Missing/bad signature: 401 with `missing_headers` / +`invalid_timestamp` / `stale_timestamp` / `bad_signature`. Verified but +malformed JSON: 202 (terminal for Granola; logged). Duplicate `event_id`: +202. Success: 202, then `onEvent` in the background. Default dedupe +capacity: 1000. + +## Registration + +Granola REST, 10s timeout, no `User-Agent`. + +| | | +| --- | --- | +| List | `GET /webhook-endpoints` | +| Create | `POST /webhook-endpoints` (`url`, `scopes: ["public"]`, `events`, `folder_ids`) | +| Update | `PATCH /webhook-endpoints/:id` | + +Create is the only call that returns `signing_secret`. Target URL is +`{publicUrl}/api/granola/webhook`. Host env names in comments/logs: +`GRANOLA_WEBHOOK_SECRET`, `GRANOLA_PUBLIC_URL` (or a portal origin) — the +library still takes them as function arguments. + +## Bindings + +`GranolaBucket`: `{ folderId, type: "diligence" \| "internal", channel }`. +Port: `load({ tenantId })` → `{ bindings, version } | undefined`; +`save({ tenantId, principalId, bindings })`. + +## Ingest + +Default not-ready retry: 60s, once, via `setTimeout` (overridable +`retryDelayMs`). Persist text is transcript lines (`Speaker: text`), else +`summary_markdown`, else `summary_text`. Synthetic event types for +operator re-entry: `operator.reprocess`, `operator.pinned-diligence`. + +## Development + +```sh +bun install +bun run typecheck +bun run test +bun run test:tools +bun run test:ingress +bun run test:ingest +bun run check-deps +bun run build +``` + +CI (`.github/workflows/test.yml`): `check-deps`, `typecheck`, `test-tools`, +`test-ingress`, `test-ingest`, `build` as separate jobs; a Node 24 job +runs `npm install`, `typecheck`, and `build`. Bun in CI is `1.3.14`. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..9c4452a --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,65 @@ +# @corbits/granola — Product + +## What it is + +Granola meeting-notes for Interchange hosts: a REST client and two grantable +tool definitions, plus optional webhook ingress and an ingest pipeline. The +host imports what it needs. Agents that only fetch notes never take webhook +or ingest machinery with them. + +`@corbits/granola` is the client (`getNote` / `listNotes` / `listFolders`) +and the tool names `granola_fetch_note` and `granola_search_notes`. +`@corbits/granola/ingress` verifies Granola webhooks and keeps the vendor +subscription converged. `@corbits/granola/ingest` turns an acked event into +a persisted transcript and a bucket-type handler. + +## Why it exists + +Hosts that want Granola notes in an agent or a workflow still have to talk +to Granola's public API, verify Standard Webhooks, and decide what a folder +means in their product. This package owns the Granola-shaped work — client, +signature, registration, and the after-ack pipeline — so each host supplies +storage, rendering, and behavior instead of copying the vendor protocol. + +## Who it is for + +Interchange host operators who already have a Granola API key and, if they +want inbound notes, a public HTTPS origin. Typical consumers grant the tools +to an agent, or mount the webhook on a Hono app and inject transcript +storage, knowledge capture, lifecycle rendering, and per-bucket handlers. + +There is nothing here for a Granola end user. The Granola app and dashboard +stay Granola's. + +## What users can do + +- Import a client, pass `apiKey` (and optional `baseUrl`), and list folders, + list notes in a folder, or fetch a note with its transcript. +- Grant `granola_fetch_note` and `granola_search_notes` as named tool + definitions. Handlers on those definitions are placeholders; they throw + until a later change fills them in. +- Bind Granola folders to a workflow type (`diligence` or `internal`) and a + chat channel, persist that set through a host port, and keep Granola's + webhook `folder_ids` in sync when the set changes. +- Mount `POST /api/granola/webhook`, ack fast, and run host logic after the + ack. A failed `onEvent` is not redelivered by Granola; a later event for + the same note or `ingest.reprocess(noteId)` is the recovery path. +- Ask a human whether to re-run a note that already has a transcript, then + re-enter with `reprocess` or `reprocessPinned`. + +## Non-goals + +- The package never reads `process.env`. The host passes `apiKey`, `baseUrl`, + public origin, and signing secret. +- It does not persist transcripts, bindings, or knowledge. Those are host + ports. +- It does not render chat UI. Lifecycle hooks carry structured events; the + host chooses wording and destination. +- It is not a Granola dashboard, a folder manager, or a general webhook + framework. +- Tool handlers are not a working agent API yet. Do not grant them expecting + a fetch or search to succeed. + +## License + +LGPL-2.1-only. diff --git a/README.md b/README.md index 1deb250..ed1e5ae 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,19 @@ # @corbits/granola -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. - -## Three entry points, one dependency direction - -| Entry point | What it is | Depends on | -| --- | --- | --- | -| `@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 - -Requires Node.js >= 24. - -```bash -bun add github:corbitsdev/corbits-granola -# or pin a commit: -bun add github:corbitsdev/corbits-granola# -``` - -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. +Granola meeting-notes client and agent tool definitions for Interchange hosts, plus optional webhook ingress and ingest. `@corbits/granola` is a REST client (`getNote` / `listNotes` / `listFolders`) and two grantable tool definitions. `@corbits/granola/ingress` verifies Granola webhooks and keeps registration converged. `@corbits/granola/ingest` turns an acked event into a persisted transcript and a bucket handler. -## Configuration +## Runtime support -The package takes explicit options — it never reads `process.env` itself. A -host supplies: +Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The package never reads `process.env`; the host passes `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. -| 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 -## Quickstart: client and tools (no webhook) +```sh +npm add @corbits/granola +pnpm add @corbits/granola +yarn add @corbits/granola +bun add @corbits/granola +``` ```ts import { @@ -58,20 +24,22 @@ import { const client = createGranolaClient({ apiKey: process.env.GRANOLA_API_KEY! }); -const { notes } = await client.listNotes({ limit: 10 }); -const note = await client.getNote(notes[0].id); +const { folders } = await client.listFolders(); +const folderId = folders[0]?.id; +if (folderId === undefined) throw new Error("no folders"); + +const { notes } = await client.listNotes({ folderId, pageSize: 10 }); +const note = await client.getNote(notes[0]!.id, { includeTranscript: true }); 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); +for (const tool of GRANOLA_TOOL_DEFINITIONS) { + void tool.name; +} ``` -## Quickstart: webhook ingress +Tool names: `granola_fetch_note`, `granola_search_notes`. Handlers on those definitions are placeholders. -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: +Ingress mounts `POST /api/granola/webhook`. Ingest is everything after ack. ```ts import { Hono } from "hono"; @@ -82,18 +50,18 @@ import { mountGranolaWebhook, reconcileGranolaWebhookFolders, } from "@corbits/granola/ingress"; +import { createGranolaIngest } from "@corbits/granola/ingest"; 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 +const publicUrl = process.env.GRANOLA_PUBLIC_URL; +const app = new Hono(); -// 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 + seedBindings: [], onChange: (bindings) => publicUrl === undefined ? undefined @@ -105,9 +73,6 @@ const bindingStore = createGranolaBindingStore({ }), }); -// 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, @@ -115,98 +80,42 @@ const secret = await ensureGranolaWebhook({ 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"; +if (secret === undefined) throw new Error("granola webhook not registered"); const ingest = createGranolaIngest({ client: createGranolaClient({ apiKey, baseUrl }), bindingStore, - transcripts: myTranscriptStore, // { hasTranscript, persist } -> TRef + transcripts: myTranscriptStore, captureKnowledge: myKnowledgeCapture, - lifecycle: myLifecycle, // onProcessingStarted, onTranscriptReady, ... -> TAnchor + lifecycle: myLifecycle, 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 +## How it works -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. +Three entry points, one dependency direction: tools never import ingress or ingest. `/ingress` verifies signatures, acks, and converges Granola-side `folder_ids`. `/ingest` is generic over the host's transcript ref and lifecycle anchor. A failed `onEvent` after ack is not redelivered; a later event or `ingest.reprocess(noteId)` is the recovery path. -## Working on it +## Development ```sh +git clone https://github.com/corbitsdev/granola-tools.git +cd granola-tools bun install bun run typecheck bun run test +bun run test:tools +bun run test:ingress +bun run test:ingest +bun run check-deps bun run build ``` -See [CONTRIBUTING.md](./CONTRIBUTING.md) and [ARCHITECTURE.md](./ARCHITECTURE.md). +`src/tools/` must not import `src/ingress/` — `bun run check-deps` enforces that. See CONTRIBUTING.md and ARCHITECTURE.md. ## License -LGPL-2.1-only. See [LICENSE](./LICENSE). +LGPL-2.1-only.