diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 5405163e..a222f869 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -6,6 +6,7 @@ import { dirname } from "node:path"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; +import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -54,6 +55,9 @@ app.whenReady().then(() => { // start fireflies sync initFirefliesSync(); + // start granola sync + initGranolaSync(); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 0297421d..2e85d34a 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -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(FSRunsRepo).singleton(), oauthRepo: asClass(FSOAuthRepo).singleton(), clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(), + granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/granola/index.ts b/apps/x/packages/core/src/knowledge/granola/index.ts new file mode 100644 index 00000000..84682af4 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/granola/index.ts @@ -0,0 +1,5 @@ +// Re-export public API +export { init } from './sync.js'; +export { IGranolaConfigRepo, FSGranolaConfigRepo } from './repo.js'; +export { GranolaConfig } from './types.js'; + diff --git a/apps/x/packages/core/src/knowledge/granola/repo.ts b/apps/x/packages/core/src/knowledge/granola/repo.ts new file mode 100644 index 00000000..edf89261 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/granola/repo.ts @@ -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; + setConfig(config: GranolaConfig): Promise; +} + +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 { + 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 { + 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 { + // Validate before saving + const validated = GranolaConfig.parse(config); + await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2)); + } +} + diff --git a/apps/x/packages/core/src/knowledge/granola/sync.ts b/apps/x/packages/core/src/knowledge/granola/sync.ts new file mode 100644 index 00000000..32c785a3 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/granola/sync.ts @@ -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 { + return { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': `Granola/${GRANOLA_CLIENT_VERSION}`, + 'X-Client-Version': GRANOLA_CLIENT_VERSION, + }; +} + +async function apiCall( + endpoint: string, + accessToken: string, + body: Record = {} +): Promise { + 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('/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('/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('/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 { + console.log('[Granola] Starting sync...'); + + // Check if enabled + const granolaRepo = container.resolve('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(); + 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 { + 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)); + } +} + diff --git a/apps/x/packages/core/src/knowledge/granola/types.ts b/apps/x/packages/core/src/knowledge/granola/types.ts new file mode 100644 index 00000000..aeec884b --- /dev/null +++ b/apps/x/packages/core/src/knowledge/granola/types.ts @@ -0,0 +1,110 @@ +import z from "zod"; + +// --- Config Schema --- + +export const GranolaConfig = z.object({ + enabled: z.boolean(), +}); +export type GranolaConfig = z.infer; + +// --- 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; + +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; + +export const GetDocumentsRequest = z.object({ + limit: z.number(), + offset: z.number(), +}); +export type GetDocumentsRequest = z.infer; + +export const GetDocumentsResponse = z.object({ + docs: z.array(Document), + deleted: z.array(z.string()), +}); +export type GetDocumentsResponse = z.infer; + +export const GetDocumentTranscriptRequest = z.object({ + document_id: z.string(), +}); +export type GetDocumentTranscriptRequest = z.infer; + +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; + +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; + +export const GetDocumentListsResponse = z.object({ + lists: z.array(DocumentListItem), +}); +export type GetDocumentListsResponse = z.infer; + +export const GetDocumentsBatchRequest = z.object({ + document_ids: z.array(z.string()), +}); +export type GetDocumentsBatchRequest = z.infer; + +export const GetDocumentsBatchResponse = z.object({ + docs: z.array(Document), +}); +export type GetDocumentsBatchResponse = z.infer; + +// --- 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; +