add granola sync

This commit is contained in:
Ramnique Singh 2026-01-08 07:30:42 +05:30
parent 6a0e5981dd
commit 949d36c04d
6 changed files with 511 additions and 0 deletions

View file

@ -10,6 +10,7 @@ import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js";
import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -29,6 +30,7 @@ container.register({
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
});
export default container;

View file

@ -0,0 +1,5 @@
// Re-export public API
export { init } from './sync.js';
export { IGranolaConfigRepo, FSGranolaConfigRepo } from './repo.js';
export { GranolaConfig } from './types.js';

View file

@ -0,0 +1,45 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import { GranolaConfig } from './types.js';
export interface IGranolaConfigRepo {
getConfig(): Promise<GranolaConfig>;
setConfig(config: GranolaConfig): Promise<void>;
}
export class FSGranolaConfigRepo implements IGranolaConfigRepo {
private readonly configPath = path.join(WorkDir, 'config', 'granola.json');
private readonly defaultConfig: GranolaConfig = { enabled: false };
constructor() {
this.ensureConfigFile();
}
private async ensureConfigFile(): Promise<void> {
try {
await fs.access(this.configPath);
} catch {
// File doesn't exist, create it with default config
await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2));
}
}
async getConfig(): Promise<GranolaConfig> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
const parsed = JSON.parse(content);
return GranolaConfig.parse(parsed);
} catch {
// If file doesn't exist or is invalid, return default
return this.defaultConfig;
}
}
async setConfig(config: GranolaConfig): Promise<void> {
// Validate before saving
const validated = GranolaConfig.parse(config);
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
}
}

View file

@ -0,0 +1,345 @@
import fs from 'fs';
import path from 'path';
import { homedir } from 'os';
import { WorkDir } from '../../config/config.js';
import { buildGraph } from '../build_graph.js';
import container from '../../di/container.js';
import { IGranolaConfigRepo } from './repo.js';
import {
GetWorkspacesResponse,
GetDocumentListsResponse,
GetDocumentsBatchResponse,
SyncState,
Document,
} from './types.js';
// --- Configuration ---
const GRANOLA_CLIENT_VERSION = '6.462.1';
const GRANOLA_API_BASE = 'https://api.granola.ai';
const GRANOLA_CONFIG_PATH = path.join(homedir(), 'Library', 'Application Support', 'Granola', 'supabase.json');
const SYNC_DIR = path.join(WorkDir, 'granola_notes');
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
const SYNC_INTERVAL_MS = 60 * 1000; // Check every minute
// --- Token Extraction ---
interface WorkosTokens {
access_token: string;
refresh_token?: string;
expires_at?: number;
}
interface SupabaseJson {
workos_tokens?: string; // JSON string containing WorkosTokens
}
function extractAccessToken(): string | null {
try {
if (!fs.existsSync(GRANOLA_CONFIG_PATH)) {
console.log('[Granola] supabase.json not found at:', GRANOLA_CONFIG_PATH);
return null;
}
const content = fs.readFileSync(GRANOLA_CONFIG_PATH, 'utf-8');
const supabaseJson: SupabaseJson = JSON.parse(content);
if (!supabaseJson.workos_tokens) {
console.log('[Granola] workos_tokens not found in supabase.json');
return null;
}
// workos_tokens is a JSON string that needs to be parsed
const tokens: WorkosTokens = JSON.parse(supabaseJson.workos_tokens);
if (!tokens.access_token) {
console.log('[Granola] access_token not found in workos_tokens');
return null;
}
return tokens.access_token;
} catch (error) {
console.error('[Granola] Error extracting access token:', error);
return null;
}
}
// --- API Client ---
function getHeaders(accessToken: string): Record<string, string> {
return {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': `Granola/${GRANOLA_CLIENT_VERSION}`,
'X-Client-Version': GRANOLA_CLIENT_VERSION,
};
}
async function apiCall<T>(
endpoint: string,
accessToken: string,
body: Record<string, unknown> = {}
): Promise<T | null> {
try {
const response = await fetch(`${GRANOLA_API_BASE}${endpoint}`, {
method: 'POST',
headers: getHeaders(accessToken),
body: JSON.stringify(body),
});
if (!response.ok) {
console.error(`[Granola] API error ${response.status}: ${response.statusText}`);
return null;
}
return await response.json() as T;
} catch (error) {
console.error(`[Granola] API call failed for ${endpoint}:`, error);
return null;
}
}
async function getWorkspaces(accessToken: string) {
const response = await apiCall<unknown>('/v1/get-workspaces', accessToken);
if (!response) return null;
try {
return GetWorkspacesResponse.parse(response);
} catch (error) {
console.error('[Granola] Failed to parse workspaces response:', error);
return null;
}
}
async function getDocumentLists(accessToken: string) {
const response = await apiCall<unknown>('/v2/get-document-lists', accessToken);
if (!response) return null;
try {
return GetDocumentListsResponse.parse(response);
} catch (error) {
console.error('[Granola] Failed to parse document lists response:', error);
return null;
}
}
async function getDocumentsBatch(accessToken: string, documentIds: string[]) {
if (documentIds.length === 0) return { docs: [] };
const response = await apiCall<unknown>('/v1/get-documents-batch', accessToken, {
document_ids: documentIds,
include_last_viewed_panel: true,
});
if (!response) return null;
try {
return GetDocumentsBatchResponse.parse(response);
} catch (error) {
console.error('[Granola] Failed to parse documents batch response:', error);
return null;
}
}
// --- State Management ---
function loadState(): SyncState {
if (fs.existsSync(STATE_FILE)) {
try {
const content = fs.readFileSync(STATE_FILE, 'utf-8');
return SyncState.parse(JSON.parse(content));
} catch {
return { lastSyncDate: '', syncedDocs: {} };
}
}
return { lastSyncDate: '', syncedDocs: {} };
}
function saveState(state: SyncState): void {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}
// --- Helpers ---
function cleanFilename(name: string): string {
return name.replace(/[\\/*?:"<>|]/g, '_').substring(0, 100).trim();
}
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function documentToMarkdown(doc: Document): string {
const title = doc.title || 'Untitled';
const createdAt = doc.created_at;
const updatedAt = doc.updated_at || doc.created_at;
let md = `---\n`;
md += `granola_id: ${doc.id}\n`;
md += `title: "${title.replace(/"/g, '\\"')}"\n`;
md += `created_at: ${createdAt}\n`;
md += `updated_at: ${updatedAt}\n`;
md += `---\n\n`;
// Use notes_markdown if available, otherwise notes_plain
if (doc.notes_markdown) {
md += doc.notes_markdown;
} else if (doc.notes_plain) {
md += doc.notes_plain;
}
return md;
}
// --- Sync Logic ---
async function syncNotes(): Promise<void> {
console.log('[Granola] Starting sync...');
// Check if enabled
const granolaRepo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
const config = await granolaRepo.getConfig();
if (!config.enabled) {
console.log('[Granola] Sync disabled in config');
return;
}
// Extract access token
const accessToken = extractAccessToken();
if (!accessToken) {
console.log('[Granola] No access token available');
return;
}
// Ensure sync directory exists
ensureDir(SYNC_DIR);
// Load state
const state = loadState();
// Get workspaces
const workspacesResponse = await getWorkspaces(accessToken);
if (!workspacesResponse) {
console.log('[Granola] Failed to fetch workspaces');
return;
}
console.log(`[Granola] Found ${workspacesResponse.workspaces.length} workspaces`);
// Build workspace lookup
const workspaceMap = new Map<string, { slug: string; displayName: string }>();
for (const ws of workspacesResponse.workspaces) {
workspaceMap.set(ws.workspace.workspace_id, {
slug: ws.workspace.slug,
displayName: ws.workspace.display_name,
});
}
// Get document lists (folders)
const listsResponse = await getDocumentLists(accessToken);
if (!listsResponse) {
console.log('[Granola] Failed to fetch document lists');
return;
}
console.log(`[Granola] Found ${listsResponse.lists.length} folders`);
let newCount = 0;
let updatedCount = 0;
// Process each folder
for (const list of listsResponse.lists) {
const folderName = cleanFilename(list.title);
const folderPath = path.join(SYNC_DIR, folderName);
// Get document IDs from the list
const docIds = list.documents.map(d => d.id);
if (docIds.length === 0) {
console.log(`[Granola] Folder "${list.title}" is empty, skipping`);
continue;
}
console.log(`[Granola] Processing folder "${list.title}" with ${docIds.length} documents`);
// Fetch full documents
const docsResponse = await getDocumentsBatch(accessToken, docIds);
if (!docsResponse) {
console.log(`[Granola] Failed to fetch documents for folder "${list.title}"`);
continue;
}
// Process each document
for (const doc of docsResponse.docs) {
const docUpdatedAt = doc.updated_at || doc.created_at;
const lastSyncedAt = state.syncedDocs[doc.id];
// Check if needs sync (new or updated)
const needsSync = !lastSyncedAt || lastSyncedAt !== docUpdatedAt;
if (!needsSync) {
continue;
}
// Ensure folder exists
ensureDir(folderPath);
// Convert to markdown and save
const markdown = documentToMarkdown(doc);
const docTitle = doc.title || 'Untitled';
const filename = `${doc.id}_${cleanFilename(docTitle)}.md`;
const filePath = path.join(folderPath, filename);
fs.writeFileSync(filePath, markdown);
if (lastSyncedAt) {
console.log(`[Granola] Updated: ${filename}`);
updatedCount++;
} else {
console.log(`[Granola] Saved: ${filename}`);
newCount++;
}
// Update state
state.syncedDocs[doc.id] = docUpdatedAt;
}
}
// Save state
state.lastSyncDate = new Date().toISOString();
saveState(state);
console.log(`[Granola] Sync complete: ${newCount} new, ${updatedCount} updated`);
// Build knowledge graph if there were changes
if (newCount > 0 || updatedCount > 0) {
console.log('[Granola] Starting knowledge graph build...');
try {
await buildGraph(SYNC_DIR);
} catch (error) {
console.error('[Granola] Error building knowledge graph:', error);
}
}
}
// --- Main Loop ---
export async function init(): Promise<void> {
console.log('[Granola] Starting Granola Sync...');
console.log(`[Granola] Will check every ${SYNC_INTERVAL_MS / 1000} seconds.`);
console.log(`[Granola] Notes will be saved to: ${SYNC_DIR}`);
while (true) {
try {
await syncNotes();
} catch (error) {
console.error('[Granola] Error in sync loop:', error);
}
// Sleep before next check
console.log(`[Granola] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
}
}

View file

@ -0,0 +1,110 @@
import z from "zod";
// --- Config Schema ---
export const GranolaConfig = z.object({
enabled: z.boolean(),
});
export type GranolaConfig = z.infer<typeof GranolaConfig>;
// --- API Schemas ---
export const Document = z.object({
id: z.string(),
created_at: z.string(),
updated_at: z.string().nullable(),
deleted_at: z.string().nullable(),
notes: z.object({
type: z.string(),
content: z.array(z.object({
type: z.string(),
attrs: z.object({
id: z.string(),
}).optional(),
content: z.array(z.object({
type: z.string(),
text: z.string().optional(),
})).optional(),
})),
}).optional(),
title: z.string().nullable(),
type: z.string(),
user_id: z.string(),
notes_plain: z.string().optional(),
notes_markdown: z.string().optional(),
workspace_id: z.string().nullable(),
public: z.boolean(),
});
export type Document = z.infer<typeof Document>;
export const GetWorkspacesResponse = z.object({
workspaces: z.array(z.object({
workspace: z.object({
workspace_id: z.string(),
slug: z.string(),
display_name: z.string(),
}),
role: z.string(),
plan_type: z.string(),
})),
});
export type GetWorkspacesResponse = z.infer<typeof GetWorkspacesResponse>;
export const GetDocumentsRequest = z.object({
limit: z.number(),
offset: z.number(),
});
export type GetDocumentsRequest = z.infer<typeof GetDocumentsRequest>;
export const GetDocumentsResponse = z.object({
docs: z.array(Document),
deleted: z.array(z.string()),
});
export type GetDocumentsResponse = z.infer<typeof GetDocumentsResponse>;
export const GetDocumentTranscriptRequest = z.object({
document_id: z.string(),
});
export type GetDocumentTranscriptRequest = z.infer<typeof GetDocumentTranscriptRequest>;
export const GetDocumentTranscriptResponse = z.array(z.object({
source: z.enum(['microphone', 'system']),
text: z.string(),
start_timestamp: z.string(),
end_timestamp: z.string(),
confidence: z.number(),
}));
export type GetDocumentTranscriptResponse = z.infer<typeof GetDocumentTranscriptResponse>;
export const DocumentListItem = z.object({
id: z.string(),
title: z.string(),
created_at: z.string(),
updated_at: z.string(),
documents: z.array(Document),
});
export type DocumentListItem = z.infer<typeof DocumentListItem>;
export const GetDocumentListsResponse = z.object({
lists: z.array(DocumentListItem),
});
export type GetDocumentListsResponse = z.infer<typeof GetDocumentListsResponse>;
export const GetDocumentsBatchRequest = z.object({
document_ids: z.array(z.string()),
});
export type GetDocumentsBatchRequest = z.infer<typeof GetDocumentsBatchRequest>;
export const GetDocumentsBatchResponse = z.object({
docs: z.array(Document),
});
export type GetDocumentsBatchResponse = z.infer<typeof GetDocumentsBatchResponse>;
// --- Sync State Schema ---
export const SyncState = z.object({
lastSyncDate: z.string(),
syncedDocs: z.record(z.string(), z.string()), // { documentId: updated_at }
});
export type SyncState = z.infer<typeof SyncState>;