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 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-`). 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 (fn: () => Promise) => { await limiter.bucket.take() return fn() } const listed = await listEndpoint( { ...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 = [ { 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 => 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: (fn: () => Promise) => Promise): Promise { const path = `/v1/notes/${id}` if (!withTranscript) return (await throttled(() => granolaRequest(a, { path }))).data as Note const res = await throttled(() => granolaRequest(a, { path, query: { include: 'transcript' }, throwOnError: false })) if (res.ok) return res.data as Note if (res.status !== 413) return (await throttled(() => granolaRequest(a, { path, query: { include: 'transcript' } }))).data as Note const note = (await throttled(() => granolaRequest(a, { path }))).data as Note const t = await listEndpoint(a, { path: `${path}/transcript`, itemsKey: 'transcript', limit: 50000, maxPageSize: 100, paginate: true }, throttled) return { ...note, transcript: t.items } }