mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-29 18:36:23 +02:00
add granola sync
This commit is contained in:
parent
6a0e5981dd
commit
949d36c04d
6 changed files with 511 additions and 0 deletions
|
|
@ -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;
|
||||
5
apps/x/packages/core/src/knowledge/granola/index.ts
Normal file
5
apps/x/packages/core/src/knowledge/granola/index.ts
Normal 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';
|
||||
|
||||
45
apps/x/packages/core/src/knowledge/granola/repo.ts
Normal file
45
apps/x/packages/core/src/knowledge/granola/repo.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
345
apps/x/packages/core/src/knowledge/granola/sync.ts
Normal file
345
apps/x/packages/core/src/knowledge/granola/sync.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
110
apps/x/packages/core/src/knowledge/granola/types.ts
Normal file
110
apps/x/packages/core/src/knowledge/granola/types.ts
Normal 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>;
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue