feat(meetings): Hanzo AI for Zoom + Google Meet

@hanzo/meetings — an in-meeting assistant for Zoom and Google Meet over one
shared panel, wired to api.hanzo.ai/v1 via @hanzo/ai and hanzo.id via @hanzo/iam.

- Shared panel (src/panel): model picker + quick-action chips (Summarize /
  Action items / Follow-up email) + streaming output + prompt, host-agnostic via
  a PanelHost seam. Same UX language as the Office task pane / PDF workspace.
- Zoom App (src/zoom): in-meeting side panel over @zoom/appssdk + a server for
  OAuth install and the recording.completed / meeting.ended webhook — verifies
  the Zoom HMAC signature, answers endpoint.url_validation, downloads the cloud
  recording VTT, and summarizes it via @hanzo/ai.
- Google Meet add-on (src/meet): side-panel web app over @googleworkspace/meet-addons.
- src/hanzo.ts: pure transcript windowing/truncation (honest context note),
  WebVTT parse, OpenAI-compatible /v1 request shaping + SSE streaming, summary-
  prompt assembly. src/config.ts: endpoints, pickBearer, server env (fail-fast).
- 70 vitest tests (windowing, VTT, request shaping over mock fetch, webhook
  signature verify + routing, OAuth shaping, config). tsc --noEmit clean; build
  produces dist/zoom, dist/meet, dist/server.js.
- Registers packages/meetings in pnpm-workspace.yaml (one line).
This commit is contained in:
Hanzo Dev
2026-07-03 19:15:50 -07:00
parent 733c87313d
commit b31bcbef56
25 changed files with 2395 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
# Hanzo AI for Zoom + Google Meet
Bring Hanzo AI **into the meeting**. Every industry runs on calls; this puts a
Hanzo assistant in the side panel of **Zoom** and **Google Meet** to answer
questions about the meeting, write a **summary**, extract **action items**, and
draft a **follow-up email** — plus a server that turns a completed Zoom cloud
recording into a summary automatically.
Two surfaces, one shared panel:
- **Zoom App** (`src/zoom/`) — an in-meeting side panel over the **Zoom Apps
SDK** (`@zoom/appssdk`), plus a small **server** (`src/zoom/server.ts`) for
OAuth install and the `recording.completed` / `meeting.ended` **webhook**.
- **Google Meet add-on** (`src/meet/`) — a side-panel web app over the **Meet
Add-ons SDK** (`@googleworkspace/meet-addons`).
Both mount the same assistant panel (`src/panel/`) and call the **same backend**
as the rest of the Hanzo productivity suite: model calls go through
`api.hanzo.ai/v1` (OpenAI-compatible, streamed over SSE) via `@hanzo/ai`, with
identity via `@hanzo/iam` (hanzo.id). No new API surface, no `/api/` prefix.
## What it does
- **Ask about the meeting** — grounded in the transcript, with speaker
attribution, streamed as it's written.
- **Summarize** — purpose, key points, decisions.
- **Action items** — a checklist with owner and due date where stated.
- **Follow-up email** — a professional recap with next steps.
- **Auto-summary on Zoom cloud recordings** — the webhook downloads the recording
transcript (VTT), summarizes it, and logs the result (delivery target is a
per-deployment choice — see below).
## Transcript → summary — honest context windowing
A one-hour call transcribes to tens of thousands of words; it does not fit a
model context window and is never sent wholesale. `buildTranscriptContext` (in
`src/hanzo.ts`, pure and unit-tested) caps the attached transcript at
`TRANSCRIPT_CHAR_BUDGET` (48 000 chars):
- Segments are walked **in order** (order carries meaning in a meeting) and
included until the budget is reached; at least the first segment is always
included (hard-cut if that one segment is over budget).
- The request carries a **context note** stating how many of how many segments
were sent and, when truncated, telling the model to answer only from what it
sees and to say so if the omitted part is needed. The model is never told it
has the whole meeting when it doesn't.
`parseVtt` turns Zoom's WebVTT transcript download into those segments (speaker +
start timestamp + text), leniently (unknown input → `[]`, reported honestly).
### A note on live in-panel transcripts
Neither the Zoom Apps SDK nor the Meet Add-ons SDK hands a **live** transcript to
an in-meeting panel. So the panels answer live questions and read meeting
metadata (topic, id, participants where scoped), while the **grounded transcript
summary** is produced **after** the call:
- **Zoom** — via the `recording.completed` webhook, which downloads the cloud
recording's VTT. (Requires cloud recording + audio transcript enabled on the
Zoom account.)
- **Meet** — the same server pattern can pull the transcript from the Meet REST
API post-meeting (gated by Workspace admin policy + consent); the panel's
`getTranscript()` returns `[]` today and is honest about it.
This is stated in the panel's context note rather than faked.
## Build
```bash
pnpm install --ignore-workspace
pnpm build # → dist/zoom, dist/meet, dist/server.js
pnpm test # vitest (pure logic: windowing, VTT, request shaping, webhook)
pnpm typecheck # tsc --noEmit
pnpm start # run the Zoom OAuth + webhook server (needs env, below)
```
Build output:
```
dist/zoom/index.html dist/zoom/zoom.js — Zoom App panel (host at meetings.hanzo.ai/zoom)
dist/meet/index.html dist/meet/meet.js — Meet add-on panel (host at meetings.hanzo.ai/meet)
dist/server.js — Zoom OAuth install + webhook service
```
The Meet add-on needs its Google Cloud **project number** stamped in at build
time:
```bash
HANZO_MEET_GCP_PROJECT=123456789012 pnpm build
```
## Configuration (environment only — never commit secrets)
The server reads these from the environment (validated at startup by
`readServerConfig`; a missing required secret fails fast). Store them in KMS,
never in the repo.
| Variable | Required | Purpose |
| --- | --- | --- |
| `ZOOM_CLIENT_ID` | yes | Zoom app OAuth client id (public; also used by the panel). |
| `ZOOM_CLIENT_SECRET` | yes | Zoom app OAuth client secret — **server only**. |
| `ZOOM_REDIRECT_URI` | yes | OAuth redirect, e.g. `https://meetings.hanzo.ai/zoom/oauth/callback`. |
| `ZOOM_WEBHOOK_SECRET_TOKEN` | yes | Verifies the HMAC signature on every webhook. |
| `HANZO_API_KEY` | no | Bearer the webhook uses to summarize (no user in the loop). Without it, the webhook verifies + routes but skips summarization. |
| `PORT` | no | Listen port (default `8788`). |
The Meet add-on and the in-panel Zoom app use a **pasted Hanzo API key** (`hk-…`,
held in `localStorage`, validated by a real `/v1/models` call) — no OAuth client
needed for the panel itself. hanzo.id device login is the documented upgrade; the
bearer plumbing already tolerates any token.
## Register the Zoom App (marketplace.zoom.us)
1. **Create app***General app* (formerly "Zoom App / OAuth"). Choose
*User-managed* (or account-level per your distribution).
2. **OAuth**
- Redirect URL for OAuth: `https://meetings.hanzo.ai/zoom/oauth/callback`
(= `ZOOM_REDIRECT_URI`).
- Add `https://meetings.hanzo.ai` to the OAuth allow list.
3. **Scopes** (least privilege for what this app uses):
- `meeting:read` — meeting context/UUID in-panel.
- `cloud_recording:read` — download the recording transcript for the webhook.
- (Optional) `meeting:read:list_participants` / participant scope for the
participant list in the panel.
4. **Features → Zoom App SDK** — enable the embedded browser; add the
in-client **Home URL**: `https://meetings.hanzo.ai/zoom/index.html`. Add the
domain to the app's Domain Allow List. Under **Zoom App SDK → APIs**, enable
exactly the JS APIs the panel requests in `src/zoom/main.ts`:
`getMeetingContext`, `getMeetingUUID`, `getMeetingParticipants`,
`getRunningContext`, `getUserContext`.
5. **Event Subscriptions (webhook)**
- Event notification endpoint URL: `https://meetings.hanzo.ai/zoom/webhook`.
- Subscribe to **Recording → Recording Completed** (`recording.completed`)
and **Meeting → Meeting Ended** (`meeting.ended`).
- Copy the **Secret Token** into `ZOOM_WEBHOOK_SECRET_TOKEN`.
- **URL validation:** Zoom sends an `endpoint.url_validation` event on save;
the server answers it automatically (echoes `plainToken` + its HMAC via
`urlValidationResponse`), so click **Validate** with the server running.
**Webhook trust:** every request is verified before any transcript is fetched.
The server recomputes `v0=HMAC_SHA256(secretToken, "v0:{timestamp}:{rawBody}")`
over the **raw** body and compares it to `x-zm-signature` in constant time
(`verifySignature`). A bad or absent signature is a `401`.
## Register the Google Meet add-on
1. In the **Google Cloud project** that owns the add-on, note the **project
number** and build with `HANZO_MEET_GCP_PROJECT=<number>`.
2. **Google Workspace Marketplace SDK** → configure a **Meet add-on**:
- **Side panel URL:** `https://meetings.hanzo.ai/meet/index.html`
- (Optional) **Main stage URL** if you add a main-stage view later.
- Set the add-on's display name, logo, and support links.
3. Publish to your Workspace (private/internal listing is enough for an org).
Admins install it for the domain; it then appears in the Meet **Activities**
panel.
The side panel calls `meet.addon.createAddonSession({ cloudProjectNumber })`
`createSidePanelClient()` and reads `getMeetingInfo()` (meeting id + code). See
`src/meet/main.ts`.
## Hosting (hanzoai/static + hanzoai/ingress)
The two panels are static (HTML + one ESM bundle + icons) — serve `dist/zoom`
and `dist/meet` with **hanzoai/static** behind **hanzoai/ingress** at
`meetings.hanzo.ai`. The **server** (`dist/server.js`) is a small dependency-free
Node service (Node stdlib only) — run it as a container behind the same ingress
for the `/zoom/install`, `/zoom/oauth/callback`, and `/zoom/webhook` routes.
Every model call goes to `api.hanzo.ai/v1` regardless of the host origin. No
nginx, no caddy.
## Where the summary is delivered
The webhook pipeline is complete through **generating the summary**
(`handleRecordingCompleted``summarizeTranscript`). Delivering it — posting to
Zoom Team Chat, emailing participants, writing to a CRM — is a per-deployment
choice wired at the marked hook in `src/zoom/server.ts`; the summary text is in
hand there.
## Layout
```
src/
config.ts endpoints, pickBearer, server env (readServerConfig)
hanzo.ts chat client + transcript windowing/VTT + prompt assembly (pure core)
auth.ts pasted-key storage + validation (localStorage)
panel/
host.ts PanelHost — the seam between the panel and a platform
panel.ts shared DOM wiring (model picker, chips, streaming output)
panel.html shared panel markup (stamped per surface at build)
zoom/
main.ts Zoom App entry — PanelHost over zoomSdk
oauth.ts Zoom OAuth request shaping (pure)
webhook.ts signature verify + url-validation + event routing (pure)
server.ts http glue: install / oauth callback / webhook
meet/
main.ts Meet add-on entry — PanelHost over the Meet SDK
tests/ vitest for every pure module
build.js esbuild → dist/ (both panels + server)
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+113
View File
@@ -0,0 +1,113 @@
// Build @hanzo/meetings into dist/: bundle the two web panels (Zoom + Meet) as
// ESM, bundle the Zoom install/webhook server for Node, copy the shared panel
// HTML once per surface (with its entry script stamped), and copy assets.
//
// node build.js → production
// HANZO_MEET_GCP_PROJECT=123456789012 node build.js → stamp the Meet GCP project number
// HANZO_MEETINGS_BASE=https://localhost:8443 node build.js → local dev base (informational)
// node build.js --watch → rebuild on change
//
// Output layout:
// dist/zoom/index.html dist/zoom/zoom.js (Zoom App panel)
// dist/meet/index.html dist/meet/meet.js (Meet add-on panel)
// dist/zoom/assets/… dist/meet/assets/… (icons, per surface)
// dist/server.js (Zoom OAuth + webhook service)
//
// The two panels are HOSTED over hanzoai/static behind hanzoai/ingress (e.g.
// meetings.hanzo.ai/zoom, meetings.hanzo.ai/meet); the server runs as a small
// service. All model calls go to api.hanzo.ai regardless of the host base. No
// framework — esbuild + Node stdlib only.
import esbuild from 'esbuild';
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = (process.env.HANZO_MEETINGS_BASE || 'https://meetings.hanzo.ai').replace(/\/+$/, '');
// The Meet add-on needs the add-on's Google Cloud project number at build time.
// Stamped as a define; empty in a plain build (the panel still runs with a
// pasted key outside Meet, and a real deploy sets this).
const MEET_GCP_PROJECT = process.env.HANZO_MEET_GCP_PROJECT || '';
const watch = process.argv.includes('--watch');
const src = join(__dirname, 'src');
const dist = join(__dirname, 'dist');
const assetsSrc = join(__dirname, 'assets');
// The two panel surfaces: entry TS + output subdir. Each gets a copy of the
// shared panel.html with __ENTRY__ pointing at its bundle.
const PANELS = [
{ name: 'zoom', entry: join(src, 'zoom', 'main.ts'), out: 'zoom' },
{ name: 'meet', entry: join(src, 'meet', 'main.ts'), out: 'meet' },
];
function copyAssets(dir) {
const dst = join(dir, 'assets');
mkdirSync(dst, { recursive: true });
for (const f of readdirSync(assetsSrc)) copyFileSync(join(assetsSrc, f), join(dst, f));
}
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle each panel as ESM into dist/<out>/<out>.js.
const panelCtx = await esbuild.context({
entryPoints: Object.fromEntries(PANELS.map((p) => [`${p.out}/${p.out}`, p.entry])),
outdir: dist,
bundle: true,
format: 'esm',
platform: 'browser',
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
sourcemap: true,
minify: !watch,
define: { HANZO_MEET_GCP_PROJECT: JSON.stringify(MEET_GCP_PROJECT) },
logLevel: 'info',
});
await panelCtx.rebuild();
// Copy the shared panel.html per surface, stamping __ENTRY__ and the base.
const panelHtml = readFileSync(join(src, 'panel', 'panel.html'), 'utf8');
for (const p of PANELS) {
const outDir = join(dist, p.out);
mkdirSync(outDir, { recursive: true });
const html = panelHtml
.split('__ENTRY__').join(`${p.out}.js`)
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
writeFileSync(join(outDir, 'index.html'), html);
copyAssets(outDir);
}
// Bundle the Zoom server as a Node ESM binary. External nothing — it uses only
// Node stdlib + our pure modules, so a single file drops into a container.
const serverCtx = await esbuild.context({
entryPoints: [join(src, 'zoom', 'server.ts')],
outfile: join(dist, 'server.js'),
bundle: true,
format: 'esm',
platform: 'node',
target: ['node18'],
sourcemap: true,
minify: !watch,
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
logLevel: 'info',
});
await serverCtx.rebuild();
console.log(`Hanzo Meetings built -> dist/ (base ${BASE}${MEET_GCP_PROJECT ? `, meet gcp ${MEET_GCP_PROJECT}` : ''})`);
console.log(' Panels: dist/zoom/index.html, dist/meet/index.html · Server: dist/server.js');
if (watch) {
await panelCtx.watch();
await serverCtx.watch();
console.log('watching...');
} else {
await panelCtx.dispose();
await serverCtx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@hanzo/meetings",
"version": "0.1.0",
"description": "Hanzo AI for Zoom + Google Meet — an in-meeting assistant that summarizes calls, extracts action items, drafts follow-ups, and answers questions about the meeting. A Zoom App and a Google Meet add-on over one shared panel, wired to the api.hanzo.ai gateway and hanzo.id IAM.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@googleworkspace/meet-addons": "1.2.0",
"@hanzo/ai": "0.1.1",
"@hanzo/iam": "0.13.2",
"@zoom/appssdk": "0.16.39"
},
"devDependencies": {
"@types/node": "20.14.0",
"esbuild": "0.25.8",
"typescript": "5.8.3",
"vitest": "3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"license": "MIT"
}
+52
View File
@@ -0,0 +1,52 @@
// Auth for the web meeting panels: the zero-setup pasted-key path. The Zoom App
// and Meet add-on are static web panels (iframe/webview), so the credential
// lives in localStorage and is validated by a real /v1/models call — a key that
// can't list models is rejected before it's saved, so the user learns at paste
// time, not at first question. OAuth via hanzo.id (device login) is the
// documented upgrade; the bearer plumbing tolerates any token, so adding it is
// additive. Mirrors @hanzo/pdf's auth exactly (one way to hold a key).
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
import { listModels } from './hanzo.js';
export function getApiKey(): string {
try {
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
} catch {
return '';
}
}
export function setApiKey(key: string): void {
try {
const k = key.trim();
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
else localStorage.removeItem(APIKEY_STORAGE_KEY);
} catch {
/* private-mode / storage disabled — the in-memory key still works this session */
}
}
export function clearApiKey(): void {
setApiKey('');
}
export function hasApiKey(): boolean {
return !!getApiKey();
}
// bearer is the credential the chat call sends: the pasted key (or empty for
// anonymous public models). When OAuth lands it slots in as the second argument
// to pickBearer with no change to callers.
export function bearer(): string {
return pickBearer(getApiKey(), '');
}
// validateKey confirms a pasted key actually works by listing models with it.
// Returns the models on success so the caller populates the picker in one
// round-trip; throws with the gateway's message on failure.
export async function validateKey(key: string): Promise<string[]> {
const k = key.trim();
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
return listModels(k);
}
+100
View File
@@ -0,0 +1,100 @@
// Meetings config — the api.hanzo.ai model gateway, the default model, the
// pasted-key storage, and the two server-side secret sets (Zoom OAuth + Zoom
// webhook). Endpoints and the bearer choice mirror @hanzo/office-addin and
// @hanzo/pdf so the productivity suite stays DRY; transcript windowing (the
// thing a *meeting* surface adds) lives in hanzo.ts, not here.
export const API_BASE_URL = 'https://api.hanzo.ai';
// Default model. Overridable per-request via the picker; the gateway routes it.
export const DEFAULT_MODEL = 'zen-1';
// localStorage key for the pasted Hanzo API key (`hk-…`). The Zoom App and the
// Meet add-on are static web panels (iframe/webview), not an Office host with
// roamingSettings, so the zero-setup credential lives in localStorage — same as
// @hanzo/pdf. OAuth via hanzo.id (device login) is the documented upgrade; the
// bearer plumbing already tolerates any token, so it slots in additively.
export const APIKEY_STORAGE_KEY = 'hanzo.meetings.apiKey';
// Transcript budget. A one-hour call transcribes to tens of thousands of words;
// the whole thing won't fit a model window and shouldn't be sent. This caps the
// characters of transcript we attach to any one request — chosen conservatively
// so it fits comfortably inside a modern context window alongside the reply, and
// is honest rather than optimal (we truncate visibly, never drop silently).
export const TRANSCRIPT_CHAR_BUDGET = 48_000;
// pickBearer chooses the credential to send: a pasted API key wins over an OAuth
// token (an explicit key is a deliberate override), else the token, else empty
// (anonymous — the gateway still serves public models). Pure — unit-tested.
export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL — the model gateway endpoints. /v1 only, never
// an /api/ prefix (api.hanzo.ai IS the api host).
export function chatCompletionsURL(): string {
return `${API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${API_BASE_URL}/v1/models`;
}
// ---- Server-side configuration (Zoom OAuth + webhook) --------------------
//
// These are read from the environment by src/zoom/server.ts. They NEVER reach
// the browser bundle: the client id is public, but the client secret and the
// webhook secret token are server-only and are validated to be present before
// the server will start (readServerConfig throws on a missing secret).
// The Zoom marketplace app OAuth base + endpoints. Zoom account-level tokens are
// minted at zoom.us/oauth/token; the REST API lives at api.zoom.us/v2.
export const ZOOM_OAUTH_TOKEN_URL = 'https://zoom.us/oauth/token';
export const ZOOM_OAUTH_AUTHORIZE_URL = 'https://zoom.us/oauth/authorize';
export const ZOOM_API_BASE_URL = 'https://api.zoom.us/v2';
export interface ServerConfig {
/** Zoom OAuth client id (public — also stamped into the in-client URL). */
zoomClientId: string;
/** Zoom OAuth client secret (SERVER ONLY — token exchange). */
zoomClientSecret: string;
/** OAuth redirect registered on the Zoom app (e.g. https://meetings.hanzo.ai/zoom/oauth/callback). */
zoomRedirectUri: string;
/** Zoom webhook secret token — verifies the HMAC signature on every event. */
zoomWebhookSecret: string;
/**
* Hanzo API key the server uses to summarize a completed recording. The
* webhook runs with no user in the loop, so it needs its own bearer. Optional:
* without it, the server verifies + routes events but cannot summarize (it
* logs and skips), which readServerConfig reports honestly.
*/
hanzoApiKey: string;
/** Listen port. */
port: number;
}
// readServerConfig fails fast (throws) if a required Zoom secret is missing — a
// server that can neither exchange OAuth codes nor verify webhooks must not
// pretend to start. hanzoApiKey is the one optional field (see above). Pure
// given an env map, so it is unit-tested without touching process.env.
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
const zoomClientId = env.ZOOM_CLIENT_ID;
const zoomClientSecret = env.ZOOM_CLIENT_SECRET;
const zoomRedirectUri = env.ZOOM_REDIRECT_URI;
const zoomWebhookSecret = env.ZOOM_WEBHOOK_SECRET_TOKEN;
const missing: string[] = [];
if (!zoomClientId) missing.push('ZOOM_CLIENT_ID');
if (!zoomClientSecret) missing.push('ZOOM_CLIENT_SECRET');
if (!zoomRedirectUri) missing.push('ZOOM_REDIRECT_URI');
if (!zoomWebhookSecret) missing.push('ZOOM_WEBHOOK_SECRET_TOKEN');
if (missing.length > 0) {
throw new Error(`Missing required environment: ${missing.join(', ')}`);
}
return {
zoomClientId: zoomClientId!,
zoomClientSecret: zoomClientSecret!,
zoomRedirectUri: zoomRedirectUri!,
zoomWebhookSecret: zoomWebhookSecret!,
hanzoApiKey: env.HANZO_API_KEY || '',
port: Number(env.PORT) || 8788,
};
}
+382
View File
@@ -0,0 +1,382 @@
// The Hanzo call and its request/response shaping — pure, host-agnostic, and
// fully unit-testable (no zoomSdk, no Meet SDK, no DOM). The panel and the
// server are the thin glue that read the meeting transcript/context and hand it
// here. Speaks the OpenAI-compatible /v1 wire protocol the whole Hanzo suite
// uses (identical to @hanzo/pdf and @hanzo/office-addin) so the gateway sees one
// shape from every surface.
//
// On @hanzo/ai: the meetings package depends on @hanzo/ai + @hanzo/iam (the
// shared headless client + identity), and the browser panel is free to route
// through createAiClient(). This module is the transcript-aware core the client
// wraps — the windowing/truncation and summary-prompt assembly that a *meeting*
// surface adds on top of a generic chat call, kept pure so it can be tested and
// reused by both the panel and the (browser-less) webhook server.
import {
chatCompletionsURL,
modelsURL,
DEFAULT_MODEL,
TRANSCRIPT_CHAR_BUDGET,
} from './config.js';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// SYSTEM_PROMPT grounds every meeting answer in the transcript. It forbids
// inventing statements nobody made (the failure mode that makes a meeting
// assistant untrustworthy), asks for speaker attribution when the transcript
// carries it, and stays honest about a truncated transcript.
export const SYSTEM_PROMPT =
'You are Hanzo AI, an assistant inside a live video meeting (Zoom or Google Meet). ' +
'Answer ONLY from the meeting transcript and context provided below — never invent ' +
'statements, decisions, or attributions that are not supported by the transcript. ' +
'Attribute points to speakers when the transcript names them. Be concise and ' +
'structured. If the transcript is marked truncated and a complete answer needs the ' +
'omitted part, say so plainly rather than guessing.';
// ---- Transcript windowing / truncation -----------------------------------
//
// A transcript arrives as ordered segments (one utterance each), optionally with
// a speaker and a timestamp. We window them down to what we will actually send,
// newest-relevant first is NOT what we want for a summary (order carries
// meaning), so we walk IN ORDER from the start and stop at the budget — always
// including at least the first segment.
// One utterance in a transcript. speaker/ts are optional because Zoom's VTT and
// Meet's captions do not always carry both.
export interface TranscriptSegment {
speaker?: string;
/** Free-form timestamp label as it appears in the source (e.g. "00:04:12"). */
ts?: string;
text: string;
}
// The result of windowing a transcript down to what we'll attach: the rendered
// text, how many of the source segments it covers, and whether anything was
// dropped (so the prompt and UI can say so honestly).
export interface TranscriptContext {
text: string;
totalSegments: number;
includedSegments: number;
truncated: boolean;
}
// renderSegment turns one utterance into a single transcript line. Pure so the
// exact rendering (used to measure the budget) is what we also send.
export function renderSegment(seg: TranscriptSegment): string {
const who = seg.speaker ? seg.speaker.trim() : '';
const t = seg.ts ? seg.ts.trim() : '';
const prefix = who && t ? `[${t}] ${who}: ` : who ? `${who}: ` : t ? `[${t}] ` : '';
return `${prefix}${seg.text.trim()}`;
}
// buildTranscriptContext caps the rendered transcript at `budget` characters. It
// walks segments IN ORDER and stops when adding the next line would exceed the
// budget — always including at least the first segment (hard-cut to the budget
// if that one line alone is over). `truncated` is true whenever not every
// segment made it in. Pure and total: deterministic, no I/O.
export function buildTranscriptContext(
segments: TranscriptSegment[],
budget: number = TRANSCRIPT_CHAR_BUDGET,
): TranscriptContext {
const total = segments.length;
if (total === 0) {
return { text: '', totalSegments: 0, includedSegments: 0, truncated: false };
}
const lines: string[] = [];
let used = 0;
let hardCut = false;
for (let i = 0; i < total; i++) {
const line = renderSegment(segments[i]);
const addition = (lines.length === 0 ? '' : '\n') + line;
if (used + addition.length > budget) {
if (lines.length === 0) {
// First line overflows alone — hard-cut so we always return something.
lines.push(line.slice(0, budget));
used = budget;
hardCut = true;
}
break;
}
lines.push(addition);
used += addition.length;
}
const includedSegments = hardCut ? 1 : lines.length;
const truncated = includedSegments < total || hardCut;
return { text: lines.join(''), totalSegments: total, includedSegments, truncated };
}
// parseVtt parses a WebVTT transcript (the format Zoom's cloud-recording
// transcript download returns) into ordered segments. It is lenient: it reads
// cue timing lines for the start timestamp, treats a leading "Name:" on the cue
// text as the speaker, and skips the WEBVTT header, NOTE blocks, and numeric
// cue ids. Pure — the network fetch that gets the VTT lives in the server; this
// is the parse it feeds. Unknown/blank input yields an empty array (honest: no
// throw, the caller sees "no transcript").
export function parseVtt(vtt: string): TranscriptSegment[] {
const segments: TranscriptSegment[] = [];
// Cues are separated by blank lines. Normalize newlines first.
const blocks = vtt.replace(/\r\n/g, '\n').split(/\n\n+/);
for (const block of blocks) {
const lines = block.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
if (lines.length === 0) continue;
if (lines[0].toUpperCase().startsWith('WEBVTT')) continue;
if (lines[0].toUpperCase().startsWith('NOTE')) continue;
let idx = 0;
// Optional numeric/string cue id line (no '-->').
if (!lines[idx].includes('-->')) idx++;
// Timing line: "00:00:01.000 --> 00:00:04.000 ...".
let ts: string | undefined;
if (idx < lines.length && lines[idx].includes('-->')) {
ts = lines[idx].split('-->')[0].trim().split('.')[0]; // start, drop ms
idx++;
}
const textLines = lines.slice(idx);
if (textLines.length === 0) continue;
let text = textLines.join(' ').trim();
if (text.length === 0) continue;
// "Speaker Name: utterance" → split speaker off the front.
let speaker: string | undefined;
const m = text.match(/^([^:]{1,60}):\s+(.*)$/);
if (m) {
speaker = m[1].trim();
text = m[2].trim();
}
if (text.length === 0) continue;
segments.push({ speaker, ts, text });
}
return segments;
}
// contextNote is the one honest sentence prepended to the transcript so the
// model (and, echoed in the panel, the user) knows the scope of what it sees.
// Never claim the whole meeting was sent when it wasn't.
export function contextNote(ctx: TranscriptContext): string {
if (ctx.totalSegments === 0) return 'No transcript is available for this meeting yet.';
return ctx.truncated
? `Transcript: ${ctx.includedSegments} of ${ctx.totalSegments} segments (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
: `Transcript: the full meeting (${ctx.totalSegments} segment${ctx.totalSegments === 1 ? '' : 's'}).`;
}
// ---- Prompt assembly ------------------------------------------------------
// Optional structured context about the meeting that the SDKs can supply
// (participants, topic, id). Attached as a short header above the transcript so
// answers can reference who was present without the model guessing.
export interface MeetingContext {
topic?: string;
participants?: string[];
meetingId?: string;
}
function meetingHeader(meta: MeetingContext | undefined): string {
if (!meta) return '';
const parts: string[] = [];
if (meta.topic) parts.push(`Meeting: ${meta.topic}`);
if (meta.participants && meta.participants.length > 0) {
parts.push(`Participants: ${meta.participants.join(', ')}`);
}
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
}
// buildMessages turns a task (the user's instruction, or one of the quick-action
// prompts) plus the windowed transcript + optional meeting metadata into the
// OpenAI-compatible message list. The transcript is fenced so the model treats
// it as data, not instructions, and the honest context note rides inside the
// user turn so it is never lost.
export function buildMessages(
task: string,
ctx: TranscriptContext,
meta?: MeetingContext,
): ChatMessage[] {
const system: ChatMessage = { role: 'system', content: SYSTEM_PROMPT };
const header = meetingHeader(meta);
const user: ChatMessage = {
role: 'user',
content:
`${header}${contextNote(ctx)}\n\n` +
`---- meeting transcript ----\n${ctx.text}\n---- end transcript ----\n\n` +
`---- task ----\n${task}`,
};
return [system, user];
}
// Quick actions — the chips the panel offers. Named so both the panel UI and the
// webhook summary path draw the exact same wording (DRY: the auto-summary a
// recording.completed event produces is the same "Summarize" the user clicks).
export const QUICK_ACTIONS = {
summary:
'Write a concise summary of this meeting: the purpose, the key points discussed, ' +
'and the decisions made. Use short paragraphs or bullets.',
actionItems:
'Extract the action items from this meeting as a checklist. For each: the owner ' +
'(if named), the task, and any due date mentioned. If none were assigned, say so.',
followupEmail:
'Draft a short, professional follow-up email to the participants: thank them, ' +
'recap the decisions, and list next steps with owners. Return only the email body.',
} as const;
export interface ChatOptions {
model?: string;
temperature?: number;
/** Request an SSE stream. Defaults to false (one-shot completion). */
stream?: boolean;
signal?: AbortSignal;
}
// requestBody is the exact JSON the gateway receives. Separated so a test can
// assert the wire shape (model default, stream flag, messages) without a network
// call. `stream` defaults to false — the one-shot contract.
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
return {
model: opts.model || DEFAULT_MODEL,
messages,
stream: opts.stream === true,
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
};
}
// extractContent pulls the assistant text out of an OpenAI-compatible completion
// response, tolerating the shapes the gateway returns. Throws on an error
// payload so the caller surfaces the real gateway message.
export function extractContent(data: any): string {
if (data && data.error) {
const msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
throw new Error(`Hanzo API error: ${msg}`);
}
const choice = data?.choices?.[0];
const content = choice?.message?.content ?? choice?.text ?? data?.content ?? '';
if (typeof content !== 'string' || content === '') {
throw new Error('Hanzo API returned no content');
}
return content;
}
// ask runs one non-streaming completion and returns the answer. token may be
// empty (the gateway serves anonymous/limited models); when present it is the
// bearer (a pasted hk- key or an IAM token).
export async function ask(
task: string,
ctx: TranscriptContext,
token: string,
meta?: MeetingContext,
opts: ChatOptions = {},
): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(chatCompletionsURL(), {
method: 'POST',
headers,
body: JSON.stringify(requestBody(buildMessages(task, ctx, meta), opts)),
signal: opts.signal,
});
const text = await resp.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Hanzo API ${resp.status}: ${text.slice(0, 200)}`);
}
if (!resp.ok) {
try {
return extractContent(data);
} catch {
const m = data?.error?.message || data?.error || `HTTP ${resp.status}`;
throw new Error(`Hanzo API ${resp.status}: ${m}`);
}
}
return extractContent(data);
}
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame
// payload (the JSON after `data: `). Pure, so the SSE parse is unit-testable.
export function streamDelta(payload: string): string {
const json = JSON.parse(payload);
return json?.choices?.[0]?.delta?.content ?? '';
}
// askStream runs a streaming completion, invoking onDelta with each text chunk
// as it arrives, and resolves with the full concatenated text. Same inputs as
// `ask`; the panel uses it for live-typing output. Throws on a non-2xx.
export async function askStream(
task: string,
ctx: TranscriptContext,
token: string,
onDelta: (text: string) => void,
meta?: MeetingContext,
opts: ChatOptions = {},
): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(chatCompletionsURL(), {
method: 'POST',
headers,
body: JSON.stringify(requestBody(buildMessages(task, ctx, meta), { ...opts, stream: true })),
signal: opts.signal,
});
if (!resp.ok || !resp.body) {
const detail = await resp.text().catch(() => '');
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n');
buffer = parts.pop() ?? '';
for (const line of parts) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') return full;
try {
const delta = streamDelta(payload);
if (delta) {
full += delta;
onDelta(delta);
}
} catch {
// partial SSE frame — wait for the next chunk
}
}
}
return full;
}
// summarizeTranscript is the server-side one-shot: window the transcript, build
// the summary prompt, and return the summary. This is what the recording.completed
// webhook calls after downloading the VTT — no browser, no streaming, one bearer.
export async function summarizeTranscript(
segments: TranscriptSegment[],
token: string,
meta?: MeetingContext,
opts: ChatOptions = {},
): Promise<string> {
const ctx = buildTranscriptContext(segments);
return ask(QUICK_ACTIONS.summary, ctx, token, meta, opts);
}
// listModels returns the model ids the caller may route to, from /v1/models. The
// endpoint is org-scoped by the bearer; an empty token lists public models.
export async function listModels(token: string): Promise<string[]> {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(modelsURL(), { headers });
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
const data: any = await resp.json();
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
return items
.map((m: any) => (typeof m === 'string' ? m : m?.id))
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
}
+71
View File
@@ -0,0 +1,71 @@
// Google Meet add-on entry — the side-panel web app. Runs inside Meet's add-on
// iframe. It creates an add-on session, gets the side-panel client, builds a
// PanelHost over it, and mounts the shared assistant panel.
//
// What the Meet Add-ons SDK supplies in-panel: meeting info (meetingId +
// meetingCode) via getMeetingInfo(). Like Zoom, the SDK does NOT hand a live
// transcript to a side-panel add-on — Meet transcripts are produced by the Meet
// REST API / Workspace after the meeting, gated by admin policy and user
// consent. So getTranscript() returns [] and the panel answers live questions,
// staying honest about scope in its context note. (A future server path can
// pull the Meet REST transcript the same way the Zoom webhook pulls the VTT.)
//
// The add-on's Google Cloud project number is stamped at build time (the Meet
// SDK requires it to create a session) — see build.js / HANZO_MEET_GCP_PROJECT.
import { meet, type MeetSidePanelClient } from '@googleworkspace/meet-addons/meet.addons';
import { mountPanel } from '../panel/panel.js';
import type { PanelHost } from '../panel/host.js';
import type { TranscriptSegment, MeetingContext } from '../hanzo.js';
// Stamped by build.js from HANZO_MEET_GCP_PROJECT. The Meet SDK needs the
// add-on's Google Cloud project number to open a session.
declare const HANZO_MEET_GCP_PROJECT: string;
let sidePanel: MeetSidePanelClient | undefined;
async function buildContextMeta(): Promise<MeetingContext> {
const meta: MeetingContext = {};
if (!sidePanel) return meta;
try {
const info = await sidePanel.getMeetingInfo();
if (info?.meetingId) meta.meetingId = info.meetingId;
// Meet's meetingCode (aaa-bbbb-ccc) is the human-facing label; use it as the
// topic when no richer title is available.
if (info?.meetingCode) meta.topic = `Meet ${info.meetingCode}`;
} catch {
/* session not ready or info unavailable — leave unset (honest) */
}
return meta;
}
const host: PanelHost = {
name: 'Google Meet',
// The side-panel SDK does not expose the live transcript; a grounded summary
// comes from the post-meeting REST transcript path. [] is the honest answer.
async getTranscript(): Promise<TranscriptSegment[]> {
return [];
},
getMeetingContext: buildContextMeta,
};
async function boot(): Promise<void> {
try {
const session = await meet.addon.createAddonSession({
cloudProjectNumber: HANZO_MEET_GCP_PROJECT,
});
sidePanel = await session.createSidePanelClient();
} catch {
// Opened outside Meet (dev in a plain browser) — the panel still mounts and
// works with a pasted key; getMeetingContext just returns empty metadata.
}
mountPanel(host);
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void boot());
} else {
void boot();
}
}
+27
View File
@@ -0,0 +1,27 @@
// PanelHost is the seam between the shared assistant panel and the two meeting
// platforms. The panel (panel/panel.ts) is host-agnostic: it renders the model
// picker, chips, prompt and streaming output, and asks the host for the two
// things only the platform can supply — the meeting's transcript and its
// metadata (topic/participants). Zoom and Meet each provide a PanelHost; the
// panel never imports a platform SDK.
//
// This is the "one way" the panel talks to a platform: values in, no coupling.
import type { TranscriptSegment, MeetingContext } from '../hanzo.js';
export interface PanelHost {
/** Short label shown in the header (e.g. "Zoom", "Google Meet"). */
readonly name: string;
/**
* The meeting transcript as ordered segments. In-panel live transcript access
* is limited by each platform's SDK (see each host for what it can and cannot
* read), so this may be empty during a call — the panel shows an honest note
* and still answers general prompts. Returns [] when no transcript is
* available rather than throwing.
*/
getTranscript(): Promise<TranscriptSegment[]>;
/** Meeting metadata (topic, participants, id) where the SDK permits. */
getMeetingContext(): Promise<MeetingContext>;
}
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI</title>
<style>
:root { color-scheme: light dark; --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --accent:#111; }
@media (prefers-color-scheme: dark) { :root { --fg:#eaeaea; --muted:#9a9a9a; --bg:#1e1e1e; --line:#333; --accent:#fff; } }
* { box-sizing: border-box; }
body { font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 12px; }
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
header img { width: 22px; height: 22px; }
header .title { font-weight: 600; }
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
textarea, select, input { width: 100%; font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
textarea#prompt { min-height: 64px; resize: vertical; }
textarea#output { min-height: 200px; resize: vertical; }
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
button { font: inherit; border: 1px solid var(--line); background: var(--accent); color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button.secondary { background: transparent; color: var(--fg); }
button:disabled { opacity: .5; cursor: default; }
.status { min-height: 18px; font-size: 12px; margin-top: 8px; color: var(--muted); }
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
.spacer { flex: 1; }
footer { margin-top: 10px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { padding: 5px 10px; border-radius: 999px; font-size: 13px; background: transparent; color: var(--fg); border: 1px solid var(--line); }
.chip:hover:not(:disabled) { border-color: var(--accent); }
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 8px; }
</style>
</head>
<body>
<header>
<img src="assets/icon-32.png" alt="Hanzo" />
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host" id="hostname">Meeting</span>
</header>
<!-- One-click actions over the meeting transcript. -->
<div class="chips">
<button class="chip" data-action="summary">Summarize</button>
<button class="chip" data-action="action-items">Action items</button>
<button class="chip" data-action="followup-email">Follow-up email</button>
</div>
<label for="prompt">Ask about this meeting</label>
<textarea id="prompt" placeholder="e.g. What did we decide about the launch date? · Who owns the pricing follow-up? · Summarize the last 10 minutes"></textarea>
<div class="row">
<button id="run">Ask Hanzo</button>
<span class="spacer"></span>
</div>
<details>
<summary>Hanzo API key</summary>
<div class="row">
<input id="apikey" type="password" placeholder="hk-…" />
<button id="savekey" class="secondary">Save</button>
</div>
<div id="authhint" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
</details>
<div class="status" id="status"></div>
<label for="output">Result</label>
<textarea id="output" placeholder="Hanzo's answer appears here."></textarea>
<footer>Routed through api.hanzo.ai · answers are grounded in the meeting transcript.</footer>
<script type="module" src="__ENTRY__"></script>
</body>
</html>
+177
View File
@@ -0,0 +1,177 @@
// The shared assistant panel — host-agnostic DOM wiring over @hanzo/ai. Both the
// Zoom App (zoom/main.ts) and the Google Meet add-on (meet/main.ts) mount THIS
// with a PanelHost; the panel never imports a platform SDK. It renders: a model
// picker (populated from /v1/models when a key is present), quick-action chips
// (Summarize / Action items / Follow-up email), a prompt box, and a streaming
// output area. Same UX language as the Office task pane and the PDF workspace.
//
// This module is the DOM glue; all the testable logic (windowing, prompt
// assembly, request shaping, streaming parse) lives in hanzo.ts and is unit-
// tested there. The panel just binds it to the page.
import { DEFAULT_MODEL } from '../config.js';
import {
buildTranscriptContext,
contextNote,
askStream,
listModels,
QUICK_ACTIONS,
type TranscriptContext,
type MeetingContext,
} from '../hanzo.js';
import { bearer, getApiKey, setApiKey, validateKey } from '../auth.js';
import type { PanelHost } from './host.js';
// $ is a tiny typed getElementById. The panel HTML (panel.html) guarantees these
// ids exist, so a missing one is a programming error, not a user error.
function $<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id);
if (!el) throw new Error(`panel: #${id} missing from the page`);
return el as T;
}
function setStatus(el: HTMLElement, text: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
el.textContent = text;
el.className = kind ? `status ${kind}` : 'status';
}
// populateModels fills the picker from /v1/models (org-scoped by the bearer).
// Falls back to the default model when unauthenticated or the list fails — the
// panel is always usable.
async function populateModels(select: HTMLSelectElement, token: string): Promise<void> {
let ids: string[] = [];
try {
ids = token ? await listModels(token) : [];
} catch {
ids = [];
}
if (ids.length === 0) ids = [DEFAULT_MODEL];
const current = select.value;
select.innerHTML = '';
for (const id of ids) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
select.appendChild(opt);
}
select.value = ids.includes(current) ? current : ids[0];
}
// gatherContext pulls the transcript + meeting metadata from the host and windows
// the transcript to the budget. Shown to the user as an honest scope note.
async function gatherContext(host: PanelHost): Promise<{ ctx: TranscriptContext; meta: MeetingContext }> {
const [segments, meta] = await Promise.all([host.getTranscript(), host.getMeetingContext()]);
return { ctx: buildTranscriptContext(segments), meta };
}
// run executes one streaming completion for `task` and types the result into the
// output area. Shared by the chips and the "Ask" button.
async function run(host: PanelHost, task: string, els: PanelEls): Promise<void> {
const trimmed = task.trim();
if (!trimmed) {
setStatus(els.status, 'Type a question or pick a quick action.', 'warn');
return;
}
els.run.disabled = true;
els.output.value = '';
try {
const { ctx, meta } = await gatherContext(host);
setStatus(els.status, contextNote(ctx));
const token = bearer();
const model = els.model.value || DEFAULT_MODEL;
await askStream(
trimmed,
ctx,
token,
(delta) => {
els.output.value += delta;
els.output.scrollTop = els.output.scrollHeight;
},
meta,
{ model },
);
setStatus(els.status, contextNote(ctx), 'ok');
} catch (e: any) {
setStatus(els.status, e?.message || 'Request failed.', 'error');
} finally {
els.run.disabled = false;
}
}
interface PanelEls {
model: HTMLSelectElement;
prompt: HTMLTextAreaElement;
output: HTMLTextAreaElement;
run: HTMLButtonElement;
status: HTMLElement;
apikey: HTMLInputElement;
savekey: HTMLButtonElement;
authhint: HTMLElement;
hostname: HTMLElement;
}
// The quick-action chips map their data-task to a QUICK_ACTIONS prompt (or their
// own inline text). Named actions keep the panel and the webhook summary in sync.
const CHIP_TASKS: Record<string, string> = {
summary: QUICK_ACTIONS.summary,
'action-items': QUICK_ACTIONS.actionItems,
'followup-email': QUICK_ACTIONS.followupEmail,
};
// mountPanel wires the DOM to a host. Call once on DOMContentLoaded from each
// platform entry after the host is ready.
export function mountPanel(host: PanelHost): void {
const els: PanelEls = {
model: $('model'),
prompt: $('prompt'),
output: $('output'),
run: $('run'),
status: $('status'),
apikey: $('apikey'),
savekey: $('savekey'),
authhint: $('authhint'),
hostname: $('hostname'),
};
els.hostname.textContent = host.name;
// Reflect the current auth state and populate models.
const refreshAuth = async (): Promise<void> => {
const key = getApiKey();
els.authhint.textContent = key ? 'Using your saved Hanzo API key.' : 'Paste a Hanzo API key (hk-…) to use your models.';
await populateModels(els.model, bearer());
};
void refreshAuth();
// Save / validate a pasted key.
els.savekey.addEventListener('click', async () => {
const key = els.apikey.value.trim();
try {
const models = await validateKey(key);
setApiKey(key);
els.apikey.value = '';
setStatus(els.status, 'Key saved.', 'ok');
els.authhint.textContent = `Saved. ${models.length} model${models.length === 1 ? '' : 's'} available.`;
await populateModels(els.model, bearer());
} catch (e: any) {
setStatus(els.status, e?.message || 'That key did not work.', 'error');
}
});
// Quick-action chips.
for (const chip of Array.from(document.querySelectorAll<HTMLButtonElement>('.chip'))) {
chip.addEventListener('click', () => {
const key = chip.getAttribute('data-action') || '';
const task = CHIP_TASKS[key];
if (task) void run(host, task, els);
});
}
// Ask button + Ctrl/Cmd-Enter in the prompt.
els.run.addEventListener('click', () => void run(host, els.prompt.value, els));
els.prompt.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void run(host, els.prompt.value, els);
}
});
}
+84
View File
@@ -0,0 +1,84 @@
// Zoom App entry — the in-meeting side panel. Runs inside Zoom's embedded
// webview. It configures the Zoom Apps SDK for the capabilities we use, builds a
// PanelHost over zoomSdk, and mounts the shared assistant panel.
//
// What the in-client SDK can supply: meeting context (topic + id), the meeting
// UUID, and the participant list (with the right scopes granted). What it CANNOT
// supply in-panel is the live transcript — Zoom exposes the transcript via the
// cloud-recording REST API AFTER the meeting (that path is the webhook in
// server.ts), not to an in-meeting app. So getTranscript() returns [] here and
// the panel answers general questions live, while the recording.completed
// webhook produces the grounded post-meeting summary. This is stated honestly in
// the panel's context note rather than faked.
import zoomSdk from '@zoom/appssdk';
import { mountPanel } from '../panel/panel.js';
import type { PanelHost } from '../panel/host.js';
import type { TranscriptSegment, MeetingContext } from '../hanzo.js';
// The JS APIs the panel needs. Declared up front so config() requests exactly
// these capabilities (Zoom gates each behind a granted scope). `as const` keeps
// each entry a literal so it satisfies the SDK's Apis union.
const CAPABILITIES = [
'getMeetingContext',
'getMeetingUUID',
'getMeetingParticipants',
'getRunningContext',
'getUserContext',
] as const;
async function buildContextMeta(): Promise<MeetingContext> {
const meta: MeetingContext = {};
try {
const ctx = await zoomSdk.getMeetingContext();
if (ctx?.meetingTopic) meta.topic = ctx.meetingTopic;
if (ctx?.meetingID) meta.meetingId = ctx.meetingID;
} catch {
/* not in a meeting, or scope not granted — leave unset (honest) */
}
try {
const uuid = await zoomSdk.getMeetingUUID();
if (uuid?.meetingUUID && !meta.meetingId) meta.meetingId = uuid.meetingUUID;
} catch {
/* ignore */
}
try {
const res = await zoomSdk.getMeetingParticipants();
const names = (res?.participants || [])
.map((p: any) => p?.screenName || p?.displayName)
.filter((n: unknown): n is string => typeof n === 'string' && n.length > 0);
if (names.length > 0) meta.participants = names;
} catch {
/* participant scope not granted — leave unset */
}
return meta;
}
const host: PanelHost = {
name: 'Zoom',
// The in-meeting SDK does not expose the live transcript to an app; the
// grounded transcript summary is produced by the cloud-recording webhook after
// the call. Returning [] is the honest answer for the live panel.
async getTranscript(): Promise<TranscriptSegment[]> {
return [];
},
getMeetingContext: buildContextMeta,
};
async function boot(): Promise<void> {
try {
await zoomSdk.config({ capabilities: [...CAPABILITIES], version: '0.16.0' });
} catch {
// Outside the Zoom client (e.g. opened directly in a browser for dev), the
// SDK config fails; the panel still mounts and works with a pasted key.
}
mountPanel(host);
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void boot());
} else {
void boot();
}
}
+88
View File
@@ -0,0 +1,88 @@
// Zoom OAuth (authorization-code) — pure request shaping. The client_secret is
// held by the server (server.ts) and never reaches the browser; these functions
// build the exact URL/headers/body of each OAuth call so the wire shape is
// unit-testable without a network round-trip. Token exchange itself is one
// fetch in the server.
//
// Zoom uses HTTP Basic auth (base64 client_id:client_secret) on the token
// endpoint and form-encoded bodies — the OAuth2 standard shape, documented at
// developers.zoom.us/docs/integrations/oauth.
import { ZOOM_OAUTH_AUTHORIZE_URL, ZOOM_OAUTH_TOKEN_URL } from '../config.js';
// authorizeUrl is where the install flow sends the admin's browser to grant the
// app. `state` is the CSRF token the server generates and re-checks on callback.
export function authorizeUrl(clientId: string, redirectUri: string, state: string): string {
const q = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
state,
});
return `${ZOOM_OAUTH_AUTHORIZE_URL}?${q.toString()}`;
}
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a
// test asserts the Basic header + form body without opening a socket.
export interface PreparedRequest {
url: string;
headers: Record<string, string>;
body: string;
}
function basicAuth(clientId: string, clientSecret: string): string {
return 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
}
// tokenExchange builds the code→token request. The secret rides in the Basic
// header, never the body or the URL.
export function tokenExchange(args: {
clientId: string;
clientSecret: string;
redirectUri: string;
code: string;
}): PreparedRequest {
return {
url: ZOOM_OAUTH_TOKEN_URL,
headers: {
Authorization: basicAuth(args.clientId, args.clientSecret),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: args.code,
redirect_uri: args.redirectUri,
}).toString(),
};
}
// The token response we care about. Zoom returns access_token (+ refresh_token,
// expires_in, scope); parseTokenResponse validates the one field we must have.
export interface ZoomTokenSet {
access_token: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
token_type?: string;
}
// parseTokenResponse validates a Zoom token payload — throws with Zoom's own
// error message on a failure body (so the server surfaces the real reason)
// rather than silently returning a token-less object.
export function parseTokenResponse(data: any): ZoomTokenSet {
if (!data || typeof data !== 'object') throw new Error('empty token response');
if (data.error) {
const reason = data.reason || data.error_description || data.error;
throw new Error(`Zoom OAuth error: ${reason}`);
}
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
throw new Error('Zoom OAuth response missing access_token');
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_in: data.expires_in,
scope: data.scope,
token_type: data.token_type,
};
}
+222
View File
@@ -0,0 +1,222 @@
// The Zoom install + webhook service. This is the ONLY place the Zoom
// client_secret and the webhook secret token exist — both are read from the
// environment (never bundled, never sent to the browser). It:
//
// GET /zoom/install → redirect the admin to Zoom's OAuth consent
// GET /zoom/oauth/callback → exchange the code for a token (secret stays here)
// POST /zoom/webhook → verify the Zoom signature, handle url_validation,
// and on recording.completed fetch the transcript
// (VTT) and summarize it via @hanzo/ai
// GET /healthz → readiness
//
// It is a dependency-free Node http handler built on the pure modules
// (config, zoom/oauth, zoom/webhook, hanzo) so it is deployable behind
// hanzoai/ingress as a small service at meetings.hanzo.ai. The pure logic it
// wraps is what the tests cover; the http server is an integration concern.
//
// node dist/server.js (after build.js bundles it)
//
// Required environment (see config.readServerConfig):
// ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, ZOOM_REDIRECT_URI, ZOOM_WEBHOOK_SECRET_TOKEN
// HANZO_API_KEY (optional — needed to actually summarize; without it the
// webhook verifies + routes but skips summarization)
// PORT (default 8788)
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { readServerConfig, type ServerConfig } from '../config.js';
import { authorizeUrl, tokenExchange, parseTokenResponse } from './oauth.js';
import {
verifySignature,
urlValidationResponse,
routeEvent,
type ZoomAction,
} from './webhook.js';
import { parseVtt, summarizeTranscript, type MeetingContext } from '../hanzo.js';
// readRawBody collects the raw request bytes as a utf8 string. The webhook
// signature is computed over these RAW bytes, so we must NOT JSON.parse-and-
// reserialize before verifying (key order would differ and every event would
// fail). One read, used for both verification and parsing.
async function readRawBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
return Buffer.concat(chunks).toString('utf8');
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function log(fields: Record<string, unknown>): void {
// Structured, single-line log.
console.log(JSON.stringify(fields));
}
// GET /zoom/install → 302 to Zoom's consent screen. `state` is a fresh CSRF
// token; a production deployment persists it (KMS/Valkey) to re-check on
// callback. Here we mint one and carry it — the checkable value is what matters.
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
const state = randomUUID();
const url = authorizeUrl(cfg.zoomClientId, cfg.zoomRedirectUri, state);
res.writeHead(302, { Location: url });
res.end();
}
// GET /zoom/oauth/callback?code=…&state=… → exchange the code for a token. The
// secret is used only inside tokenExchange's Basic header, server-side.
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
const code = url.searchParams.get('code');
if (!code) return json(res, 400, { error: 'missing code' });
const reqShape = tokenExchange({
clientId: cfg.zoomClientId,
clientSecret: cfg.zoomClientSecret,
redirectUri: cfg.zoomRedirectUri,
code,
});
const zoomResp = await fetch(reqShape.url, {
method: 'POST',
headers: reqShape.headers,
body: reqShape.body,
});
const data = await zoomResp.json().catch(() => ({}));
try {
parseTokenResponse(data);
// The token authorizes future REST calls (recording download on plans that
// require it). A production deployment stores it in KMS keyed by the Zoom
// account id; here we confirm the install succeeded to the admin.
return json(res, 200, { ok: true, installed: true });
} catch (e: any) {
return json(res, 400, { error: e?.message || 'token exchange failed' });
}
}
// fetchTranscript downloads the recording transcript (VTT). Zoom's download_url
// may require the bearer token / download_token on some plans; we attach the
// download_token as a query param when present (Zoom's documented mechanism) and
// fall back to an unauthenticated GET otherwise. Returns '' on any failure so
// the caller logs and skips rather than throwing inside the webhook.
async function fetchTranscript(url: string, downloadToken?: string): Promise<string> {
if (!url) return '';
const target = downloadToken
? `${url}${url.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(downloadToken)}`
: url;
try {
const resp = await fetch(target);
if (!resp.ok) return '';
return await resp.text();
} catch {
return '';
}
}
// handleRecordingCompleted is the summary pipeline: download VTT → parse →
// window + summarize via @hanzo/ai. It NEVER throws into the webhook handler
// (the webhook must 200 fast so Zoom does not retry); it logs the outcome.
// Returns the summary text (or '') for tests/callers that await it.
async function handleRecordingCompleted(
cfg: ServerConfig,
action: Extract<ZoomAction, { kind: 'recording_completed' }>,
): Promise<string> {
if (!cfg.hanzoApiKey) {
log({ msg: 'recording.completed: HANZO_API_KEY unset, skipping summary', meetingUuid: action.meetingUuid });
return '';
}
const vtt = await fetchTranscript(action.transcriptUrl, action.downloadToken);
const segments = parseVtt(vtt);
if (segments.length === 0) {
log({ msg: 'recording.completed: no transcript, skipping summary', meetingUuid: action.meetingUuid });
return '';
}
const meta: MeetingContext = { topic: action.topic, meetingId: action.meetingUuid };
try {
const summary = await summarizeTranscript(segments, cfg.hanzoApiKey, meta);
log({
msg: 'recording.completed: summary generated',
meetingUuid: action.meetingUuid,
segments: segments.length,
summaryChars: summary.length,
});
// Deliver hook: a production deployment posts `summary` to Zoom Team Chat or
// emails participants here. The pipeline through summary is complete; the
// delivery target is a per-deployment choice (documented in the README).
return summary;
} catch (e: any) {
log({ msg: 'recording.completed: summary failed', meetingUuid: action.meetingUuid, error: e?.message });
return '';
}
}
// POST /zoom/webhook → verify, then dispatch. Verification is the trust gate:
// an event with a bad/absent signature is a 401 before any transcript fetch.
async function handleWebhook(cfg: ServerConfig, req: IncomingMessage, res: ServerResponse): Promise<void> {
const raw = await readRawBody(req);
const sig = req.headers['x-zm-signature'] as string | undefined;
const ts = req.headers['x-zm-request-timestamp'] as string | undefined;
if (!verifySignature(cfg.zoomWebhookSecret, sig, ts, raw)) {
log({ msg: 'webhook: signature verification failed' });
return json(res, 401, { error: 'invalid signature' });
}
let body: any;
try {
body = JSON.parse(raw);
} catch {
return json(res, 400, { error: 'invalid json' });
}
const action = routeEvent(body);
switch (action.kind) {
case 'url_validation':
// Echo the plainToken + its HMAC so Zoom marks the endpoint verified.
return json(res, 200, urlValidationResponse(cfg.zoomWebhookSecret, action.plainToken));
case 'recording_completed':
// Acknowledge Zoom immediately; run the summary pipeline in the background
// so the webhook returns fast (Zoom retries slow endpoints).
json(res, 200, { ok: true });
void handleRecordingCompleted(cfg, action);
return;
case 'meeting_ended':
log({ msg: 'meeting.ended', meetingUuid: action.meetingUuid });
return json(res, 200, { ok: true });
case 'ignored':
return json(res, 200, { ok: true, ignored: action.event });
}
}
// createHandler is the request router. Everything is a small handler over the
// pure modules. Exported so a test can drive it with a mock req/res if desired.
export function createHandler(cfg: ServerConfig) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
try {
if (req.method === 'GET' && url.pathname === '/zoom/install') return handleInstall(cfg, res);
if (req.method === 'GET' && url.pathname === '/zoom/oauth/callback') return await handleOAuthCallback(cfg, url, res);
if (req.method === 'POST' && url.pathname === '/zoom/webhook') return await handleWebhook(cfg, req, res);
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
return json(res, 404, { error: 'not found' });
} catch (e: any) {
log({ msg: 'unhandled error', error: e?.message });
return json(res, 500, { error: 'internal error' });
}
};
}
// main boots the server when run directly. Import-safe: only the direct entry
// listens, so tests import the handlers without opening a port.
export function main(): void {
const cfg = readServerConfig(process.env);
const server = createServer(createHandler(cfg));
server.listen(cfg.port, () => {
log({ msg: 'hanzo meetings (zoom) service up', port: cfg.port, summarize: !!cfg.hanzoApiKey });
});
}
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
main();
}
+144
View File
@@ -0,0 +1,144 @@
// Zoom webhook: signature verification, URL-validation challenge, and event
// routing — all pure over their inputs so they are unit-testable without an http
// server. server.ts is the thin http glue that reads the raw body + headers and
// calls these. Crypto is Node's stdlib (node:crypto), never a dependency.
//
// Zoom signs every webhook request. The signature header is:
// x-zm-signature: v0=HMAC_SHA256(secretToken, `v0:${timestamp}:${rawBody}`)
// with the timestamp in x-zm-request-timestamp. We recompute it over the RAW
// body bytes (not a re-serialized object — key order would differ) and compare
// in constant time. This is the whole trust boundary: an event that fails here
// is discarded before any transcript is fetched.
//
// Zoom also validates the endpoint URL on save with an `endpoint.url_validation`
// event carrying a plainToken; the response must echo it plus its HMAC. That is
// the same secret and the same hash, so it lives here too.
import { createHmac, timingSafeEqual } from 'node:crypto';
// signPayload is the HMAC-SHA256 hex of a message under the secret token. The
// one crypto primitive; both the request signature and the URL-validation
// response are built from it.
export function signPayload(secret: string, message: string): string {
return createHmac('sha256', secret).update(message).digest('hex');
}
// zoomSignature builds the value Zoom sends in x-zm-signature for a given raw
// body + timestamp: `v0=<hmac>`. Exposed so a test can construct a valid header
// the same way Zoom does.
export function zoomSignature(secret: string, timestamp: string, rawBody: string): string {
return `v0=${signPayload(secret, `v0:${timestamp}:${rawBody}`)}`;
}
// constantTimeEqual compares two strings without leaking length/position via
// timing. Different-length inputs are unequal (and short-circuit safely — we
// never feed mismatched buffers to timingSafeEqual, which would throw).
export function constantTimeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
// verifySignature is the trust gate: recompute the expected x-zm-signature over
// the raw body + timestamp and compare in constant time. Returns false on any
// missing input rather than throwing, so a malformed request is a clean reject.
export function verifySignature(
secret: string,
signatureHeader: string | undefined,
timestamp: string | undefined,
rawBody: string,
): boolean {
if (!secret || !signatureHeader || !timestamp) return false;
const expected = zoomSignature(secret, timestamp, rawBody);
return constantTimeEqual(signatureHeader, expected);
}
// The Zoom URL-validation event body shape.
export interface UrlValidationPayload {
plainToken: string;
}
// urlValidationResponse builds the JSON body Zoom expects back for an
// `endpoint.url_validation` event: the plainToken echoed plus its HMAC under the
// secret. Returning this within a few seconds is how the marketplace verifies
// we own the webhook secret.
export function urlValidationResponse(
secret: string,
plainToken: string,
): { plainToken: string; encryptedToken: string } {
return { plainToken, encryptedToken: signPayload(secret, plainToken) };
}
// ---- Event routing --------------------------------------------------------
//
// After a request is verified, we parse it into a discriminated action so the
// server can dispatch. We only act on the two events that produce a summary
// (a completed cloud recording, which has a downloadable transcript) and treat
// meeting.ended as a signal (the recording usually isn't ready yet, so we log it
// and wait for recording.completed). Everything else is acknowledged and ignored.
export type ZoomAction =
| { kind: 'url_validation'; plainToken: string }
| { kind: 'recording_completed'; meetingUuid: string; topic: string; transcriptUrl: string; downloadToken?: string }
| { kind: 'meeting_ended'; meetingUuid: string; topic: string }
| { kind: 'ignored'; event: string };
// A recording file entry as it appears in a recording.completed payload.
interface RecordingFile {
file_type?: string;
file_extension?: string;
recording_type?: string;
download_url?: string;
status?: string;
}
// transcriptFileUrl finds the transcript among a recording's files. Zoom marks
// the closed-caption/transcript file with file_type "TRANSCRIPT" (extension VTT);
// we fall back to a .vtt/.transcript.vtt extension match. Returns '' if none —
// a recording without a transcript can't be summarized, and the server says so.
export function transcriptFileUrl(files: RecordingFile[] | undefined): string {
if (!Array.isArray(files)) return '';
const byType = files.find((f) => (f.file_type || '').toUpperCase() === 'TRANSCRIPT');
if (byType?.download_url) return byType.download_url;
const byExt = files.find((f) => (f.file_extension || '').toUpperCase() === 'VTT');
return byExt?.download_url || '';
}
// routeEvent turns a PARSED, ALREADY-VERIFIED webhook body into a ZoomAction.
// Verification is the caller's job (verifySignature) — this is pure dispatch
// over the parsed JSON, so it is unit-testable with plain objects. Unknown
// events become { kind: 'ignored' } (still a 200 to Zoom — acknowledge, do
// nothing) rather than an error.
export function routeEvent(body: any): ZoomAction {
const event: string = body?.event || '';
if (event === 'endpoint.url_validation') {
const plainToken = body?.payload?.plainToken;
if (typeof plainToken === 'string' && plainToken.length > 0) {
return { kind: 'url_validation', plainToken };
}
return { kind: 'ignored', event };
}
if (event === 'recording.completed') {
const obj = body?.payload?.object || {};
const transcriptUrl = transcriptFileUrl(obj.recording_files);
return {
kind: 'recording_completed',
meetingUuid: obj.uuid || obj.meeting_uuid || '',
topic: obj.topic || '',
transcriptUrl,
// Zoom includes a download_token in the payload for authenticated file
// access on some plans; carry it through if present.
downloadToken: body?.download_token || obj.download_token || undefined,
};
}
if (event === 'meeting.ended') {
const obj = body?.payload?.object || {};
return { kind: 'meeting_ended', meetingUuid: obj.uuid || '', topic: obj.topic || '' };
}
return { kind: 'ignored', event };
}
+240
View File
@@ -0,0 +1,240 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildMessages,
requestBody,
extractContent,
streamDelta,
ask,
askStream,
listModels,
summarizeTranscript,
SYSTEM_PROMPT,
QUICK_ACTIONS,
buildTranscriptContext,
type TranscriptContext,
} from '../src/hanzo';
import { DEFAULT_MODEL } from '../src/config';
function ctxOf(text: string, opts: Partial<TranscriptContext> = {}): TranscriptContext {
return { text, totalSegments: 1, includedSegments: 1, truncated: false, ...opts };
}
describe('SYSTEM_PROMPT (transcript grounding)', () => {
it('forbids inventing statements and asks for attribution', () => {
const p = SYSTEM_PROMPT.toLowerCase();
expect(p).toContain('only from the meeting transcript');
expect(p).toContain('never invent');
expect(p).toContain('attribute');
});
});
describe('buildMessages (summary-prompt assembly)', () => {
it('fences the transcript, carries the honest note, and appends the task', () => {
const m = buildMessages('Summarize this', ctxOf('Ana: hi'));
expect(m[0].role).toBe('system');
expect(m[0].content).toBe(SYSTEM_PROMPT);
expect(m[1].role).toBe('user');
expect(m[1].content).toContain('---- meeting transcript ----');
expect(m[1].content).toContain('Ana: hi');
expect(m[1].content).toContain('---- task ----');
expect(m[1].content).toContain('Summarize this');
expect(m[1].content.toLowerCase()).toContain('full meeting'); // context note
});
it('includes a meeting header (topic + participants) when metadata is given', () => {
const m = buildMessages('Recap', ctxOf('body'), {
topic: 'Q3 Planning',
participants: ['Ana', 'Bob'],
meetingId: 'abc',
});
expect(m[1].content).toContain('Meeting: Q3 Planning');
expect(m[1].content).toContain('Participants: Ana, Bob');
});
it('omits the header when no metadata is given', () => {
const m = buildMessages('Recap', ctxOf('body'));
expect(m[1].content).not.toContain('Meeting:');
expect(m[1].content).not.toContain('Participants:');
});
it('carries the truncation warning into the prompt when windowed', () => {
const big = Array.from({ length: 20 }, (_, i) => ({ text: 'z'.repeat(200), speaker: `S${i}` }));
const m = buildMessages('Summarize', buildTranscriptContext(big, 400));
expect(m[1].content.toLowerCase()).toContain('truncated');
});
});
describe('QUICK_ACTIONS (panel/webhook parity)', () => {
it('names the three actions with distinct intents', () => {
expect(QUICK_ACTIONS.summary.toLowerCase()).toContain('summary');
expect(QUICK_ACTIONS.actionItems.toLowerCase()).toContain('action items');
expect(QUICK_ACTIONS.followupEmail.toLowerCase()).toContain('follow-up email');
});
});
describe('requestBody', () => {
it('defaults the model and disables streaming', () => {
const b = requestBody(buildMessages('x', ctxOf('d')));
expect(b.model).toBe(DEFAULT_MODEL);
expect(b.stream).toBe(false);
expect(Array.isArray(b.messages)).toBe(true);
});
it('passes an explicit model + temperature through', () => {
const b = requestBody(buildMessages('x', ctxOf('d')), { model: 'zen-pro', temperature: 0.1 });
expect(b.model).toBe('zen-pro');
expect(b.temperature).toBe(0.1);
});
it('enables streaming only when opts.stream is true', () => {
expect(requestBody(buildMessages('x', ctxOf('d')), { stream: true }).stream).toBe(true);
expect(requestBody(buildMessages('x', ctxOf('d')), { stream: false }).stream).toBe(false);
});
});
describe('streamDelta', () => {
it('reads choices[0].delta.content from an SSE frame', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Par' } }] }))).toBe('Par');
});
it('returns empty string for a role-only / empty delta', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
});
});
describe('extractContent', () => {
it('reads OpenAI choices[0].message.content', () => {
expect(extractContent({ choices: [{ message: { content: 'answer' } }] })).toBe('answer');
});
it('tolerates choices[0].text and bare content', () => {
expect(extractContent({ choices: [{ text: 'a' }] })).toBe('a');
expect(extractContent({ content: 'b' })).toBe('b');
});
it('throws on an error payload', () => {
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
});
it('throws when there is no content', () => {
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
});
});
describe('ask (request shaping over mock fetch)', () => {
afterEach(() => vi.unstubAllGlobals());
it('POSTs to the gateway /v1 with the bearer, transcript and task', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
const body = JSON.parse(init.body);
expect(init.headers.Authorization).toBe('Bearer hk-tok');
expect(body.messages[1].content).toContain('DECISION: ship friday'); // transcript
expect(body.messages[1].content).toContain('What did we decide?'); // task
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ choices: [{ message: { content: 'We decided to ship Friday.' } }] }),
} as any;
});
vi.stubGlobal('fetch', fetchMock);
const out = await ask('What did we decide?', ctxOf('DECISION: ship friday'), 'hk-tok');
expect(out).toBe('We decided to ship Friday.');
expect(fetchMock.mock.calls[0][0]).toBe('https://api.hanzo.ai/v1/chat/completions');
});
it('omits Authorization when the token is empty', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => {
expect(init.headers.Authorization).toBeUndefined();
return { ok: true, status: 200, text: async () => JSON.stringify({ content: 'ok' }) } as any;
}));
expect(await ask('hi', ctxOf('d'), '')).toBe('ok');
});
it('surfaces a structured gateway error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 429,
text: async () => JSON.stringify({ error: { message: 'too many requests' } }),
} as any)));
await expect(ask('x', ctxOf('d'), 't')).rejects.toThrow(/too many requests/);
});
});
describe('askStream', () => {
afterEach(() => vi.unstubAllGlobals());
it('concatenates deltas and reports each chunk', async () => {
const frames = [
'data: ' + JSON.stringify({ choices: [{ delta: { content: 'Hello' } }] }) + '\n\n',
'data: ' + JSON.stringify({ choices: [{ delta: { content: ' world' } }] }) + '\n\n',
'data: [DONE]\n\n',
];
const stream = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
for (const f of frames) controller.enqueue(enc.encode(f));
controller.close();
},
});
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => {
expect(JSON.parse(init.body).stream).toBe(true); // askStream forces stream
return { ok: true, body: stream } as any;
}));
const chunks: string[] = [];
const full = await askStream('Summarize', ctxOf('d'), 'hk-1', (c) => chunks.push(c));
expect(full).toBe('Hello world');
expect(chunks).toEqual(['Hello', ' world']);
});
it('throws on a non-2xx stream response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500, text: async () => 'boom' } as any)));
await expect(askStream('x', ctxOf('d'), 't', () => {})).rejects.toThrow(/500/);
});
});
describe('summarizeTranscript (server one-shot)', () => {
afterEach(() => vi.unstubAllGlobals());
it('windows the segments and sends the Summarize action with the bearer', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
const body = JSON.parse(init.body);
expect(init.headers.Authorization).toBe('Bearer hk-srv');
expect(body.messages[1].content).toContain(QUICK_ACTIONS.summary);
expect(body.messages[1].content).toContain('Ana: kickoff'); // rendered segment
expect(body.messages[1].content).toContain('Meeting: Sprint Review'); // meta header
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ choices: [{ message: { content: 'Summary text.' } }] }),
} as any;
});
vi.stubGlobal('fetch', fetchMock);
const out = await summarizeTranscript(
[{ speaker: 'Ana', text: 'kickoff' }, { speaker: 'Bob', text: 'agenda' }],
'hk-srv',
{ topic: 'Sprint Review' },
);
expect(out).toBe('Summary text.');
});
});
describe('listModels', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns ids from /v1/models with the bearer', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://api.hanzo.ai/v1/models');
expect(init.headers.Authorization).toBe('Bearer t');
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
}));
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
});
it('tolerates a bare array and omits the bearer when unauthenticated', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => {
expect(init.headers.Authorization).toBeUndefined();
return { ok: true, json: async () => ['a', 'b'] } as any;
}));
expect(await listModels('')).toEqual(['a', 'b']);
});
it('throws on a failed model list', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401 } as any)));
await expect(listModels('bad')).rejects.toThrow(/401/);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import {
pickBearer,
chatCompletionsURL,
modelsURL,
readServerConfig,
DEFAULT_MODEL,
} from '../src/config';
import { authorizeUrl, tokenExchange, parseTokenResponse } from '../src/zoom/oauth';
describe('pickBearer', () => {
it('prefers a pasted API key over an OAuth token', () => {
expect(pickBearer('hk-key', 'oauth-tok')).toBe('hk-key');
});
it('falls back to the OAuth token, then empty', () => {
expect(pickBearer('', 'oauth-tok')).toBe('oauth-tok');
expect(pickBearer('', '')).toBe('');
});
it('ignores whitespace-only credentials', () => {
expect(pickBearer(' ', 'oauth-tok')).toBe('oauth-tok');
});
});
describe('gateway URLs (/v1, never /api/)', () => {
it('points at api.hanzo.ai/v1', () => {
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(chatCompletionsURL()).not.toContain('/api/');
});
it('has a zen default model', () => {
expect(DEFAULT_MODEL).toBe('zen-1');
});
});
describe('readServerConfig', () => {
const full = {
ZOOM_CLIENT_ID: 'cid',
ZOOM_CLIENT_SECRET: 'csecret',
ZOOM_REDIRECT_URI: 'https://meetings.hanzo.ai/zoom/oauth/callback',
ZOOM_WEBHOOK_SECRET_TOKEN: 'whsec',
};
it('reads a complete config with the optional Hanzo key and a default port', () => {
const cfg = readServerConfig({ ...full, HANZO_API_KEY: 'hk-1' });
expect(cfg.zoomClientId).toBe('cid');
expect(cfg.zoomWebhookSecret).toBe('whsec');
expect(cfg.hanzoApiKey).toBe('hk-1');
expect(cfg.port).toBe(8788);
});
it('allows a missing HANZO_API_KEY (verify+route without summarizing)', () => {
const cfg = readServerConfig(full);
expect(cfg.hanzoApiKey).toBe('');
});
it('honors PORT', () => {
expect(readServerConfig({ ...full, PORT: '9000' }).port).toBe(9000);
});
it('throws naming every missing required secret', () => {
expect(() => readServerConfig({})).toThrow(/ZOOM_CLIENT_ID/);
expect(() => readServerConfig({ ZOOM_CLIENT_ID: 'x' })).toThrow(/ZOOM_CLIENT_SECRET/);
expect(() => readServerConfig({ ...full, ZOOM_WEBHOOK_SECRET_TOKEN: undefined })).toThrow(
/ZOOM_WEBHOOK_SECRET_TOKEN/,
);
});
});
describe('Zoom OAuth request shaping', () => {
it('builds the authorize URL with state', () => {
const url = new URL(authorizeUrl('cid', 'https://h/cb', 'st8'));
expect(url.origin + url.pathname).toBe('https://zoom.us/oauth/authorize');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe('cid');
expect(url.searchParams.get('redirect_uri')).toBe('https://h/cb');
expect(url.searchParams.get('state')).toBe('st8');
});
it('puts the secret in the Basic header, not the body', () => {
const req = tokenExchange({ clientId: 'cid', clientSecret: 'sec', redirectUri: 'https://h/cb', code: 'abc' });
expect(req.url).toBe('https://zoom.us/oauth/token');
expect(req.headers.Authorization).toBe('Basic ' + Buffer.from('cid:sec').toString('base64'));
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(req.body).not.toContain('sec'); // secret is NOT in the body
const params = new URLSearchParams(req.body);
expect(params.get('grant_type')).toBe('authorization_code');
expect(params.get('code')).toBe('abc');
expect(params.get('redirect_uri')).toBe('https://h/cb');
});
it('parses a token response and throws on an error body', () => {
expect(parseTokenResponse({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 })).toEqual({
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
scope: undefined,
token_type: undefined,
});
expect(() => parseTokenResponse({ error: 'invalid_grant', reason: 'bad code' })).toThrow(/bad code/);
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import {
renderSegment,
buildTranscriptContext,
parseVtt,
contextNote,
type TranscriptSegment,
} from '../src/hanzo';
function segs(n: number, per: number): TranscriptSegment[] {
return Array.from({ length: n }, (_, i) => ({ speaker: `S${i}`, ts: `00:0${i}`, text: 'x'.repeat(per) }));
}
describe('renderSegment', () => {
it('renders speaker + timestamp + text', () => {
expect(renderSegment({ speaker: 'Ana', ts: '00:01', text: 'hello' })).toBe('[00:01] Ana: hello');
});
it('drops the missing pieces cleanly', () => {
expect(renderSegment({ speaker: 'Ana', text: 'hi' })).toBe('Ana: hi');
expect(renderSegment({ ts: '00:02', text: 'hi' })).toBe('[00:02] hi');
expect(renderSegment({ text: 'hi' })).toBe('hi');
});
it('trims stray whitespace in each field', () => {
expect(renderSegment({ speaker: ' Ana ', ts: ' 00:01 ', text: ' hi ' })).toBe('[00:01] Ana: hi');
});
});
describe('buildTranscriptContext (windowing / truncation)', () => {
it('includes everything and is not truncated when under budget', () => {
const ctx = buildTranscriptContext(segs(3, 10), 10_000);
expect(ctx.totalSegments).toBe(3);
expect(ctx.includedSegments).toBe(3);
expect(ctx.truncated).toBe(false);
expect(ctx.text.split('\n')).toHaveLength(3);
});
it('stops at the budget and marks truncated', () => {
// Each rendered line ~ "[00:0i] Si: " + 100 chars ≈ 112 chars. Budget 250
// fits ~2 lines.
const ctx = buildTranscriptContext(segs(10, 100), 250);
expect(ctx.totalSegments).toBe(10);
expect(ctx.includedSegments).toBeGreaterThanOrEqual(1);
expect(ctx.includedSegments).toBeLessThan(10);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBeLessThanOrEqual(250);
});
it('always includes at least the first segment, hard-cutting an over-budget line', () => {
const ctx = buildTranscriptContext([{ text: 'y'.repeat(1000) }], 100);
expect(ctx.includedSegments).toBe(1);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBe(100);
});
it('preserves transcript order', () => {
const ctx = buildTranscriptContext(
[{ text: 'first' }, { text: 'second' }, { text: 'third' }],
10_000,
);
expect(ctx.text.indexOf('first')).toBeLessThan(ctx.text.indexOf('second'));
expect(ctx.text.indexOf('second')).toBeLessThan(ctx.text.indexOf('third'));
});
it('handles an empty transcript', () => {
const ctx = buildTranscriptContext([], 1000);
expect(ctx).toEqual({ text: '', totalSegments: 0, includedSegments: 0, truncated: false });
});
});
describe('parseVtt', () => {
it('parses cues into speaker/ts/text segments', () => {
const vtt = [
'WEBVTT',
'',
'1',
'00:00:01.000 --> 00:00:04.000',
'Ana Ng: Welcome everyone.',
'',
'2',
'00:00:05.000 --> 00:00:09.000',
'Bob Lee: Thanks, let us start.',
].join('\n');
const s = parseVtt(vtt);
expect(s).toHaveLength(2);
expect(s[0]).toEqual({ speaker: 'Ana Ng', ts: '00:00:01', text: 'Welcome everyone.' });
expect(s[1]).toEqual({ speaker: 'Bob Lee', ts: '00:00:05', text: 'Thanks, let us start.' });
});
it('handles CRLF newlines and cues without a speaker', () => {
const vtt = 'WEBVTT\r\n\r\n00:00:01.000 --> 00:00:02.000\r\nJust some narration.';
const s = parseVtt(vtt);
expect(s).toHaveLength(1);
expect(s[0].speaker).toBeUndefined();
expect(s[0].ts).toBe('00:00:01');
expect(s[0].text).toBe('Just some narration.');
});
it('skips NOTE blocks and the header', () => {
const vtt = 'WEBVTT\n\nNOTE this is a comment\n\n00:00:01.000 --> 00:00:02.000\nHi.';
const s = parseVtt(vtt);
expect(s).toHaveLength(1);
expect(s[0].text).toBe('Hi.');
});
it('joins multi-line cue text', () => {
const vtt = 'WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nCarol: This is a long\nsentence split across lines.';
const s = parseVtt(vtt);
expect(s[0].speaker).toBe('Carol');
expect(s[0].text).toBe('This is a long sentence split across lines.');
});
it('returns [] for empty or non-VTT input', () => {
expect(parseVtt('')).toEqual([]);
expect(parseVtt('WEBVTT')).toEqual([]);
});
});
describe('contextNote (honesty)', () => {
it('says "full meeting" when nothing was dropped', () => {
const ctx = buildTranscriptContext(segs(2, 5), 10_000);
expect(contextNote(ctx).toLowerCase()).toContain('full meeting');
});
it('says "truncated" and names the counts when windowed', () => {
const ctx = buildTranscriptContext(segs(10, 100), 250);
const note = contextNote(ctx).toLowerCase();
expect(note).toContain('truncated');
expect(note).toContain('of 10 segments');
});
it('says no transcript when empty', () => {
expect(contextNote(buildTranscriptContext([], 100)).toLowerCase()).toContain('no transcript');
});
});
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import {
signPayload,
zoomSignature,
constantTimeEqual,
verifySignature,
urlValidationResponse,
transcriptFileUrl,
routeEvent,
} from '../src/zoom/webhook';
const SECRET = 'super-secret-token';
describe('signPayload / zoomSignature', () => {
it('is a deterministic HMAC-SHA256 hex', () => {
const a = signPayload(SECRET, 'hello');
const b = signPayload(SECRET, 'hello');
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('builds the v0=<hmac> header shape Zoom sends', () => {
const sig = zoomSignature(SECRET, '1700000000', '{"event":"x"}');
expect(sig.startsWith('v0=')).toBe(true);
expect(sig.slice(3)).toBe(signPayload(SECRET, 'v0:1700000000:{"event":"x"}'));
});
});
describe('constantTimeEqual', () => {
it('is true for equal strings and false otherwise', () => {
expect(constantTimeEqual('abc', 'abc')).toBe(true);
expect(constantTimeEqual('abc', 'abd')).toBe(false);
});
it('is false for different lengths without throwing', () => {
expect(constantTimeEqual('abc', 'abcd')).toBe(false);
});
});
describe('verifySignature (trust gate)', () => {
const ts = '1700000000';
const body = JSON.stringify({ event: 'meeting.ended', payload: {} });
const good = zoomSignature(SECRET, ts, body);
it('accepts a correctly signed request', () => {
expect(verifySignature(SECRET, good, ts, body)).toBe(true);
});
it('rejects a tampered body', () => {
expect(verifySignature(SECRET, good, ts, body + ' ')).toBe(false);
});
it('rejects a wrong secret', () => {
expect(verifySignature('other', good, ts, body)).toBe(false);
});
it('rejects a mismatched timestamp (replay of a different frame)', () => {
expect(verifySignature(SECRET, good, '1700000001', body)).toBe(false);
});
it('rejects missing signature / timestamp / secret rather than throwing', () => {
expect(verifySignature(SECRET, undefined, ts, body)).toBe(false);
expect(verifySignature(SECRET, good, undefined, body)).toBe(false);
expect(verifySignature('', good, ts, body)).toBe(false);
});
});
describe('urlValidationResponse', () => {
it('echoes the plainToken and its HMAC under the secret', () => {
const r = urlValidationResponse(SECRET, 'plain-123');
expect(r.plainToken).toBe('plain-123');
expect(r.encryptedToken).toBe(signPayload(SECRET, 'plain-123'));
});
});
describe('transcriptFileUrl', () => {
it('prefers the TRANSCRIPT file_type', () => {
const files = [
{ file_type: 'MP4', download_url: 'https://z/video' },
{ file_type: 'TRANSCRIPT', file_extension: 'VTT', download_url: 'https://z/transcript.vtt' },
];
expect(transcriptFileUrl(files)).toBe('https://z/transcript.vtt');
});
it('falls back to a VTT file_extension', () => {
const files = [{ file_extension: 'VTT', download_url: 'https://z/cc.vtt' }];
expect(transcriptFileUrl(files)).toBe('https://z/cc.vtt');
});
it('returns empty when there is no transcript file', () => {
expect(transcriptFileUrl([{ file_type: 'MP4', download_url: 'https://z/v' }])).toBe('');
expect(transcriptFileUrl(undefined)).toBe('');
});
});
describe('routeEvent (dispatch)', () => {
it('routes endpoint.url_validation with the plainToken', () => {
const a = routeEvent({ event: 'endpoint.url_validation', payload: { plainToken: 'p1' } });
expect(a).toEqual({ kind: 'url_validation', plainToken: 'p1' });
});
it('ignores a url_validation with no plainToken', () => {
const a = routeEvent({ event: 'endpoint.url_validation', payload: {} });
expect(a.kind).toBe('ignored');
});
it('routes recording.completed with the transcript url and topic', () => {
const a = routeEvent({
event: 'recording.completed',
download_token: 'dl-tok',
payload: {
object: {
uuid: 'mtg-uuid',
topic: 'Weekly Sync',
recording_files: [
{ file_type: 'M4A', download_url: 'https://z/audio' },
{ file_type: 'TRANSCRIPT', download_url: 'https://z/t.vtt' },
],
},
},
});
expect(a).toEqual({
kind: 'recording_completed',
meetingUuid: 'mtg-uuid',
topic: 'Weekly Sync',
transcriptUrl: 'https://z/t.vtt',
downloadToken: 'dl-tok',
});
});
it('routes meeting.ended', () => {
const a = routeEvent({ event: 'meeting.ended', payload: { object: { uuid: 'u2', topic: 'T' } } });
expect(a).toEqual({ kind: 'meeting_ended', meetingUuid: 'u2', topic: 'T' });
});
it('ignores unknown events but keeps the name', () => {
const a = routeEvent({ event: 'meeting.started', payload: {} });
expect(a).toEqual({ kind: 'ignored', event: 'meeting.started' });
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -9,6 +9,7 @@ packages:
- 'packages/gworkspace'
- 'packages/jetbrains'
- 'packages/mcp'
- 'packages/meetings'
- 'packages/office'
- 'packages/outlook'
- 'packages/pdf'