Skip to content
← Public packages

@charlesmatte/granola

Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.

src/notes/search.ts

174 lines · 9.2 KB · TypeScript
Raw
import { listEndpoint, parseWithAccount } from '../lib/api.ts'
import { granolaRequest, GranolaRequestError } from '../lib/client.ts'
import { mapLimit, TokenBucket } from '../lib/ratelimit.ts'
import { matchNote, parseQuery, type FieldText } from '../lib/search.ts'
import { transcriptToText, type TranscriptItem } from '../lib/transcript.ts'
import { DATE_PATTERN, FOLDER_ID_PATTERN } from '../lib/validate.ts'

type NoteSummary = { id: string; title: string | null; owner?: { name?: string | null; email?: string }; created_at: string; updated_at: string }
type Note = NoteSummary & {
	web_url?: string
	attendees?: Array<{ name?: string | null; email?: string }>
	summary_text?: string
	summary_markdown?: string | null
	private_notes_text?: string | null
	private_notes_markdown?: string | null
	transcript?: Array<TranscriptItem>
	calendar_event?: { event_title?: string | null } | null
}

/** Shared bucket per isolate so back-to-back searches also respect the limit (replaceable in tests). */
export const limiter = { bucket: new TokenBucket(25, 5) }

/**
 * Keyword search across Granola notes with the API key (the API has no search endpoint, so this
 * scans): lists notes newest first (date/folder filters applied server-side), fetches each one and
 * matches title, AI summary, private notes (owner keys), attendees, calendar title and — only with
 * `includeTranscripts: true` (slower) — transcripts. Case- and accent-insensitive; `"quoted phrase"`
 * stays whole; `-word` excludes. Results are ranked by weighted hit count with short snippets.
 * Requests are throttled to Granola's limit (burst 25, 5/s): ~100 notes take ≈20 s, more with
 * transcripts. When `capHit` is true, continue with `cursor: nextCursor` or narrow the dates.
 *
 * @param input.account - Account name (secret `granola-<account>`). Required. @param input.secretName - Secret override.
 * @param input.query - Words and/or "quoted phrases"; `-word` to exclude. Required.
 * @param input.mode - `'all'` (default: every term must appear) or `'any'`.
 * @param input.createdAfter - `YYYY-MM-DD` or ISO. @param input.createdBefore - same. @param input.updatedAfter - same.
 * @param input.updatedBefore - same (applied locally to listed notes).
 * @param input.folderId - `fol_…` (includes subfolders).
 * @param input.includeTranscripts - Also search transcripts (default false; costs extra requests for long meetings).
 * @param input.includePrivateNotes - Search the owner's private notes (default true).
 * @param input.maxNotes - Max notes to scan (1-1000, default 100). @param input.maxResults - Max results (1-100, default 20).
 * @param input.snippetChars - Context chars on each side of a hit (20-400, default 90). @param input.snippetsPerNote - 1-10, default 3.
 * @param input.cursor - Continue scanning from a previous `nextCursor`.
 * @returns `{ account, query, terms, excluded, mode, scanned, listed, capHit, nextCursor, fetchErrors, matchedCount, results: [{ noteId, title, created_at, updated_at, web_url, owner, score, termsMatched, hits, snippets }], elapsedMs }`
 *
 * @example
 * import searchNotes from 'kody:@charlesmatte/granola/notes/search'
 *
 * const r = await searchNotes({ account: 'work', query: 'pricing "next steps"', createdAfter: '2026-09-01' })
 */
export default async function searchGranolaNotes(input: unknown) {
	const started = Date.now()
	const p = parseWithAccount<{
		query: string; mode?: 'all' | 'any'
		createdAfter?: string; createdBefore?: string; updatedAfter?: string; updatedBefore?: string; folderId?: string
		includeTranscripts?: boolean; includePrivateNotes?: boolean
		maxNotes?: number; maxResults?: number; snippetChars?: number; snippetsPerNote?: number; cursor?: string
	}>(
		input,
		{
			query: { type: 'string', required: true, maxLength: 500 },
			mode: { type: 'enum', values: ['all', 'any'] },
			createdAfter: { type: 'string', pattern: DATE_PATTERN },
			createdBefore: { type: 'string', pattern: DATE_PATTERN },
			updatedAfter: { type: 'string', pattern: DATE_PATTERN },
			updatedBefore: { type: 'string', pattern: DATE_PATTERN },
			folderId: { type: 'string', pattern: FOLDER_ID_PATTERN },
			includeTranscripts: { type: 'boolean' },
			includePrivateNotes: { type: 'boolean' },
			maxNotes: { type: 'integer', min: 1, max: 1000 },
			maxResults: { type: 'integer', min: 1, max: 100 },
			snippetChars: { type: 'integer', min: 20, max: 400 },
			snippetsPerNote: { type: 'integer', min: 1, max: 10 },
			cursor: { type: 'string', maxLength: 4000 },
		},
		'notes/search',
	)
	const q = parseQuery(p.query)
	if (q.terms.length === 0) throw new Error('notes/search: "query" needs at least one search term (excluded -terms alone are not enough).')
	const mode = p.mode ?? 'all'
	const maxNotes = p.maxNotes ?? 100
	const includePrivate = p.includePrivateNotes ?? true

	// 1. List candidates (newest first). Each list page is one request.
	const throttled = async <T>(fn: () => Promise<T>) => {
		await limiter.bucket.take()
		return fn()
	}
	const listed = await listEndpoint<NoteSummary>(
		{ ...p.acct },
		{
			path: '/v1/notes',
			itemsKey: 'notes',
			query: { created_after: p.createdAfter, created_before: p.createdBefore, updated_after: p.updatedAfter, folder_id: p.folderId },
			limit: maxNotes,
			maxPageSize: 30,
			cursor: p.cursor,
			paginate: true,
		},
		throttled,
	)
	const updatedBefore = p.updatedBefore ? Date.parse(p.updatedBefore) : null
	const candidates = listed.items.filter((n) => updatedBefore === null || Date.parse(n.updated_at) < updatedBefore)

	// 2. Fetch + match each note (throttled, 4 in flight).
	const fetchErrors: Array<{ noteId: string; status: number | null; error: string }> = []
	const radius = p.snippetChars ?? 90
	const maxSnippets = p.snippetsPerNote ?? 3
	const scored = await mapLimit(candidates, 4, async (summary) => {
		let note: Note
		try {
			note = await fetchNote(p.acct, summary.id, Boolean(p.includeTranscripts), throttled)
		} catch (error) {
			if (error instanceof GranolaRequestError && error.status === 401) throw error // bad key: stop the whole search
			fetchErrors.push({ noteId: summary.id, status: error instanceof GranolaRequestError ? error.status : null, error: String(error instanceof Error ? error.message : error).slice(0, 200) })
			note = summary // still match the title
		}
		const fields: Array<FieldText> = [
			{ field: 'title', text: note.title ?? '', weight: 3 },
			{ field: 'calendarTitle', text: note.calendar_event?.event_title && note.calendar_event.event_title !== note.title ? note.calendar_event.event_title : '', weight: 2 },
			{ field: 'attendees', text: (note.attendees ?? []).map((a) => [a.name, a.email].filter(Boolean).join(' ')).join(' · '), weight: 2 },
			{ field: 'summary', text: note.summary_markdown ?? note.summary_text ?? '', weight: 1 },
			{ field: 'privateNotes', text: includePrivate ? (note.private_notes_markdown ?? note.private_notes_text ?? '') : '', weight: 1 },
			{ field: 'transcript', text: note.transcript ? transcriptToText(note.transcript) : '', weight: 1 },
		]
		const m = matchNote(fields, q, { mode, radius, maxSnippets })
		return m.matched
			? {
					noteId: note.id,
					title: note.title,
					created_at: note.created_at,
					updated_at: note.updated_at,
					web_url: note.web_url ?? null,
					owner: note.owner ?? null,
					score: m.score,
					termsMatched: m.termsMatched,
					hits: m.hits,
					snippets: m.snippets,
				}
			: null
	})
	const matches = scored.filter((x): x is NonNullable<typeof x> => x !== null).sort((a, b) => b.score - a.score || Date.parse(b.created_at) - Date.parse(a.created_at))
	const maxResults = p.maxResults ?? 20
	return {
		account: p.acct.account,
		query: p.query,
		terms: q.terms,
		excluded: q.excluded,
		mode,
		searchedFields: ['title', 'calendarTitle', 'attendees', 'summary', ...(includePrivate ? ['privateNotes'] : []), ...(p.includeTranscripts ? ['transcript'] : [])],
		scanned: candidates.length,
		listed: listed.count,
		capHit: listed.hasMore,
		nextCursor: listed.hasMore ? listed.cursor : null,
		...(listed.hasMore
			? { coverage: `Scanned the ${listed.count} newest matching notes; older notes were not searched. Continue with cursor: nextCursor, raise maxNotes (≤1000), or narrow the date range.` }
			: { coverage: 'Every note matching the filters was scanned.' }),
		fetchErrors,
		matchedCount: matches.length,
		results: matches.slice(0, maxResults),
		resultsTruncated: matches.length > maxResults,
		elapsedMs: Date.now() - started,
	}
}

async function fetchNote(a: { account: string; secretName: string }, id: string, withTranscript: boolean, throttled: <T>(fn: () => Promise<T>) => Promise<T>): Promise<Note> {
	const path = `/v1/notes/${id}`
	if (!withTranscript) return (await throttled(() => granolaRequest<Note>(a, { path }))).data as Note
	const res = await throttled(() => granolaRequest<Note>(a, { path, query: { include: 'transcript' }, throwOnError: false }))
	if (res.ok) return res.data as Note
	if (res.status !== 413) return (await throttled(() => granolaRequest<Note>(a, { path, query: { include: 'transcript' } }))).data as Note
	const note = (await throttled(() => granolaRequest<Note>(a, { path }))).data as Note
	const t = await listEndpoint<TranscriptItem>(a, { path: `${path}/transcript`, itemsKey: 'transcript', limit: 50000, maxPageSize: 100, paginate: true }, throttled)
	return { ...note, transcript: t.items }
}