diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 178cb7e15..4f1a81e70 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -7,12 +7,12 @@ const pkg = require('./package.json'); module.exports = { packagerConfig: { - executableName: 'rowboat', + executableName: 'assistant', icon: './icons/icon', // .icns extension added automatically - appBundleId: 'com.rowboat.app', + appBundleId: 'com.assistant.app', appCategoryType: 'public.app-category.productivity', extendInfo: { - NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', + NSAudioCaptureUsageDescription: 'This app needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', }, osxSign: { batchCodesignCalls: true, @@ -43,27 +43,27 @@ module.exports = { name: '@electron-forge/maker-dmg', config: (arch) => ({ format: 'ULFO', - name: `Rowboat-darwin-${arch}-${pkg.version}`, // Architecture-specific name to avoid conflicts + name: `Assistant-darwin-${arch}-${pkg.version}`, }) }, { name: '@electron-forge/maker-squirrel', config: (arch) => ({ - authors: 'rowboatlabs', + authors: 'gokulb20', description: 'AI coworker with memory', - name: `Rowboat-win32-${arch}`, - setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, + name: `Assistant-win32-${arch}`, + setupExe: `Assistant-win32-${arch}-${pkg.version}-setup.exe`, }) }, { name: '@electron-forge/maker-deb', config: (arch) => ({ options: { - name: `Rowboat-linux`, - bin: "rowboat", + name: `Assistant-linux`, + bin: "assistant", description: 'AI coworker with memory', - maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com' + maintainer: 'gokulb20', + homepage: 'https://github.com/gokulb20/rowboat' } }) }, @@ -71,10 +71,10 @@ module.exports = { name: '@electron-forge/maker-rpm', config: { options: { - name: `Rowboat-linux`, - bin: "rowboat", + name: `Assistant-linux`, + bin: "assistant", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com' + homepage: 'https://github.com/gokulb20/rowboat' } } }, @@ -88,7 +88,7 @@ module.exports = { name: '@electron-forge/publisher-github', config: { repository: { - owner: 'rowboatlabs', + owner: 'gokulb20', name: 'rowboat' }, prerelease: true diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb15984..0c04de03c 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -1,6 +1,6 @@ { - "name": "rowboat", - "productName": "Rowboat", + "name": "assistant", + "productName": "Assistant", "description": "AI coworker with memory", "type": "module", "version": "0.1.0", diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index d319c5fb6..eec80a9f6 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -41,7 +41,7 @@ export const BROWSER_PARTITION = 'persist:rowboat-browser'; const SPOOF_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; -const HOME_URL = 'https://www.google.com'; +const HOME_URL = 'http://localhost:3210/sites/homepage/'; const NAVIGATION_TIMEOUT_MS = 10000; const POST_ACTION_IDLE_MS = 400; const POST_ACTION_MAX_ELEMENTS = 25; @@ -231,6 +231,20 @@ export class BrowserViewManager extends EventEmitter { } return { action: 'deny' }; }); + + // Intercept rowboat:// protocol URLs for internal actions (e.g. md-edit) + wc.on('will-navigate', (event, url) => { + if (url.startsWith('rowboat://')) { + event.preventDefault(); + const parsed = new URL(url); + if (parsed.host === 'md-edit') { + const filePath = parsed.searchParams.get('path'); + if (filePath && this.window && !this.window.isDestroyed()) { + this.window.webContents.send('browser:mdEdit', { path: filePath }); + } + } + } + }); } private snapshotTabState(tab: BrowserTab): BrowserTabState { diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a9de9572d..845d70b3e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -26,7 +26,6 @@ import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; import { testModelConnection } from '@x/core/dist/models/models.js'; import { isSignedIn } from '@x/core/dist/account/account.js'; -import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; @@ -40,10 +39,8 @@ import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedu import { search } from '@x/core/dist/search/search.js'; import { versionHistory, voice } from '@x/core'; import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js'; -import { getBillingInfo } from '@x/core/dist/billing/billing.js'; import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; -import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js'; import { trackBus } from '@x/core/dist/knowledge/track/bus.js'; import { @@ -480,9 +477,6 @@ export function setupIpcHandlers() { return { success: true }; }, 'models:list': async () => { - if (await isSignedIn()) { - return await listGatewayModels(); - } return await listOnboardingModels(); }, 'models:test': async (_event, args) => { @@ -511,19 +505,8 @@ export function setupIpcHandlers() { return { config }; }, 'account:getRowboat': async () => { - const signedIn = await isSignedIn(); - if (!signedIn) { - return { signedIn: false, accessToken: null, config: null }; - } - - const config = await getRowboatConfig(); - - try { - const accessToken = await getAccessToken(); - return { signedIn: true, accessToken, config }; - } catch { - return { signedIn: true, accessToken: null, config }; - } + // Rowboat cloud sign-in has been removed + return { signedIn: false, accessToken: null, config: null }; }, 'granola:getConfig': async () => { const repo = container.resolve('granolaConfigRepo'); @@ -606,6 +589,24 @@ export function setupIpcHandlers() { 'composio:use-composio-for-google-calendar': async () => { return composioHandler.useComposioForGoogleCalendar(); }, + // Supermemory integration handlers + 'supermemory:is-configured': async () => { + const { isConfigured } = await import('@x/core/dist/supermemory/client.js'); + return { configured: await isConfigured() }; + }, + 'supermemory:set-api-key': async (_event, args) => { + try { + const { setApiKey } = await import('@x/core/dist/supermemory/client.js'); + setApiKey(args.apiKey); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : 'Failed to save' }; + } + }, + 'supermemory:test-connection': async () => { + const { testConnection } = await import('@x/core/dist/supermemory/client.js'); + return { success: await testConnection() }; + }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { const repo = container.resolve('agentScheduleRepo'); @@ -822,9 +823,9 @@ export function setupIpcHandlers() { return { success: false, error: err instanceof Error ? err.message : String(err) }; } }, - // Billing handler + // Billing handler — no longer available (Rowboat cloud removed) 'billing:getInfo': async () => { - return await getBillingInfo(); + return { userEmail: null, userId: null, subscriptionPlan: null, subscriptionStatus: null, trialExpiresAt: null, sanctionedCredits: 0, availableCredits: 0 }; }, // Embedded browser handlers (WebContentsView + navigation) ...browserIpcHandlers, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index eea21481c..f17a761f8 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -15,7 +15,7 @@ import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; 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"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; @@ -28,6 +28,8 @@ import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/ev import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; +import { WorkDir } from "@x/core/dist/config/config.js"; +import fs from "node:fs"; import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; @@ -203,16 +205,9 @@ app.whenReady().then(async () => { registerAppProtocol(); } - // Initialize auto-updater (only in production) - if (app.isPackaged) { - updateElectronApp({ - updateSource: { - type: UpdateSourceType.ElectronPublicUpdateService, - repo: "rowboatlabs/rowboat", - }, - notifyUser: true, // Shows native dialog when update is available - }); - } + // Auto-updater disabled (Rowboat cloud dependencies removed) + // To re-enable, point this to your own fork: + // repo: "gokulb20/rowboat" // Ensure agent-slack CLI is available try { @@ -259,17 +254,50 @@ app.whenReady().then(async () => { // start track event processor (consumes events/pending/, triggers matching tracks) initTrackEventProcessor(); - // start gmail sync - initGmailSync(); + // start gmail sync — only if configured + if (fs.existsSync(path.join(WorkDir, 'config', 'composio.json'))) { + initGmailSync(); + } else { + console.log('[main] Gmail sync disabled — no Composio config'); + } - // start calendar sync - initCalendarSync(); + // start calendar sync — only if configured + if (fs.existsSync(path.join(WorkDir, 'config', 'composio.json'))) { + initCalendarSync(); + } else { + console.log('[main] Calendar sync disabled — no Composio config'); + } - // start fireflies sync - initFirefliesSync(); + // fireflies sync removed — not used - // start granola sync - initGranolaSync(); + // seed Composio API key from environment if not already configured + try { + const { getApiKey, setApiKey } = await import('@x/core/dist/composio/client.js'); + if (!getApiKey() && process.env.COMPOSIO_API_KEY) { + setApiKey(process.env.COMPOSIO_API_KEY); + console.log('[main] Seeded Composio API key from environment'); + } + } catch (e) { + console.error('[main] Failed to seed Composio API key:', e); + } + + // seed Supermemory API key from environment if not already configured + try { + const sm = await import('@x/core/dist/supermemory/client.js'); + if (!sm.getApiKey() && process.env.SUPERMEMORY_API_KEY) { + sm.setApiKey(process.env.SUPERMEMORY_API_KEY); + console.log('[main] Seeded Supermemory API key from environment'); + } + } catch (e) { + console.error('[main] Failed to seed Supermemory API key:', e); + } + + // start granola sync — only if configured + if (fs.existsSync(path.join(WorkDir, 'config', 'granola.json'))) { + initGranolaSync(); + } else { + console.log('[main] Granola sync disabled — no config'); + } // start knowledge graph builder initGraphBuilder(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 3bb9063bb..e2b6bfe24 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -9,9 +9,7 @@ import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; -import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { emitOAuthEvent } from './ipc.js'; -import { getBillingInfo } from '@x/core/dist/billing/billing.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -268,19 +266,6 @@ export async function connectProvider(provider: string, credentials?: { clientId if (provider === 'google') { triggerGmailSync(); triggerCalendarSync(); - } else if (provider === 'fireflies-ai') { - triggerFirefliesSync(); - } - - // For Rowboat sign-in, ensure user + Stripe customer exist before - // notifying the renderer. Without this, parallel API calls from - // multiple renderer hooks race to create the user, causing duplicates. - if (provider === 'rowboat') { - try { - await getBillingInfo(); - } catch (meError) { - console.error('[OAuth] Failed to initialize user via /v1/me:', meError); - } } // Emit success event to renderer diff --git a/apps/x/apps/renderer/index.html b/apps/x/apps/renderer/index.html index 856065c22..eaaaa3caa 100644 --- a/apps/x/apps/renderer/index.html +++ b/apps/x/apps/renderer/index.html @@ -4,7 +4,7 @@ - Rowboat + Assistant + + +
+ +
+ ${html} + +`; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + function createLocalSitesApp(): express.Express { const app = express(); @@ -520,6 +634,163 @@ function createLocalSitesApp(): express.Express { }); }); + // Workspace file serving — lets the embedded browser render any workspace file + // via http://localhost:3210/workspace/ + app.use('/workspace/', (req, res) => { + const relPath = req.path.replace(/^\/+/, ''); + const absPath = path.join(WorkDir, relPath); + + // Prevent path traversal + if (!absPath.startsWith(WorkDir)) { + res.status(403).json({ error: 'Path traversal not allowed' }); + return; + } + + fs.promises.readFile(absPath).then((data) => { + const ext = path.extname(absPath).toLowerCase(); + const mime = MIME_TYPES[ext] || 'application/octet-stream'; + res.setHeader('Content-Type', mime); + res.setHeader('Content-Disposition', `inline; filename="${path.basename(absPath)}"`); + res.send(data); + }).catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + res.status(404).json({ error: 'File not found' }); + } else if (err.code === 'EISDIR') { + res.status(400).json({ error: 'Is a directory' }); + } else { + res.status(500).json({ error: err.message }); + } + }); + }); + + // Markdown viewer — renders .md files as styled HTML in the embedded browser + // via http://localhost:3210/md-view/ + app.get('/md-view', (req, res) => { + const relPath = (req.query.p as string || '').replace(/^\/+/, ''); + if (!relPath) { + res.status(400).json({ error: 'Missing ?p= parameter' }); + return; + } + const absPath = path.join(WorkDir, relPath); + + if (!absPath.startsWith(WorkDir)) { + res.status(403).json({ error: 'Path traversal not allowed' }); + return; + } + + fs.promises.readFile(absPath, 'utf-8').then((md) => { + const html = markdownToHtml(md, relPath); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(html); + }).catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + res.status(404).json({ error: 'File not found' }); + } else if (err.code === 'EISDIR') { + res.status(400).json({ error: 'Is a directory' }); + } else { + res.status(500).json({ error: err.message }); + } + }); + }); + + // Local file serving — lets the embedded browser view any local file + // via http://localhost:3210/local-file?p= + // Resolves ~ to home directory. Only serves files with viewable extensions. + const LOCAL_FILE_ALLOWED_EXTENSIONS = new Set([ + '.pdf', '.html', '.htm', '.svg', + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', + '.csv', '.json', '.xml', '.txt', '.md', + ]); + + app.get('/local-file', (req, res) => { + const rawPath = req.query.p as string; + if (!rawPath) { + res.status(400).json({ error: 'Missing ?p= parameter' }); + return; + } + + // Resolve ~ to home directory + const resolvedPath = rawPath.startsWith('~') + ? path.join(os.homedir(), rawPath.slice(1).replace(/^\/+/, '')) + : path.resolve(rawPath); + + // Only serve files with allowed extensions + const ext = path.extname(resolvedPath).toLowerCase(); + if (!LOCAL_FILE_ALLOWED_EXTENSIONS.has(ext)) { + res.status(403).json({ error: 'File type not supported' }); + return; + } + + // Only serve regular files (no directories, symlinks are fine) + fs.promises.stat(resolvedPath).then((stat) => { + if (!stat.isFile()) { + res.status(400).json({ error: 'Not a regular file' }); + return; + } + + const mime = MIME_TYPES[ext] || 'application/octet-stream'; + res.setHeader('Content-Type', mime); + res.setHeader('Content-Disposition', `inline; filename="${path.basename(resolvedPath)}"`); + fs.createReadStream(resolvedPath).pipe(res); + }).catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + res.status(404).json({ error: 'File not found' }); + } else { + res.status(500).json({ error: err.message }); + } + }); + }); + + // Vault — unified file access with alias-based routing + // /vault// serves files from the designated folder + // /vault.json returns the current vault configuration + app.get('/vault.json', (_req, res) => { + const config = loadVaultConfig(); + res.json(config); + }); + + app.use('/vault', (req, res) => { + // Route: /vault// + // Also: /vault.json (handled by the GET route above) + if (req.path === '' || req.path === '/') { + res.status(400).json({ error: 'Usage: /vault//' }); + return; + } + + // Extract alias and relative path from the URL + const parts = req.path.replace(/^\/+/, '').split('/'); + const alias = parts[0]; + const relPath = parts.slice(1).join('/'); + const config = loadVaultConfig(); + const absPath = resolveVaultPath(alias, relPath, config); + + if (!absPath) { + res.status(404).json({ error: 'Vault alias not found or path traversal blocked' }); + return; + } + + const ext = path.extname(absPath).toLowerCase(); + const mime = MIME_TYPES[ext] || 'application/octet-stream'; + + fs.promises.stat(absPath).then((stat) => { + if (!stat.isFile()) { + res.status(400).json({ error: 'Not a regular file' }); + return; + } + res.setHeader('Content-Type', mime); + res.setHeader('Content-Disposition', `inline; filename="${path.basename(absPath)}"`); + fs.createReadStream(absPath).pipe(res); + }).catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + res.status(404).json({ error: 'File not found' }); + } else if (err.code === 'EACCES') { + res.status(403).json({ error: 'Permission denied' }); + } else { + res.status(500).json({ error: err.message }); + } + }); + }); + return app; } diff --git a/apps/x/packages/core/src/local-sites/templates.ts b/apps/x/packages/core/src/local-sites/templates.ts index b20c42210..48c123efe 100644 --- a/apps/x/packages/core/src/local-sites/templates.ts +++ b/apps/x/packages/core/src/local-sites/templates.ts @@ -622,4 +622,54 @@ async function refresh() { refresh(); setInterval(refresh, 120000); `, + 'homepage/index.html': ` + + + + +Home + + + +
+

Welcome home

+

Your local dashboard. Edit ~/.rowboat/sites/homepage/index.html to customize this page.

+ + +
+ +`, } diff --git a/apps/x/packages/core/src/local-sites/vault.ts b/apps/x/packages/core/src/local-sites/vault.ts new file mode 100644 index 000000000..f92fb2545 --- /dev/null +++ b/apps/x/packages/core/src/local-sites/vault.ts @@ -0,0 +1,142 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { WorkDir } from '../config/config.js'; + +// --- Vault folder configuration --- +// Each vault folder maps a short alias to a physical directory on disk. +// Files are served via /vault//. + +export interface VaultFolder { + alias: string; + path: string; // ~ and env vars expanded at load time + default?: boolean; // the workspace folder is always present +} + +export interface VaultConfig { + folders: VaultFolder[]; +} + +const VAULT_CONFIG_PATH = path.join(WorkDir, 'config', 'vault.json'); + +function expandPath(p: string): string { + if (p === '~') return os.homedir(); + if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2)); + return path.resolve(p); +} + +function detectDefaultFolders(): VaultFolder[] { + const folders: VaultFolder[] = [ + { alias: 'workspace', path: WorkDir, default: true }, + ]; + + const candidates: Array<{ alias: string; dir: string }> = [ + { alias: 'downloads', dir: path.join(os.homedir(), 'Downloads') }, + { alias: 'desktop', dir: path.join(os.homedir(), 'Desktop') }, + { alias: 'documents', dir: path.join(os.homedir(), 'Documents') }, + ]; + + for (const { alias, dir } of candidates) { + try { + if (fs.statSync(dir).isDirectory()) { + folders.push({ alias, path: dir }); + } + } catch { + // Directory doesn't exist, skip it + } + } + + return folders; +} + +export function loadVaultConfig(): VaultConfig { + try { + const raw = fs.readFileSync(VAULT_CONFIG_PATH, 'utf-8'); + const parsed = JSON.parse(raw) as VaultConfig; + + // Expand paths + for (const folder of parsed.folders) { + folder.path = expandPath(folder.path); + } + + // Ensure workspace is always present + if (!parsed.folders.some(f => f.alias === 'workspace')) { + parsed.folders.unshift({ alias: 'workspace', path: WorkDir, default: true }); + } + + return parsed; + } catch { + return { folders: detectDefaultFolders() }; + } +} + +export function saveVaultConfig(config: VaultConfig): void { + const dir = path.dirname(VAULT_CONFIG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + // Store paths in compact form (use ~/ for home-relative paths) + const serializable = { + folders: config.folders.map(f => ({ + ...f, + path: f.path.startsWith(os.homedir()) + ? '~' + f.path.slice(os.homedir().length) + : f.path, + })), + }; + + fs.writeFileSync(VAULT_CONFIG_PATH, JSON.stringify(serializable, null, 2)); +} + +export function ensureVaultConfig(): void { + if (fs.existsSync(VAULT_CONFIG_PATH)) return; + const config = { folders: detectDefaultFolders() }; + saveVaultConfig(config); +} + +/** Resolve a vault alias + relative path to an absolute file path. + * Returns null if the alias doesn't exist or the path traverses outside the folder. */ +export function resolveVaultPath(alias: string, relPath: string, config: VaultConfig): string | null { + const folder = config.folders.find(f => f.alias === alias); + if (!folder) return null; + + const absPath = path.resolve(folder.path, relPath); + // Path traversal check + if (!absPath.startsWith(folder.path + path.sep) && absPath !== folder.path) return null; + + return absPath; +} + +/** Given an absolute path, find the vault alias + relative path. + * Returns null if the path doesn't fall inside any vault folder. */ +export function findVaultAlias(absPath: string, config: VaultConfig): { alias: string; relPath: string } | null { + // Sort folders by path length descending so more specific matches win + const sorted = [...config.folders].sort((a, b) => b.path.length - a.path.length); + + for (const folder of sorted) { + if (absPath.startsWith(folder.path + path.sep) || absPath === folder.path) { + const relPath = path.relative(folder.path, absPath); + return { alias: folder.alias, relPath }; + } + } + + return null; +} + +/** Given an arbitrary file path (may be relative, ~, or absolute), + * resolve it to a vault URL path like /vault//. + * Returns null if the path doesn't fall inside any vault folder. */ +export function filePathToVaultUrl(filePath: string, config: VaultConfig): string | null { + const expanded = expandPath(filePath); + + // Check if it's a workspace-relative path (e.g. "knowledge/notes.md") + if (!filePath.startsWith('/') && !filePath.startsWith('~') && !filePath.includes(':')) { + const absPath = path.resolve(WorkDir, filePath); + const match = findVaultAlias(absPath, config); + if (match) return `/vault/${match.alias}/${match.relPath}`; + } + + const match = findVaultAlias(expanded, config); + if (match) return `/vault/${match.alias}/${match.relPath}`; + + return null; +} diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 66dda9e09..b19a8bb53 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -1,23 +1,14 @@ import z from "zod"; import { LlmProvider } from "@x/shared/dist/models.js"; import { IModelConfigRepo } from "./repo.js"; -import { isSignedIn } from "../account/account.js"; import container from "../di/container.js"; -const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; -const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; -const SIGNED_IN_KG_MODEL = "anthropic/claude-haiku-4.5"; -const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5"; - /** * The single source of truth for "what model+provider should we use when - * the caller didn't specify and the agent didn't declare". Returns names only. - * This is the only place that branches on signed-in state. + * the caller didn't specify and the agent didn't declare". Always reads from + * the user's models.json config (BYOK mode). */ export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> { - if (await isSignedIn()) { - return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER }; - } const repo = container.resolve("modelConfigRepo"); const cfg = await repo.getConfig(); return { model: cfg.model, provider: cfg.provider.flavor }; @@ -28,15 +19,11 @@ export async function getDefaultModelAndProvider(): Promise<{ model: string; pro * getDefaultModelAndProvider) into the full LlmProvider config that * createProvider expects (apiKey/baseURL/headers). * - * - "rowboat" → gateway provider (auth via OAuth bearer; no creds field). * - other names → look up models.json's `providers[name]` map. * - fallback: if the name matches the active default's flavor (legacy * single-provider configs that didn't write to the providers map yet). */ export async function resolveProviderConfig(name: string): Promise> { - if (name === "rowboat") { - return { flavor: "rowboat" }; - } const repo = container.resolve("modelConfigRepo"); const cfg = await repo.getConfig(); const entry = cfg.providers?.[name]; @@ -56,33 +43,27 @@ export async function resolveProviderConfig(name: string): Promise { - if (await isSignedIn()) return SIGNED_IN_KG_MODEL; const cfg = await container.resolve("modelConfigRepo").getConfig(); return cfg.knowledgeGraphModel ?? cfg.model; } /** * Model used by track-block runner + routing classifier. - * Signed-in: curated default. BYOK: user override (`trackBlockModel`) or - * assistant model. + * BYOK: user override (`trackBlockModel`) or assistant model. */ export async function getTrackBlockModel(): Promise { - if (await isSignedIn()) return SIGNED_IN_TRACK_BLOCK_MODEL; const cfg = await container.resolve("modelConfigRepo").getConfig(); return cfg.trackBlockModel ?? cfg.model; } /** - * Model used by the meeting-notes summarizer. No special signed-in default — - * historically meetings used the assistant model. BYOK: user override - * (`meetingNotesModel`) or assistant model. + * Model used by the meeting-notes summarizer. + * BYOK: user override (`meetingNotesModel`) or assistant model. */ export async function getMeetingNotesModel(): Promise { - if (await isSignedIn()) return SIGNED_IN_DEFAULT_MODEL; const cfg = await container.resolve("modelConfigRepo").getConfig(); return cfg.meetingNotesModel ?? cfg.model; } diff --git a/apps/x/packages/core/src/models/gateway.ts b/apps/x/packages/core/src/models/gateway.ts deleted file mode 100644 index 6f613704d..000000000 --- a/apps/x/packages/core/src/models/gateway.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ProviderV2 } from '@ai-sdk/provider'; -import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import { getAccessToken } from '../auth/tokens.js'; -import { API_URL } from '../config/env.js'; - -const authedFetch: typeof fetch = async (input, init) => { - const token = await getAccessToken(); - const headers = new Headers(init?.headers); - headers.set('Authorization', `Bearer ${token}`); - return fetch(input, { ...init, headers }); -}; - -export function getGatewayProvider(): ProviderV2 { - return createOpenRouter({ - baseURL: `${API_URL}/v1/llm`, - apiKey: 'managed-by-rowboat', - fetch: authedFetch, - }); -} - -type ProviderSummary = { - id: string; - name: string; - models: Array<{ - id: string; - name?: string; - release_date?: string; - }>; -}; - -export async function listGatewayModels(): Promise<{ providers: ProviderSummary[] }> { - const accessToken = await getAccessToken(); - const response = await fetch(`${API_URL}/v1/llm/models`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - throw new Error(`Gateway /v1/models failed: ${response.status}`); - } - const body = await response.json() as { data: Array<{ id: string }> }; - const models = body.data.map((m) => ({ id: m.id })); - return { - providers: [{ - id: 'rowboat', - name: 'Rowboat', - models, - }], - }; -} diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 92353f0ab..d221a243c 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -8,7 +8,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js"; import z from "zod"; -import { getGatewayProvider } from "./gateway.js"; export const Provider = LlmProvider; export const ModelConfig = LlmModelConfig; @@ -46,9 +45,14 @@ export function createProvider(config: z.infer): ProviderV2 { if (ollamaURL && !ollamaURL.replace(/\/+$/, '').endsWith('/api')) { ollamaURL = ollamaURL.replace(/\/+$/, '') + '/api'; } + // For Ollama Cloud, include API key as Bearer token + const ollamaHeaders: Record = { ...(headers || {}) }; + if (apiKey) { + ollamaHeaders['Authorization'] = `Bearer ${apiKey}`; + } return createOllama({ baseURL: ollamaURL, - headers, + headers: ollamaHeaders, }); } case "openai-compatible": @@ -64,8 +68,6 @@ export function createProvider(config: z.infer): ProviderV2 { baseURL, headers, }) as unknown as ProviderV2; - case "rowboat": - return getGatewayProvider(); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } diff --git a/apps/x/packages/core/src/supermemory/client.ts b/apps/x/packages/core/src/supermemory/client.ts new file mode 100644 index 000000000..6d37e7fe4 --- /dev/null +++ b/apps/x/packages/core/src/supermemory/client.ts @@ -0,0 +1,107 @@ +import { WorkDir } from "../config/config.js"; +import fs from "fs"; +import path from "path"; + +const CONFIG_FILE = path.join(WorkDir, "config", "supermemory.json"); +const API_BASE = "https://api.supermemory.ai"; +const DEFAULT_CONTAINER_TAG = "rowboat-user"; + +interface SupermemoryConfig { + apiKey?: string; + containerTag?: string; +} + +function loadConfig(): SupermemoryConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); + } + } catch (error) { + console.error("[Supermemory] Failed to load config:", error); + } + return {}; +} + +function saveConfig(config: SupermemoryConfig): void { + const dir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +export function getApiKey(): string | null { + const config = loadConfig(); + return config.apiKey || process.env.SUPERMEMORY_API_KEY || null; +} + +export function setApiKey(apiKey: string): void { + const config = loadConfig(); + config.apiKey = apiKey; + saveConfig(config); +} + +export function getContainerTag(): string { + const config = loadConfig(); + return config.containerTag || DEFAULT_CONTAINER_TAG; +} + +export async function isConfigured(): Promise { + return !!getApiKey(); +} + +async function authedFetch(path: string, options: RequestInit = {}): Promise { + const apiKey = getApiKey(); + if (!apiKey) throw new Error("Supermemory API key not configured"); + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${apiKey}`); + headers.set("Content-Type", "application/json"); + return fetch(`${API_BASE}${path}`, { ...options, headers }); +} + +export async function addDocument(content: string, containerTag?: string): Promise<{ id: string; status: string }> { + const response = await authedFetch("/v3/documents", { + method: "POST", + body: JSON.stringify({ + content, + containerTag: containerTag || getContainerTag(), + taskType: "memory", + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Supermemory add failed: ${response.status} ${text}`); + } + return response.json(); +} + +export interface ProfileResult { + profile: { static: string[]; dynamic: string[] }; + searchResults: { results: Array<{ memory: string; score?: number }> }; +} + +export async function getProfile(query: string, containerTag?: string): Promise { + const tag = containerTag || getContainerTag(); + const response = await authedFetch( + `/v3/memory/profile?containerTag=${encodeURIComponent(tag)}&q=${encodeURIComponent(query)}` + ); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Supermemory profile failed: ${response.status} ${text}`); + } + return response.json(); +} + +export async function testConnection(): Promise { + try { + const apiKey = getApiKey(); + if (!apiKey) return false; + const response = await fetch(`${API_BASE}/v3/memory/profile?containerTag=test&q=test`, { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }); + return response.status === 200 || response.status === 404; + } catch { + return false; + } +} diff --git a/apps/x/packages/core/src/voice/voice.ts b/apps/x/packages/core/src/voice/voice.ts index 1cfba03bd..992d653fb 100644 --- a/apps/x/packages/core/src/voice/voice.ts +++ b/apps/x/packages/core/src/voice/voice.ts @@ -1,9 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { isSignedIn } from '../account/account.js'; -import { getAccessToken } from '../auth/tokens.js'; import { WorkDir } from '../config/config.js'; -import { API_URL } from '../config/env.js'; export interface VoiceConfig { deepgram: { apiKey: string } | null; @@ -34,32 +31,17 @@ export async function getVoiceConfig(): Promise { export async function synthesizeSpeech(text: string): Promise<{ audioBase64: string; mimeType: string }> { const config = await getVoiceConfig(); - const signedIn = await isSignedIn(); - let url: string; - let headers: Record; - - if (signedIn) { - const voiceId = config.elevenlabs?.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; - const accessToken = await getAccessToken(); - url = `${API_URL}/v1/voice/text-to-speech/${voiceId}`; - headers = { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }; - console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId); - } else { - if (!config.elevenlabs) { - throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "" }`); - } - const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; - url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; - headers = { - 'xi-api-key': config.elevenlabs.apiKey, - 'Content-Type': 'application/json', - }; - console.log('[voice] synthesizing speech via ElevenLabs, text length:', text.length, 'voiceId:', voiceId); + if (!config.elevenlabs) { + throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "" }`); } + const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0'; + const url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; + const headers: Record = { + 'xi-api-key': config.elevenlabs.apiKey, + 'Content-Type': 'application/json', + }; + console.log('[voice] synthesizing speech via ElevenLabs, text length:', text.length, 'voiceId:', voiceId); const response = await fetch(url, { method: 'POST', diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index cc98f4f11..5077f80d9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -720,6 +720,25 @@ const ipcSchemas = { req: BrowserStateSchema, res: z.null(), }, + 'browser:mdEdit': { + req: z.object({ path: z.string() }), + res: z.null(), + }, + // Supermemory channels + 'supermemory:is-configured': { + req: z.null(), + res: z.object({ + configured: z.boolean(), + }), + }, + 'supermemory:set-api-key': { + req: z.object({ apiKey: z.string() }), + res: z.object({ success: z.boolean(), error: z.string().optional() }), + }, + 'supermemory:test-connection': { + req: z.null(), + res: z.object({ success: z.boolean() }), + }, // Billing channels 'billing:getInfo': { req: z.null(), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index e5b0e82fa..e1b2ab32f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const LlmProvider = z.object({ - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), diff --git a/apps/x/packages/shared/src/service-events.ts b/apps/x/packages/shared/src/service-events.ts index b7a7c5799..841f1955c 100644 --- a/apps/x/packages/shared/src/service-events.ts +++ b/apps/x/packages/shared/src/service-events.ts @@ -4,7 +4,6 @@ export const ServiceName = z.enum([ 'graph', 'gmail', 'calendar', - 'fireflies', 'granola', 'voice_memo', 'email_labeling',