← 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 · TypeScriptimport { 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 }
}