diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index f7c25752..4bd774c2 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -8,7 +8,7 @@ permissions: contents: write # Required to upload release assets jobs: - build: + build-macos: runs-on: macos-latest steps: @@ -75,7 +75,7 @@ jobs: security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" # Add keychain to search list - security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain # Verify certificate was imported security find-identity -v "$KEYCHAIN_PATH" @@ -87,31 +87,16 @@ jobs: run: pnpm install --frozen-lockfile working-directory: apps/x - - name: Build and publish to S3 + - name: Build electron app env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} - run: npm run publish - working-directory: apps/x/apps/main - - - name: Upload workflow artifacts - uses: actions/upload-artifact@v4 - with: - name: distributables - path: apps/x/apps/main/out/make/* - retention-days: 30 - - - name: Attach files to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: apps/x/apps/main/out/make/* - env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx electron-forge publish --arch=arm64,x64 --platform=darwin + working-directory: apps/x/apps/main - name: Cleanup keychain if: always() @@ -120,3 +105,140 @@ jobs: if [ -f "$KEYCHAIN_PATH" ]; then security delete-keychain "$KEYCHAIN_PATH" || true fi + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: distributables + path: apps/x/apps/main/out/make/* + retention-days: 30 + + build-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: 'apps/x/pnpm-lock.yaml' + + - name: Extract version from tag + id: version + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version: ${VERSION}" + + - name: Update package.json versions + run: | + node -e " + const fs = require('fs'); + const version = '${{ steps.version.outputs.version }}'; + + // Update apps/x/package.json + const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); + rootPackage.version = version; + fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); + + // Update apps/x/apps/main/package.json + const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); + mainPackage.version = version; + fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); + + console.log('Updated version to:', version); + " + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: apps/x + + - name: Build electron app + env: + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx electron-forge publish --arch=x64,arm64 --platform=linux + working-directory: apps/x/apps/main + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: distributables-linux + path: apps/x/apps/main/out/make/* + retention-days: 30 + + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: 'apps/x/pnpm-lock.yaml' + + - name: Extract version from tag + id: version + shell: bash + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version: ${VERSION}" + + - name: Update package.json versions + shell: bash + run: | + node -e " + const fs = require('fs'); + const version = '${{ steps.version.outputs.version }}'; + + // Update apps/x/package.json + const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); + rootPackage.version = version; + fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); + + // Update apps/x/apps/main/package.json + const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); + mainPackage.version = version; + fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); + + console.log('Updated version to:', version); + " + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: apps/x + + - name: Build electron app + env: + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx electron-forge publish --arch=x64 --platform=win32 + working-directory: apps/x/apps/main + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: distributables-windows + path: apps/x/apps/main/out/make/* + retention-days: 30 diff --git a/.github/workflows/x-publish.yml b/.github/workflows/x-publish.yml index aac99cb9..4f58b1df 100644 --- a/.github/workflows/x-publish.yml +++ b/.github/workflows/x-publish.yml @@ -1,9 +1,6 @@ name: Publish to npm -on: - push: - branches: - - main +on: workflow_dispatch permissions: id-token: write # Required for OIDC diff --git a/README.md b/README.md index 09fd5f95..a038b06c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Work knowledge graph +rowboat-github-2
@@ -28,35 +28,47 @@
-Rowboat connects your email and meeting notes, builds long-lived knowledge from them, and uses that knowledge to help get work done on your machine. +Rowboat connects to your email and meeting notes, builds a long-lived knowledge graph, and uses that context to help you get work done - privately, on your machine. + +You can do things like: +- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph +- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) +- Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) +- Record voice memos that automatically capture and update key takeaways in the graph + +Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/) ---- ## Demo -[![Demo video](https://github.com/user-attachments/assets/f378285b-4ef3-4a4b-aa20-7dbb664e496c)](https://www.youtube.com/watch?v=T2Bmiy05FrI) + +[![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I) + +[Watch the full video](https://www.youtube.com/watch?v=5AWoGo-L16I) --- -## Quick start +## Installation -**Download for Mac:** +**Download latest for Mac/Windows/Linux:** [Download](https://www.rowboatlabs.com/) -https://github.com/rowboatlabs/rowboat/releases/latest +**All release files:** https://github.com/rowboatlabs/rowboat/releases/latest ## What it does -Rowboat ingests your: -- **Email** (Gmail) -- **Meeting notes** (Granola, Fireflies) +Rowboat is a **local-first AI coworker** that can: +- **Remember** the important context you don’t want to re-explain (people, projects, decisions, commitments) +- **Understand** what’s relevant right now (before a meeting, while replying to an email, when writing a doc) +- **Help you act** by drafting, summarizing, planning, and producing real artifacts (briefs, emails, docs, PDF slides) -and organizes them into a local, Obsidian-compatible vault of plain Markdown files with backlinks. +Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Markdown notes with backlinks — a transparent “working memory” you can inspect and edit. -This vault is not just for browsing or search. It becomes a working memory that Rowboat’s AI uses to take actions on your behalf. +## Integrations -As new emails and meetings come in, the relevant notes update automatically, building persistent context across people, projects, organizations, and topics. - ---- +Rowboat builds memory from the work you already do, including: +- **Gmail** (email) +- **Granola** (meeting notes) +- **Fireflies** (meeting notes) ## How it’s different @@ -70,33 +82,53 @@ Rowboat maintains **long-lived knowledge** instead: The result is memory that compounds, rather than retrieval that starts cold every time. ---- - ## What you can do with it -Rowboat uses this knowledge to help with everyday work, including: +- **Meeting prep** from prior decisions, threads, and open questions +- **Email drafting** grounded in history and commitments +- **Docs & decks** generated from your ongoing context (including PDF slides) +- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped +- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) -- Drafting emails using accumulated context -- Preparing for meetings from prior decisions and discussions -- Organizing files and project artifacts as work evolves -- Running shell commands or scripts as agent actions -- Extending capabilities via external tools and MCP servers +## Background agents -Actions are explicit and grounded in the current state of your knowledge. +Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time. ---- +Examples: +- Draft email replies in the background (grounded in your past context and commitments) +- Generate a daily voice note each morning (agenda, priorities, upcoming meetings) +- Create recurring project updates from the latest emails/notes +- Keep your knowledge graph up to date as new information comes in + +You control what runs, when it runs, and what gets written back into your local Markdown vault. + +## Bring your own model + +Rowboat works with the model setup you prefer: +- **Local models** via Ollama or LM Studio +- **Hosted models** (bring your own API key/provider) +- Swap models anytime — your data stays in your local Markdown vault + +## Extend Rowboat with tools (MCP) + +Rowboat can connect to external tools and services via **Model Context Protocol (MCP)**. +That means you can plug in (for example) search, databases, CRMs, support tools, and automations - or your own internal tools. + +Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, GitHub, and more. ## Local-first by design - All data is stored locally as plain Markdown - No proprietary formats or hosted lock-in -- Works with local models via Ollama or LM Studio, or hosted models if you prefer - You can inspect, edit, back up, or delete everything at any time + +## Looking for Rowboat Web Studio? + +If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/). + ---
- -Made with ❤️ by the Rowboat team [Discord](https://discord.com/invite/htdKpBZF) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
diff --git a/apps/docs/docs/img/google-setup/01-select-project-dropdown.png b/apps/docs/docs/img/google-setup/01-select-project-dropdown.png new file mode 100644 index 00000000..ed73ee5a Binary files /dev/null and b/apps/docs/docs/img/google-setup/01-select-project-dropdown.png differ diff --git a/apps/docs/docs/img/google-setup/02-enable-api.png b/apps/docs/docs/img/google-setup/02-enable-api.png new file mode 100644 index 00000000..fd154f7e Binary files /dev/null and b/apps/docs/docs/img/google-setup/02-enable-api.png differ diff --git a/apps/docs/docs/img/google-setup/03-oauth-consent-screen.png b/apps/docs/docs/img/google-setup/03-oauth-consent-screen.png new file mode 100644 index 00000000..25d6dadc Binary files /dev/null and b/apps/docs/docs/img/google-setup/03-oauth-consent-screen.png differ diff --git a/apps/docs/docs/img/google-setup/04-add-test-users.png b/apps/docs/docs/img/google-setup/04-add-test-users.png new file mode 100644 index 00000000..774ea9e9 Binary files /dev/null and b/apps/docs/docs/img/google-setup/04-add-test-users.png differ diff --git a/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png b/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png new file mode 100644 index 00000000..eaa73755 Binary files /dev/null and b/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png differ diff --git a/apps/docs/docs/img/google-setup/06-copy-client-id.png b/apps/docs/docs/img/google-setup/06-copy-client-id.png new file mode 100644 index 00000000..ea0df758 Binary files /dev/null and b/apps/docs/docs/img/google-setup/06-copy-client-id.png differ diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 3a2b340f..57f733f2 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -3,10 +3,10 @@ // Forge loads configs with require(), which fails on ESM files const path = require('path'); +const pkg = require('./package.json'); module.exports = { packagerConfig: { - name: 'Rowboat', executableName: 'rowboat', icon: './icons/icon', // .icns extension added automatically appBundleId: 'com.rowboat.app', @@ -19,9 +19,6 @@ module.exports = { appleIdPassword: process.env.APPLE_PASSWORD, teamId: process.env.APPLE_TEAM_ID }, - // NOTE: Electron Forge ignores packagerConfig.dir and always packages from the - // config file's directory. We use packageAfterCopy hook instead to customize output. - // dir: path.join(__dirname, '.package'), // Not supported by Forge // Since we bundle everything with esbuild, we don't need node_modules at all. // These settings prevent Forge's dependency walker (flora-colossus) from trying // to analyze/copy node_modules, which fails with pnpm's symlinked workspaces. @@ -39,27 +36,55 @@ module.exports = { name: '@electron-forge/maker-dmg', config: (arch) => ({ format: 'ULFO', - name: `Rowboat-${arch}`, // Architecture-specific name to avoid conflicts + name: `Rowboat-darwin-${arch}-${pkg.version}`, // Architecture-specific name to avoid conflicts }) }, { - name: '@electron-forge/maker-zip', - platforms: ['darwin'], - // ZIP is used by Squirrel.Mac for auto-updates + name: '@electron-forge/maker-squirrel', config: (arch) => ({ - // Path must match S3 publisher's folder structure: releases/darwin/{arch} - macUpdateManifestBaseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/darwin/${arch}` + authors: 'rowboatlabs', + description: 'AI coworker with memory', + name: `Rowboat-win32-${arch}`, + setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, }) + }, + { + name: '@electron-forge/maker-deb', + config: (arch) => ({ + options: { + name: `Rowboat-linux`, + bin: "rowboat", + description: 'AI coworker with memory', + maintainer: 'rowboatlabs', + homepage: 'https://rowboatlabs.com' + } + }) + }, + { + name: '@electron-forge/maker-rpm', + config: { + options: { + name: `Rowboat-linux`, + bin: "rowboat", + description: 'AI coworker with memory', + homepage: 'https://rowboatlabs.com' + } + } + }, + { + name: '@electron-forge/maker-zip', + platform: ["darwin", "win32", "linux"], } ], publishers: [ { - name: '@electron-forge/publisher-s3', + name: '@electron-forge/publisher-github', config: { - bucket: 'rowboat-desktop-app-releases', - region: 'us-east-1', - public: true, - folder: 'releases' // Creates structure: releases/darwin/{arch}/files (separate builds for arm64 and x64) + repository: { + owner: 'rowboatlabs', + name: 'rowboat' + }, + prerelease: true } } ], diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 676f7269..c777a237 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -1,31 +1,41 @@ { - "name": "Rowboat", + "name": "rowboat", + "productName": "Rowboat", + "description": "AI coworker with memory", "type": "module", "version": "0.1.0", "main": ".package/dist/main.cjs", + "license": "Apache-2.0", "scripts": { "start": "electron .", "build": "rm -rf dist && tsc && node bundle.mjs", - "package": "electron-forge package --arch=arm64,x64 --platform=darwin", - "make": "electron-forge make --arch=arm64,x64 --platform=darwin", - "publish": "electron-forge publish --arch=arm64,x64 --platform=darwin" + "package": "electron-forge package", + "make": "electron-forge make" }, "dependencies": { "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", + "electron-squirrel-startup": "^1.0.1", + "mammoth": "^1.11.0", + "papaparse": "^5.5.3", + "pdf-parse": "^2.4.5", "update-electron-app": "^3.1.2", + "xlsx": "^0.18.5", "zod": "^4.2.1" }, "devDependencies": { - "@types/node": "^25.0.3", - "electron": "^39.2.7", - "esbuild": "^0.24.2", "@electron-forge/cli": "^7.10.2", - "@electron-forge/maker-deb": "^7.10.2", + "@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-dmg": "^7.10.2", + "@electron-forge/maker-rpm": "^7.11.1", "@electron-forge/maker-squirrel": "^7.10.2", "@electron-forge/maker-zip": "^7.10.2", - "@electron-forge/publisher-s3": "^7.10.2" + "@electron-forge/publisher-github": "^7.11.1", + "@electron-forge/publisher-s3": "^7.10.2", + "@types/electron-squirrel-startup": "^1.0.2", + "@types/node": "^25.0.3", + "electron": "^39.2.7", + "esbuild": "^0.24.2" } } \ No newline at end of file diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5a7a7bd9..2896ee7a 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,5 +1,7 @@ -import { ipcMain, BrowserWindow } from 'electron'; +import { ipcMain, BrowserWindow, shell } from 'electron'; import { ipc } from '@x/shared'; +import path from 'node:path'; +import os from 'node:os'; import { connectProvider, disconnectProvider, @@ -12,10 +14,12 @@ import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as runsCore from '@x/core/dist/runs/runs.js'; import { bus } from '@x/core/dist/runs/bus.js'; +import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; import z from 'zod'; import { RunEvent } from '@x/shared/dist/runs.js'; +import { ServiceEvent } from '@x/shared/dist/service-events.js'; 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'; @@ -24,6 +28,9 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; +import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; +import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; +import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -215,6 +222,15 @@ function emitRunEvent(event: z.infer): void { } } +function emitServiceEvent(event: z.infer): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('services:events', event); + } + } +} + export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void { const windows = BrowserWindow.getAllWindows(); for (const win of windows) { @@ -234,6 +250,30 @@ export async function startRunsWatcher(): Promise { }); } +let servicesWatcher: (() => void) | null = null; +export async function startServicesWatcher(): Promise { + if (servicesWatcher) { + return; + } + servicesWatcher = await serviceBus.subscribe(async (event) => { + emitServiceEvent(event); + }); +} + +export function stopRunsWatcher(): void { + if (runsWatcher) { + runsWatcher(); + runsWatcher = null; + } +} + +export function stopServicesWatcher(): void { + if (servicesWatcher) { + servicesWatcher(); + servicesWatcher = null; + } +} + // ============================================================================ // Handler Implementations // ============================================================================ @@ -384,5 +424,76 @@ export function setupIpcHandlers() { 'composio:execute-action': async (_event, args) => { return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); }, + // Agent schedule handlers + 'agent-schedule:getConfig': async () => { + const repo = container.resolve('agentScheduleRepo'); + try { + return await repo.getConfig(); + } catch { + // Return empty config if file doesn't exist + return { agents: {} }; + } + }, + 'agent-schedule:getState': async () => { + const repo = container.resolve('agentScheduleStateRepo'); + try { + return await repo.getState(); + } catch { + // Return empty state if file doesn't exist + return { agents: {} }; + } + }, + 'agent-schedule:updateAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + await repo.upsert(args.agentName, args.entry); + // Trigger the runner to pick up the change immediately + triggerAgentScheduleRun(); + return { success: true }; + }, + 'agent-schedule:deleteAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + const stateRepo = container.resolve('agentScheduleStateRepo'); + await repo.delete(args.agentName); + await stateRepo.deleteAgentState(args.agentName); + return { success: true }; + }, + // Shell integration handlers + 'shell:openPath': async (_event, args) => { + let filePath = args.path; + if (filePath.startsWith('~')) { + filePath = path.join(os.homedir(), filePath.slice(1)); + } else if (!path.isAbsolute(filePath)) { + // Workspace-relative path — resolve against ~/.rowboat/ + filePath = path.join(os.homedir(), '.rowboat', filePath); + } + const error = await shell.openPath(filePath); + return { error: error || undefined }; + }, + 'shell:readFileBase64': async (_event, args) => { + let filePath = args.path; + if (filePath.startsWith('~')) { + filePath = path.join(os.homedir(), filePath.slice(1)); + } else if (!path.isAbsolute(filePath)) { + // Workspace-relative path — resolve against ~/.rowboat/ + filePath = path.join(os.homedir(), '.rowboat', filePath); + } + const stat = await fs.stat(filePath); + if (stat.size > 10 * 1024 * 1024) { + throw new Error('File too large (>10MB)'); + } + const buffer = await fs.readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeMap: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', '.ico': 'image/x-icon', + '.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', + '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.aac': 'audio/aac', + '.pdf': 'application/pdf', '.json': 'application/json', + '.txt': 'text/plain', '.md': 'text/markdown', + }; + const mimeType = mimeMap[ext] || 'application/octet-stream'; + return { data: buffer.toString('base64'), mimeType, size: stat.size }; + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 6ddab7bc..2a5330ab 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,6 +1,14 @@ import { app, BrowserWindow, protocol, net, shell } from "electron"; import path from "node:path"; -import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; +import { + setupIpcHandlers, + startRunsWatcher, + startServicesWatcher, + startWorkspaceWatcher, + stopRunsWatcher, + stopServicesWatcher, + stopWorkspaceWatcher +} from "./ipc.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; @@ -10,11 +18,16 @@ import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies 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 initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; +import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; +import started from "electron-squirrel-startup"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// run this as early in the main process as possible +if (started) app.quit(); + // Path resolution differs between development and production: const preloadPath = app.isPackaged ? path.join(__dirname, "../preload/dist/preload.js") @@ -64,6 +77,10 @@ function createWindow() { const win = new BrowserWindow({ width: 1280, height: 800, + show: false, // Don't show until ready + backgroundColor: "#252525", // Prevent white flash (matches dark mode) + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -73,6 +90,11 @@ function createWindow() { }, }); + // Show window when content is ready to prevent blank screen + win.once("ready-to-show", () => { + win.show(); + }); + // Open external links in system browser (not sandboxed Electron window) // This handles window.open() and target="_blank" links win.webContents.setWindowOpenHandler(({ url }) => { @@ -107,8 +129,8 @@ app.whenReady().then(async () => { if (app.isPackaged) { updateElectronApp({ updateSource: { - type: UpdateSourceType.StaticStorage, - baseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/${process.platform}/${process.arch}`, + type: UpdateSourceType.ElectronPublicUpdateService, + repo: "rowboatlabs/rowboat", }, notifyUser: true, // Shows native dialog when update is available }); @@ -131,6 +153,9 @@ app.whenReady().then(async () => { // start runs watcher startRunsWatcher(); + // start services watcher + startServicesWatcher(); + // start gmail sync initGmailSync(); @@ -149,6 +174,9 @@ app.whenReady().then(async () => { // start pre-built agent runner initPreBuiltRunner(); + // start background agent runner (scheduled agents) + initAgentRunner(); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -165,4 +193,6 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { // Clean up watcher on app quit stopWorkspaceWatcher(); -}); \ No newline at end of file + stopRunsWatcher(); + stopServicesWatcher(); +}); diff --git a/apps/x/apps/renderer/index.html b/apps/x/apps/renderer/index.html index 1803a850..856065c2 100644 --- a/apps/x/apps/renderer/index.html +++ b/apps/x/apps/renderer/index.html @@ -5,6 +5,22 @@ Rowboat + +
diff --git a/apps/x/apps/renderer/public/logo-only.png b/apps/x/apps/renderer/public/logo-only.png new file mode 100644 index 00000000..e2fd6386 Binary files /dev/null and b/apps/x/apps/renderer/public/logo-only.png differ diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 4fd02863..991236ea 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -123,6 +123,9 @@ --sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0)); --sidebar-border: var(--sub-alt-color, oklch(0.922 0 0)); --sidebar-ring: var(--main-color, oklch(0.708 0 0)); + --scrollbar-track: oklch(0.95 0 0); + --scrollbar-thumb: oklch(0.75 0 0); + --scrollbar-thumb-hover: oklch(0.65 0 0); } .dark { @@ -157,15 +160,39 @@ --sidebar-accent-foreground: var(--text-color, oklch(0.985 0 0)); --sidebar-border: var(--sub-alt-color, oklch(1 0 0 / 10%)); --sidebar-ring: var(--main-color, oklch(0.556 0 0)); + --scrollbar-track: oklch(0.2 0 0); + --scrollbar-thumb: oklch(0.4 0 0); + --scrollbar-thumb-hover: oklch(0.5 0 0); } @layer base { * { @apply border-border outline-ring/50; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + scrollbar-width: thin; } + body { @apply bg-background text-foreground; } + + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--scrollbar-track); + } + + ::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + } } /* Markdown content base styles for Streamdown/MessageResponse */ @@ -228,6 +255,15 @@ } } +/* Titlebar drag regions for frameless window */ +.titlebar-drag-region { + -webkit-app-region: drag; +} + +.titlebar-no-drag { + -webkit-app-region: no-drag; +} + .graph-view { background-color: var(--background); user-select: none; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 25ab8031..bc9bb6cc 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,20 +6,20 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen, Square } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; import { ChatSidebar } from './components/chat-sidebar'; import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { useDebounce } from './hooks/use-debounce'; -import { SidebarIcon } from '@/components/sidebar-icon'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarSectionProvider, type ActiveSection } from '@/contexts/sidebar-context'; import { Conversation, ConversationContent, ConversationEmptyState, + ScrollPositionPreserver, } from '@/components/ai-elements/conversation'; import { Message, @@ -43,13 +43,17 @@ import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/ import { SidebarInset, SidebarProvider, - SidebarTrigger, + useSidebar, } from "@/components/ui/sidebar" import { TooltipProvider } from "@/components/ui/tooltip" -import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { FileCardProvider } from '@/contexts/file-card-context' +import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' +import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' type DirEntry = z.infer type RunEventType = z.infer @@ -106,6 +110,8 @@ const toToolState = (status: ToolCall['status']): ToolState => { } } +const streamdownComponents = { pre: MarkdownPreOverride } + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -354,7 +360,7 @@ function ChatInputInner({ }, [controller]) return ( -
+
void + onNavigateForward: () => void + canNavigateBack: boolean + canNavigateForward: boolean +}) { + const { toggleSidebar } = useSidebar() + return ( +
+ {/* Placeholder dots that show through when traffic lights are hidden (window unfocused) */} +
+
+
+
+
+ {/* Sidebar toggle */} + + {/* Back / Forward navigation */} + + +
+ ) +} + +/** Main content header that adjusts padding based on sidebar state */ +function ContentHeader({ children }: { children: React.ReactNode }) { + const { state } = useSidebar() + const isCollapsed = state === "collapsed" + return ( +
+ {children} +
+ ) +} + function App() { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) - const [fileHistoryBack, setFileHistoryBack] = useState([]) - const [fileHistoryForward, setFileHistoryForward] = useState([]) const [fileContent, setFileContent] = useState('') const [editorContent, setEditorContent] = useState('') + const editorContentRef = useRef('') const [tree, setTree] = useState([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) + const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [], @@ -459,6 +551,16 @@ function App() { const [graphError, setGraphError] = useState(null) const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) + // Keep the latest selected path in a ref (avoids stale async updates when switching rapidly) + const selectedPathRef = useRef(null) + const editorPathRef = useRef(null) + const fileLoadRequestIdRef = useRef(0) + const initialContentByPathRef = useRef>(new Map()) + + // Global navigation history (back/forward) across views (chat/file/graph/task) + const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] }) + const [viewHistory, setViewHistory] = useState(historyRef.current) + // Auto-save state const [isSaving, setIsSaving] = useState(false) const [lastSaved, setLastSaved] = useState(null) @@ -474,7 +576,11 @@ function App() { const [, setModelUsage] = useState(null) const [runId, setRunId] = useState(null) const runIdRef = useRef(null) + const loadRunRequestIdRef = useRef(0) const [isProcessing, setIsProcessing] = useState(false) + const [processingRunIds, setProcessingRunIds] = useState>(new Set()) + const processingRunIdsRef = useRef>(new Set()) + const streamingBuffersRef = useRef>(new Map()) const [isStopping, setIsStopping] = useState(false) const [stopClickedAt, setStopClickedAt] = useState(null) const [agentId] = useState('copilot') @@ -498,11 +604,70 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // Background tasks state + type BackgroundTaskItem = { + name: string + description?: string + schedule: z.infer["agents"][string]["schedule"] + enabled: boolean + startingMessage?: string + status?: z.infer["agents"][string]["status"] + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + } + const [backgroundTasks, setBackgroundTasks] = useState([]) + const [selectedBackgroundTask, setSelectedBackgroundTask] = useState(null) + + // Keep selectedPathRef in sync for async guards + useEffect(() => { + selectedPathRef.current = selectedPath + if (!selectedPath) { + editorPathRef.current = null + } + }, [selectedPath]) + // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) useEffect(() => { runIdRef.current = runId }, [runId]) + const handleEditorChange = useCallback((markdown: string) => { + const nextSelectedPath = selectedPathRef.current + // Avoid clobbering editorPath during rapid transitions (e.g. autosave rename) where refs may lag a tick. + if (!editorPathRef.current || (nextSelectedPath && editorPathRef.current === nextSelectedPath)) { + editorPathRef.current = nextSelectedPath + } + editorContentRef.current = markdown + setEditorContent(markdown) + }, []) + // Keep processingRunIdsRef in sync for use in async callbacks + useEffect(() => { + processingRunIdsRef.current = processingRunIds + }, [processingRunIds]) + + // Sync active run streaming UI with background tracking + useEffect(() => { + if (!runId) { + setIsProcessing(false) + setCurrentAssistantMessage('') + setCurrentReasoning('') + return + } + const isRunProcessing = processingRunIdsRef.current.has(runId) + setIsProcessing(isRunProcessing) + if (isRunProcessing) { + const buffer = streamingBuffersRef.current.get(runId) + setCurrentAssistantMessage(buffer?.assistant ?? '') + setCurrentReasoning(buffer?.reasoning ?? '') + } else { + setCurrentAssistantMessage('') + setCurrentReasoning('') + streamingBuffersRef.current.delete(runId) + } + }, [runId]) + // Load directory tree const loadDirectory = useCallback(async () => { try { @@ -527,26 +692,38 @@ function App() { const cleanup = window.ipc.on('workspace:didChange', async (event) => { loadDirectory().then(setTree) - // Reload current file if it was changed externally - if (!selectedPath) return - const changedPath = event.type === 'changed' ? event.path : null const changedPaths = (event.type === 'bulkChanged' ? event.paths : []) ?? [] + // Reload background tasks if agent-schedule.json changed + if (changedPath === 'config/agent-schedule.json' || changedPaths.includes('config/agent-schedule.json')) { + loadBackgroundTasks() + } + + // Reload current file if it was changed externally + if (!selectedPath) return + const pathToReload = selectedPath + const isCurrentFileChanged = - changedPath === selectedPath || changedPaths.includes(selectedPath) + changedPath === pathToReload || changedPaths.includes(pathToReload) if (isCurrentFileChanged) { // Only reload if no unsaved edits - if (editorContent === initialContentRef.current) { - const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath }) + const baseline = initialContentByPathRef.current.get(pathToReload) ?? initialContentRef.current + if (editorContent === baseline) { + const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload }) + if (selectedPathRef.current !== pathToReload) return setFileContent(result.data) setEditorContent(result.data) + editorContentRef.current = result.data + editorPathRef.current = pathToReload + initialContentByPathRef.current.set(pathToReload, result.data) initialContentRef.current = result.data } } }) return cleanup + // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadDirectory, selectedPath, editorContent]) // Load file content when selected @@ -554,31 +731,57 @@ function App() { if (!selectedPath) { setFileContent('') setEditorContent('') + editorContentRef.current = '' initialContentRef.current = '' setLastSaved(null) return } - (async () => { + const requestId = (fileLoadRequestIdRef.current += 1) + const pathToLoad = selectedPath + let cancelled = false + ;(async () => { try { - const stat = await window.ipc.invoke('workspace:stat', { path: selectedPath }) + const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return if (stat.kind === 'file') { - const result = await window.ipc.invoke('workspace:readFile', { path: selectedPath }) + const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return setFileContent(result.data) - setEditorContent(result.data) - initialContentRef.current = result.data - setLastSaved(null) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + const isSameEditorFile = editorPathRef.current === pathToLoad + const wouldClobberActiveEdits = + isSameEditorFile + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(result.data) + if (!wouldClobberActiveEdits) { + setEditorContent(result.data) + editorContentRef.current = result.data + editorPathRef.current = pathToLoad + initialContentByPathRef.current.set(pathToLoad, result.data) + initialContentRef.current = result.data + setLastSaved(null) + } else { + // Still update the editor's path so subsequent autosaves write to the correct file. + editorPathRef.current = pathToLoad + } } else { setFileContent('') setEditorContent('') + editorContentRef.current = '' initialContentRef.current = '' } } catch (err) { console.error('Failed to load file:', err) - setFileContent('') - setEditorContent('') - initialContentRef.current = '' + if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) { + setFileContent('') + setEditorContent('') + editorContentRef.current = '' + initialContentRef.current = '' + } } })() + return () => { + cancelled = true + } }, [selectedPath]) // Track recently opened markdown files for wiki links @@ -593,48 +796,87 @@ function App() { // Auto-save when content changes useEffect(() => { - if (!selectedPath || !selectedPath.endsWith('.md')) return - if (debouncedContent === initialContentRef.current) return + const pathAtStart = editorPathRef.current + if (!pathAtStart || !pathAtStart.endsWith('.md')) return + + const baseline = initialContentByPathRef.current.get(pathAtStart) ?? initialContentRef.current + if (debouncedContent === baseline) return if (!debouncedContent) return - const saveFile = async () => { - setIsSaving(true) - let pathToSave = selectedPath - try { - if (!renameInProgressRef.current && selectedPath.startsWith('knowledge/')) { + const saveFile = async () => { + const wasActiveAtStart = selectedPathRef.current === pathAtStart + if (wasActiveAtStart) setIsSaving(true) + let pathToSave = pathAtStart + let renamedFrom: string | null = null + let renamedTo: string | null = null + try { + // Only rename the currently active file (avoids renaming/jumping while user switches rapidly) + if ( + wasActiveAtStart && + selectedPathRef.current === pathAtStart && + !renameInProgressRef.current && + pathAtStart.startsWith('knowledge/') + ) { const headingTitle = getHeadingTitle(debouncedContent) const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null - const currentBase = getBaseName(selectedPath) + const currentBase = getBaseName(pathAtStart) if (desiredName && desiredName !== currentBase) { - const parentDir = selectedPath.split('/').slice(0, -1).join('/') + const parentDir = pathAtStart.split('/').slice(0, -1).join('/') const targetPath = `${parentDir}/${desiredName}.md` - if (targetPath !== selectedPath) { + if (targetPath !== pathAtStart) { const exists = await window.ipc.invoke('workspace:exists', { path: targetPath }) - if (!exists.exists) { - renameInProgressRef.current = true - await window.ipc.invoke('workspace:rename', { from: selectedPath, to: targetPath }) - pathToSave = targetPath - setSelectedPath(targetPath) - } - } - } + if (!exists.exists) { + renameInProgressRef.current = true + await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath }) + pathToSave = targetPath + renamedFrom = pathAtStart + renamedTo = targetPath + editorPathRef.current = targetPath + initialContentByPathRef.current.delete(pathAtStart) + } + } + } + } + await window.ipc.invoke('workspace:writeFile', { + path: pathToSave, + data: debouncedContent, + opts: { encoding: 'utf8' } + }) + initialContentByPathRef.current.set(pathToSave, debouncedContent) + + // If we renamed the active file, update state/history AFTER the write completes so the editor + // doesn't reload stale on-disk content mid-typing (which can drop the latest character). + if (renamedFrom && renamedTo) { + const fromPath = renamedFrom + const toPath = renamedTo + const replaceRenamedPath = (stack: ViewState[]) => + stack.map((v) => (v.type === 'file' && v.path === fromPath ? ({ type: 'file', path: toPath } satisfies ViewState) : v)) + setHistory({ + back: replaceRenamedPath(historyRef.current.back), + forward: replaceRenamedPath(historyRef.current.forward), + }) + + if (selectedPathRef.current === fromPath) { + setSelectedPath(toPath) + } + } + + // Only update "current file" UI state if we're still on this file + if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) { + initialContentRef.current = debouncedContent + setLastSaved(new Date()) } - await window.ipc.invoke('workspace:writeFile', { - path: pathToSave, - data: debouncedContent, - opts: { encoding: 'utf8' } - }) - initialContentRef.current = debouncedContent - setLastSaved(new Date()) } catch (err) { console.error('Failed to save file:', err) } finally { renameInProgressRef.current = false - setIsSaving(false) + if (wasActiveAtStart && (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave)) { + setIsSaving(false) + } } } saveFile() - }, [debouncedContent, selectedPath]) + }, [debouncedContent]) // Load runs list (all pages) const loadRuns = useCallback(async () => { @@ -662,10 +904,69 @@ function App() { loadRuns() }, [loadRuns]) + // Load background tasks + const loadBackgroundTasks = useCallback(async () => { + try { + const [configResult, stateResult] = await Promise.all([ + window.ipc.invoke('agent-schedule:getConfig', null), + window.ipc.invoke('agent-schedule:getState', null), + ]) + + const tasks: BackgroundTaskItem[] = Object.entries(configResult.agents).map(([name, entry]) => { + const state = stateResult.agents[name] + return { + name, + description: entry.description, + schedule: entry.schedule, + enabled: entry.enabled ?? true, + startingMessage: entry.startingMessage, + status: state?.status, + nextRunAt: state?.nextRunAt, + lastRunAt: state?.lastRunAt, + lastError: state?.lastError, + runCount: state?.runCount ?? 0, + } + }) + + setBackgroundTasks(tasks) + } catch (err) { + console.error('Failed to load background tasks:', err) + } + }, []) + + // Load background tasks on mount + useEffect(() => { + loadBackgroundTasks() + }, [loadBackgroundTasks]) + + // Handle toggling background task enabled state + const handleToggleBackgroundTask = useCallback(async (taskName: string, enabled: boolean) => { + const task = backgroundTasks.find(t => t.name === taskName) + if (!task) return + + try { + await window.ipc.invoke('agent-schedule:updateAgent', { + agentName: taskName, + entry: { + schedule: task.schedule, + enabled, + startingMessage: task.startingMessage, + description: task.description, + }, + }) + // Reload to get updated state + await loadBackgroundTasks() + } catch (err) { + console.error('Failed to update background task:', err) + } + }, [backgroundTasks, loadBackgroundTasks]) + // Load a specific run and populate conversation const loadRun = useCallback(async (id: string) => { + const requestId = (loadRunRequestIdRef.current += 1) try { const run = await window.ipc.invoke('runs:fetch', { runId: id }) + if (loadRunRequestIdRef.current !== requestId) return // Parse the log events into conversation items const items: ConversationItem[] = [] @@ -749,6 +1050,7 @@ function App() { } } } + if (loadRunRequestIdRef.current !== requestId) return // Track permission requests and responses from history const allPermissionRequests = new Map>() @@ -767,6 +1069,7 @@ function App() { respondedAskHumanIds.add(event.toolCallId) } } + if (loadRunRequestIdRef.current !== requestId) return // Separate pending vs responded permission requests const pendingPerms = new Map>() @@ -782,12 +1085,11 @@ function App() { pendingAsks.set(id, req) } } + if (loadRunRequestIdRef.current !== requestId) return // Set the conversation and runId setConversation(items) setRunId(id) - setCurrentAssistantMessage('') - setCurrentReasoning('') setMessage('') setPendingPermissionRequests(pendingPerms) setPendingAskHumanRequests(pendingAsks) @@ -807,25 +1109,57 @@ function App() { return cleanup }, []) + const getStreamingBuffer = (id: string) => { + const existing = streamingBuffersRef.current.get(id) + if (existing) return existing + const next = { assistant: '', reasoning: '' } + streamingBuffersRef.current.set(id, next) + return next + } + + const appendStreamingBuffer = (id: string, field: 'assistant' | 'reasoning', delta: string) => { + if (!delta) return + const buffer = getStreamingBuffer(id) + buffer[field] += delta + } + + const clearStreamingBuffer = (id: string) => { + streamingBuffersRef.current.delete(id) + } + const handleRunEvent = (event: RunEventType) => { - // Use ref to get current runId to avoid stale closure issues - if (event.runId !== runIdRef.current) return + const activeRunId = runIdRef.current + const isActiveRun = event.runId === activeRunId console.log('Run event:', event.type, event) switch (event.type) { case 'run-processing-start': + setProcessingRunIds(prev => { + const next = new Set(prev) + next.add(event.runId) + return next + }) + if (!isActiveRun) return setIsProcessing(true) setModelUsage(null) break case 'run-processing-end': + setProcessingRunIds(prev => { + const next = new Set(prev) + next.delete(event.runId) + return next + }) + clearStreamingBuffer(event.runId) + if (!isActiveRun) return setIsProcessing(false) setIsStopping(false) setStopClickedAt(null) break case 'start': + if (!isActiveRun) return setCurrentAssistantMessage('') setCurrentReasoning('') setModelUsage(null) @@ -834,7 +1168,16 @@ function App() { case 'llm-stream-event': { const llmEvent = event.event + if (!isActiveRun) { + if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) { + appendStreamingBuffer(event.runId, 'reasoning', llmEvent.delta) + } else if (llmEvent.type === 'text-delta' && llmEvent.delta) { + appendStreamingBuffer(event.runId, 'assistant', llmEvent.delta) + } + return + } if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) { + appendStreamingBuffer(event.runId, 'reasoning', llmEvent.delta) setCurrentReasoning(prev => prev + llmEvent.delta) } else if (llmEvent.type === 'reasoning-end') { setCurrentReasoning(reasoning => { @@ -848,6 +1191,7 @@ function App() { return '' }) } else if (llmEvent.type === 'text-delta' && llmEvent.delta) { + appendStreamingBuffer(event.runId, 'assistant', llmEvent.delta) setCurrentAssistantMessage(prev => prev + llmEvent.delta) } else if (llmEvent.type === 'tool-call') { setConversation(prev => [...prev, { @@ -869,6 +1213,12 @@ function App() { case 'message': { const msg = event.message + if (!isActiveRun) { + if (msg.role === 'assistant') { + clearStreamingBuffer(event.runId) + } + return + } if (msg.role === 'assistant') { setCurrentAssistantMessage(currentMsg => { if (currentMsg) { @@ -887,12 +1237,14 @@ function App() { } return '' }) + clearStreamingBuffer(event.runId) } } break case 'tool-invocation': { + if (!isActiveRun) return const parsedInput = normalizeToolInput(event.input) setConversation(prev => { let matched = false @@ -922,6 +1274,7 @@ function App() { case 'tool-result': { + if (!isActiveRun) return setConversation(prev => { let matched = false const next = prev.map(item => { @@ -954,6 +1307,7 @@ function App() { } case 'tool-permission-request': { + if (!isActiveRun) return const key = event.toolCall.toolCallId setPendingPermissionRequests(prev => { const next = new Map(prev) @@ -969,6 +1323,7 @@ function App() { } case 'tool-permission-response': { + if (!isActiveRun) return setPendingPermissionRequests(prev => { const next = new Map(prev) next.delete(event.toolCallId) @@ -983,6 +1338,7 @@ function App() { } case 'ask-human-request': { + if (!isActiveRun) return const key = event.toolCallId setPendingAskHumanRequests(prev => { const next = new Map(prev) @@ -993,6 +1349,7 @@ function App() { } case 'ask-human-response': { + if (!isActiveRun) return setPendingAskHumanRequests(prev => { const next = new Map(prev) next.delete(event.toolCallId) @@ -1002,6 +1359,13 @@ function App() { } case 'run-stopped': + setProcessingRunIds(prev => { + const next = new Set(prev) + next.delete(event.runId) + return next + }) + clearStreamingBuffer(event.runId) + if (!isActiveRun) return setIsProcessing(false) setIsStopping(false) setStopClickedAt(null) @@ -1024,6 +1388,13 @@ function App() { break case 'error': + setProcessingRunIds(prev => { + const next = new Set(prev) + next.delete(event.runId) + return next + }) + clearStreamingBuffer(event.runId) + if (!isActiveRun) return setIsProcessing(false) setIsStopping(false) setStopClickedAt(null) @@ -1157,6 +1528,8 @@ function App() { }, [runId]) const handleNewChat = useCallback(() => { + // Invalidate any in-flight run loads (rapid switching can otherwise "pop" old conversations back in) + loadRunRequestIdRef.current += 1 setConversation([]) setCurrentAssistantMessage('') setCurrentReasoning('') @@ -1168,6 +1541,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setSelectedBackgroundTask(null) }, []) const handleChatInputSubmit = (text: string) => { @@ -1177,55 +1551,162 @@ function App() { } const handleOpenFullScreenChat = useCallback(() => { + // Remember where we came from so the close button can return + if (selectedPath || isGraphOpen) { + setExpandedFrom({ path: selectedPath, graph: isGraphOpen }) + } + // Copy sidebar input text to full-screen input (keep sidebar message intact for return) + if (message.trim()) { + setPresetMessage(message) + } setSelectedPath(null) setIsGraphOpen(false) + }, [selectedPath, isGraphOpen, message]) + + const handleCloseFullScreenChat = useCallback(() => { + if (expandedFrom) { + if (expandedFrom.graph) { + setIsGraphOpen(true) + } else if (expandedFrom.path) { + setSelectedPath(expandedFrom.path) + } + setExpandedFrom(null) + } + }, [expandedFrom]) + + const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => { + historyRef.current = next + setViewHistory(next) }, []) - // File navigation with history tracking - const navigateToFile = useCallback((path: string | null) => { - if (path === selectedPath) return + const currentViewState = React.useMemo(() => { + if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } + if (selectedPath) return { type: 'file', path: selectedPath } + if (isGraphOpen) return { type: 'graph' } + return { type: 'chat', runId } + }, [selectedBackgroundTask, selectedPath, isGraphOpen, runId]) - // Push current path to back history (if we have one) - if (selectedPath) { - setFileHistoryBack(prev => [...prev, selectedPath]) + const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { + const last = stack[stack.length - 1] + if (last && viewStatesEqual(last, entry)) return stack + return [...stack, entry] + }, []) + + const applyViewState = useCallback(async (view: ViewState) => { + switch (view.type) { + case 'file': + setSelectedBackgroundTask(null) + setIsGraphOpen(false) + setExpandedFrom(null) + setSelectedPath(view.path) + return + case 'graph': + setSelectedBackgroundTask(null) + setSelectedPath(null) + setExpandedFrom(null) + setIsGraphOpen(true) + return + case 'task': + setSelectedPath(null) + setIsGraphOpen(false) + setExpandedFrom(null) + setSelectedBackgroundTask(view.name) + return + case 'chat': + setSelectedPath(null) + setIsGraphOpen(false) + setExpandedFrom(null) + setSelectedBackgroundTask(null) + if (view.runId) { + await loadRun(view.runId) + } else { + handleNewChat() + } + return } - // Clear forward history when navigating to a new file - setFileHistoryForward([]) - setSelectedPath(path) - }, [selectedPath]) + }, [handleNewChat, loadRun]) - const navigateBack = useCallback(() => { - if (fileHistoryBack.length === 0) return + const navigateToView = useCallback(async (nextView: ViewState) => { + const current = currentViewState + if (viewStatesEqual(current, nextView)) return - const newBack = [...fileHistoryBack] - const previousPath = newBack.pop()! + const nextHistory = { + back: appendUnique(historyRef.current.back, current), + forward: [] as ViewState[], + } + setHistory(nextHistory) + await applyViewState(nextView) + }, [appendUnique, applyViewState, currentViewState, setHistory]) - // Push current path to forward history - if (selectedPath) { - setFileHistoryForward(prev => [...prev, selectedPath]) + const navigateBack = useCallback(async () => { + const { back, forward } = historyRef.current + if (back.length === 0) return + + let i = back.length - 1 + while (i >= 0 && viewStatesEqual(back[i], currentViewState)) i -= 1 + if (i < 0) { + setHistory({ back: [], forward }) + return } - setFileHistoryBack(newBack) - setSelectedPath(previousPath) - }, [fileHistoryBack, selectedPath]) + const target = back[i] + const nextHistory = { + back: back.slice(0, i), + forward: appendUnique(forward, currentViewState), + } + setHistory(nextHistory) + await applyViewState(target) + }, [appendUnique, applyViewState, currentViewState, setHistory]) - const navigateForward = useCallback(() => { - if (fileHistoryForward.length === 0) return + const navigateForward = useCallback(async () => { + const { back, forward } = historyRef.current + if (forward.length === 0) return - const newForward = [...fileHistoryForward] - const nextPath = newForward.pop()! - - // Push current path to back history - if (selectedPath) { - setFileHistoryBack(prev => [...prev, selectedPath]) + let i = forward.length - 1 + while (i >= 0 && viewStatesEqual(forward[i], currentViewState)) i -= 1 + if (i < 0) { + setHistory({ back, forward: [] }) + return } - setFileHistoryForward(newForward) - setSelectedPath(nextPath) - }, [fileHistoryForward, selectedPath]) + const target = forward[i] + const nextHistory = { + back: appendUnique(back, currentViewState), + forward: forward.slice(0, i), + } + setHistory(nextHistory) + await applyViewState(target) + }, [appendUnique, applyViewState, currentViewState, setHistory]) - const canNavigateBack = fileHistoryBack.length > 0 - const canNavigateForward = fileHistoryForward.length > 0 + const canNavigateBack = React.useMemo(() => { + for (let i = viewHistory.back.length - 1; i >= 0; i--) { + if (!viewStatesEqual(viewHistory.back[i], currentViewState)) return true + } + return false + }, [viewHistory.back, currentViewState]) + + const canNavigateForward = React.useMemo(() => { + for (let i = viewHistory.forward.length - 1; i >= 0; i--) { + if (!viewStatesEqual(viewHistory.forward[i], currentViewState)) return true + } + return false + }, [viewHistory.forward, currentViewState]) + + const navigateToFile = useCallback((path: string) => { + void navigateToView({ type: 'file', path }) + }, [navigateToView]) + + const navigateToFullScreenChat = useCallback(() => { + // Only treat this as navigation when coming from another view + if (currentViewState.type !== 'chat') { + const nextHistory = { + back: appendUnique(historyRef.current.back, currentViewState), + forward: [] as ViewState[], + } + setHistory(nextHistory) + } + handleOpenFullScreenChat() + }, [appendUnique, currentViewState, handleOpenFullScreenChat, setHistory]) // Handle image upload for the markdown editor const handleImageUpload = useCallback(async (file: File): Promise => { @@ -1266,22 +1747,26 @@ function App() { } }, []) - // Keyboard shortcut: Ctrl+L to open main chat view + // Keyboard shortcut: Ctrl+L to toggle main chat view + const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { e.preventDefault() - handleOpenFullScreenChat() + if (isFullScreenChat && expandedFrom) { + handleCloseFullScreenChat() + } else { + navigateToFullScreenChat() + } } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [handleOpenFullScreenChat]) + }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { navigateToFile(path) - setIsGraphOpen(false) return } @@ -1297,10 +1782,12 @@ function App() { // Handle sidebar section changes - switch to chat view for tasks const handleSectionChange = useCallback((section: ActiveSection) => { if (section === 'tasks') { - setSelectedPath(null) - setIsGraphOpen(false) + if (selectedBackgroundTask) return + if (selectedPath || isGraphOpen) { + void navigateToView({ type: 'chat', runId }) + } } - }, []) + }, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath]) // Knowledge quick actions const knowledgeFiles = React.useMemo(() => { @@ -1388,8 +1875,7 @@ function App() { data: `# ${name}\n\n`, opts: { encoding: 'utf8' } }) - setIsGraphOpen(false) - setSelectedPath(fullPath) + navigateToFile(fullPath) } catch (err) { console.error('Failed to create note:', err) throw err @@ -1407,8 +1893,7 @@ function App() { } }, openGraph: () => { - setSelectedPath(null) - setIsGraphOpen(true) + void navigateToView({ type: 'graph' }) }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), @@ -1439,7 +1924,7 @@ function App() { const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path navigator.clipboard.writeText(fullPath) }, - }), [tree, selectedPath, workspaceRoot, collectDirPaths]) + }), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView]) // Handler for when a voice note is created/updated const handleVoiceNoteCreated = useCallback(async (notePath: string) => { @@ -1460,9 +1945,8 @@ function App() { }) // Select the file to show it in the editor - setIsGraphOpen(false) - setSelectedPath(notePath) - }, [loadDirectory]) + navigateToFile(notePath) + }, [loadDirectory, navigateToFile]) const ensureWikiFile = useCallback(async (wikiPath: string) => { const resolvedPath = toKnowledgePath(wikiPath) @@ -1642,7 +2126,7 @@ function App() { return ( - {item.content} + {item.content} ) @@ -1685,22 +2169,24 @@ function App() { const conversationContentClassName = hasConversation ? "mx-auto w-full max-w-4xl pb-28" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" - const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') + const headerTitle = selectedPath + ? selectedPath + : isGraphOpen + ? 'Graph View' + : selectedBackgroundTask + ? `Background Task: ${selectedBackgroundTask}` + : 'Chat' + const selectedTask = selectedBackgroundTask + ? backgroundTasks.find(t => t.name === selectedBackgroundTask) + : null return (
- {/* Icon sidebar - always visible, fixed position */} - - - {/* Spacer for the fixed icon sidebar */} -
- {/* Content sidebar with SidebarProvider for collapse functionality */} @@ -1713,17 +2199,25 @@ function App() { onVoiceNoteCreated={handleVoiceNoteCreated} runs={runs} currentRunId={runId} + processingRunIds={processingRunIds} tasksActions={{ - onNewChat: handleNewChat, - onSelectRun: loadRun, + onNewChat: () => { + void navigateToView({ type: 'chat', runId: null }) + }, + onSelectRun: (runIdToLoad) => { + void navigateToView({ type: 'chat', runId: runIdToLoad }) + }, + onSelectBackgroundTask: (taskName) => { + void navigateToView({ type: 'task', name: taskName }) + }, }} + backgroundTasks={backgroundTasks} + selectedBackgroundTask={selectedBackgroundTask} /> - {/* Header with sidebar triggers */} -
- - - + {/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */} + + {headerTitle} {selectedPath && ( @@ -1741,47 +2235,37 @@ function App() { ) : null}
)} - {!isGraphOpen && ( - - )} {!selectedPath && isGraphOpen && ( )} - {(selectedPath || isGraphOpen) && ( - <> - - - + {!selectedPath && !isGraphOpen && expandedFrom && ( + )} - + {(selectedPath || isGraphOpen) && ( + + )} + {isGraphOpen ? (
@@ -1791,7 +2275,6 @@ function App() { isLoading={graphStatus === 'loading'} error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null} onSelectNode={(path) => { - setIsGraphOpen(false) navigateToFile(path) }} /> @@ -1801,14 +2284,10 @@ function App() {
) : ( @@ -1818,19 +2297,31 @@ function App() {
) + ) : selectedTask ? ( +
+ handleToggleBackgroundTask(selectedTask.name, enabled)} + /> +
) : ( + { navigateToFile(path) }}>
- + + {!hasConversation ? ( -
- Rowboat -
-
- - L - to open chat from anywhere +
+ What are we working on?
) : ( @@ -1879,7 +2370,7 @@ function App() { {currentAssistantMessage && ( - {currentAssistantMessage} + {currentAssistantMessage} )} @@ -1917,6 +2408,7 @@ function App() {
+ )} @@ -1926,7 +2418,7 @@ function App() { defaultWidth={400} isOpen={isChatSidebarOpen} onNewChat={handleNewChat} - onOpenFullScreen={handleOpenFullScreenChat} + onOpenFullScreen={navigateToFullScreenChat} conversation={conversation} currentAssistantMessage={currentAssistantMessage} currentReasoning={currentReasoning} @@ -1946,8 +2438,16 @@ function App() { permissionResponses={permissionResponses} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} + onOpenKnowledgeFile={(path) => { navigateToFile(path) }} /> )} + {/* Rendered last so its no-drag region paints over the sidebar drag region */} + { void navigateBack() }} + onNavigateForward={() => { void navigateForward() }} + canNavigateBack={canNavigateBack} + canNavigateForward={canNavigateForward} + /> {/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */} diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index 35baf6c4..d9f36353 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -3,20 +3,152 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowDownIcon } from "lucide-react"; -import type { ComponentProps } from "react"; -import { useCallback } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; -export type ConversationProps = ComponentProps; +// Context to share scroll preservation state +interface ScrollPreservationContextValue { + registerScrollContainer: (container: HTMLElement | null) => void; + markUserEngaged: () => void; + resetEngagement: () => void; +} -export const Conversation = ({ className, ...props }: ConversationProps) => ( - -); +const ScrollPreservationContext = createContext(null); + +export type ConversationProps = ComponentProps & { + children?: ReactNode; +}; + +export const Conversation = ({ className, children, ...props }: ConversationProps) => { + const [scrollContainer, setScrollContainer] = useState(null); + const isUserEngagedRef = useRef(false); + const savedScrollTopRef = useRef(0); + const lastScrollHeightRef = useRef(0); + + const contextValue: ScrollPreservationContextValue = { + registerScrollContainer: (container) => { + setScrollContainer(container); + }, + markUserEngaged: () => { + // Only save position on first engagement, not on repeated calls + if (!isUserEngagedRef.current && scrollContainer) { + savedScrollTopRef.current = scrollContainer.scrollTop; + lastScrollHeightRef.current = scrollContainer.scrollHeight; + } + isUserEngagedRef.current = true; + }, + resetEngagement: () => { + isUserEngagedRef.current = false; + }, + }; + + // Watch for content changes and restore scroll position if user was engaged + useEffect(() => { + if (!scrollContainer) return; + + let rafId: number | null = null; + + const checkAndRestoreScroll = () => { + if (!isUserEngagedRef.current) return; + + const currentScrollTop = scrollContainer.scrollTop; + const currentScrollHeight = scrollContainer.scrollHeight; + const savedScrollTop = savedScrollTopRef.current; + + // If scroll position jumped significantly (auto-scroll happened) + // and scroll height also changed (content changed), restore position + if ( + Math.abs(currentScrollTop - savedScrollTop) > 50 && + currentScrollHeight !== lastScrollHeightRef.current + ) { + scrollContainer.scrollTop = savedScrollTop; + } + + lastScrollHeightRef.current = currentScrollHeight; + }; + + // Use ResizeObserver to detect content changes + const resizeObserver = new ResizeObserver(() => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(checkAndRestoreScroll); + }); + + resizeObserver.observe(scrollContainer); + + return () => { + resizeObserver.disconnect(); + if (rafId) cancelAnimationFrame(rafId); + }; + }, [scrollContainer]); + + return ( + + + {children} + + + ); +}; + +/** + * Component that tracks scroll engagement and preserves position. + * Must be used inside Conversation component. + */ +export const ScrollPositionPreserver = () => { + const { isAtBottom } = useStickToBottomContext(); + const preservationContext = useContext(ScrollPreservationContext); + const containerFoundRef = useRef(false); + + // Find and register scroll container on mount + useLayoutEffect(() => { + if (containerFoundRef.current || !preservationContext) return; + + // Find the scroll container (StickToBottom creates one) + // It's the first parent with overflow-y scroll/auto + const findScrollContainer = (): HTMLElement | null => { + const candidates = document.querySelectorAll('[role="log"]'); + for (const candidate of candidates) { + // The scroll container is a direct child of the role="log" element + const children = candidate.children; + for (const child of children) { + const style = window.getComputedStyle(child); + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + return child as HTMLElement; + } + } + } + return null; + }; + + const container = findScrollContainer(); + if (container) { + preservationContext.registerScrollContainer(container); + containerFoundRef.current = true; + } + }, [preservationContext]); + + // Track engagement based on scroll position + useEffect(() => { + if (!preservationContext) return; + + if (!isAtBottom) { + // User is not at bottom - mark as engaged + preservationContext.markUserEngaged(); + } else { + // User is back at bottom - reset + preservationContext.resetEngagement(); + } + }, [isAtBottom, preservationContext]); + + return null; +}; export type ConversationContentProps = ComponentProps< typeof StickToBottom.Content diff --git a/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx new file mode 100644 index 00000000..c178c4d0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/file-path-card.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { BookOpen, FileIcon, FileText, Image, Music, Pause, Play, Video } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useFileCard } from '@/contexts/file-card-context' +import { useSidebarSection } from '@/contexts/sidebar-context' +import { wikiLabel } from '@/lib/wiki-links' + +const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.m4a', '.ogg', '.flac', '.aac']) +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']) +const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm']) +const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.rtf', '.csv']) + +function getExtension(filePath: string): string { + const dot = filePath.lastIndexOf('.') + return dot >= 0 ? filePath.slice(dot).toLowerCase() : '' +} + +function getFileNameWithoutExt(filePath: string): string { + const name = filePath.split('/').pop() || filePath + const dot = name.lastIndexOf('.') + return dot > 0 ? name.slice(0, dot) : name +} + +function getFileCategory(ext: string): { label: string; icon: typeof FileIcon } { + if (AUDIO_EXTENSIONS.has(ext)) return { label: 'Audio', icon: Music } + if (IMAGE_EXTENSIONS.has(ext)) return { label: 'Image', icon: Image } + if (VIDEO_EXTENSIONS.has(ext)) return { label: 'Video', icon: Video } + if (DOCUMENT_EXTENSIONS.has(ext)) return { label: 'Document', icon: FileText } + if (ext === '.md') return { label: 'Markdown', icon: FileText } + return { label: 'File', icon: FileIcon } +} + +function getExtLabel(ext: string): string { + return ext ? ext.slice(1).toUpperCase() : '' +} + +// Shared card shell used by all variants +function CardShell({ + icon, + title, + subtitle, + onClick, + action, +}: { + icon: React.ReactNode + title: string + subtitle: string + onClick?: () => void + action?: React.ReactNode +}) { + return ( +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined} + className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 pr-4 text-left transition-colors hover:bg-accent/50 cursor-pointer w-full my-2" + > +
+ {icon} +
+
+
{title}
+
{subtitle}
+
+ {action} +
+ ) +} + +// --- Knowledge File Card --- + +function KnowledgeFileCard({ filePath }: { filePath: string }) { + const { onOpenKnowledgeFile } = useFileCard() + const { setActiveSection } = useSidebarSection() + const label = wikiLabel(filePath) + const ext = getExtension(filePath) + const extLabel = getExtLabel(ext) + + return ( + } + title={label} + subtitle={extLabel ? `Knowledge \u00b7 ${extLabel}` : 'Knowledge'} + onClick={() => { setActiveSection('knowledge'); onOpenKnowledgeFile(filePath) }} + action={ + + } + /> + ) +} + +// --- Audio File Card --- + +function AudioFileCard({ filePath }: { filePath: string }) { + const [isPlaying, setIsPlaying] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const audioRef = useRef(null) + const ext = getExtension(filePath) + const extLabel = getExtLabel(ext) + + const handlePlayPause = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation() + if (isPlaying && audioRef.current) { + audioRef.current.pause() + setIsPlaying(false) + return + } + + if (!audioRef.current) { + setIsLoading(true) + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath }) + const dataUrl = `data:${result.mimeType};base64,${result.data}` + const audio = new Audio(dataUrl) + audio.addEventListener('ended', () => setIsPlaying(false)) + audioRef.current = audio + } catch (err) { + console.error('Failed to load audio:', err) + setIsLoading(false) + return + } + setIsLoading(false) + } + + audioRef.current.play() + setIsPlaying(true) + }, [filePath, isPlaying]) + + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + } + }, []) + + const handleOpen = async () => { + await window.ipc.invoke('shell:openPath', { path: filePath }) + } + + return ( + + {isPlaying + ? + : + } + + } + title={getFileNameWithoutExt(filePath)} + subtitle={`Audio \u00b7 ${extLabel}`} + onClick={handleOpen} + action={ + + } + /> + ) +} + +// --- System File Card --- + +function SystemFileCard({ filePath }: { filePath: string }) { + const ext = getExtension(filePath) + const isImage = IMAGE_EXTENSIONS.has(ext) + const [thumbnail, setThumbnail] = useState(null) + const { label: categoryLabel, icon: CategoryIcon } = getFileCategory(ext) + const extLabel = getExtLabel(ext) + + useEffect(() => { + if (!isImage) return + let cancelled = false + window.ipc.invoke('shell:readFileBase64', { path: filePath }) + .then((result) => { + if (!cancelled) { + setThumbnail(`data:${result.mimeType};base64,${result.data}`) + } + }) + .catch(() => {/* ignore thumbnail failures */}) + return () => { cancelled = true } + }, [filePath, isImage]) + + const handleOpen = async () => { + await window.ipc.invoke('shell:openPath', { path: filePath }) + } + + return ( + + : + } + title={getFileNameWithoutExt(filePath)} + subtitle={extLabel ? `${categoryLabel} \u00b7 ${extLabel}` : categoryLabel} + onClick={handleOpen} + action={ + + } + /> + ) +} + +// --- Main FilePathCard --- + +export function FilePathCard({ filePath }: { filePath: string }) { + const trimmed = filePath.trim() + + if (trimmed.startsWith('knowledge/')) { + return + } + + const ext = getExtension(trimmed) + if (AUDIO_EXTENSIONS.has(ext)) { + return + } + + return +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx new file mode 100644 index 00000000..c1470326 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx @@ -0,0 +1,27 @@ +import { isValidElement, type JSX } from 'react' +import { FilePathCard } from './file-path-card' + +export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { + const { children, ...rest } = props + + // Check if the child is a with className "language-filepath" + if (isValidElement(children)) { + const childProps = children.props as { className?: string; children?: unknown } + if ( + typeof childProps.className === 'string' && + childProps.className.includes('language-filepath') + ) { + // Extract the text content from the code element + const text = typeof childProps.children === 'string' + ? childProps.children.trim() + : '' + if (text) { + return + } + } + } + + // Passthrough for all other code blocks - return children directly + // so Streamdown's own rendering (syntax highlighting, etc.) is preserved + return
{children}
+} diff --git a/apps/x/apps/renderer/src/components/ai-elements/message.tsx b/apps/x/apps/renderer/src/components/ai-elements/message.tsx index 635d455c..ec3acfc1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/message.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/message.tsx @@ -50,7 +50,7 @@ export const MessageContent = ({ className={cn( "is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm", "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground", - "group-[.is-assistant]:text-foreground", + "group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground", className )} {...props} diff --git a/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx b/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx index 94eed585..7e4d2f57 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/suggestions.tsx @@ -1,4 +1,4 @@ -import { Mail, Calendar, FolderOpen, FileText } from 'lucide-react' +import { Mail, Calendar, FolderOpen, FileText, Presentation } from 'lucide-react' import { cn } from '@/lib/utils' export interface Suggestion { @@ -33,6 +33,12 @@ const defaultSuggestions: Suggestion[] = [ prompt: 'Help me organize [folder or files]', icon: , }, + { + id: 'create-presentation', + label: 'Create a presentation', + prompt: 'Create a pdf presentation on [topic]', + icon: , + }, ] interface SuggestionsProps { diff --git a/apps/x/apps/renderer/src/components/background-task-detail.tsx b/apps/x/apps/renderer/src/components/background-task-detail.tsx new file mode 100644 index 00000000..78d69f2e --- /dev/null +++ b/apps/x/apps/renderer/src/components/background-task-detail.tsx @@ -0,0 +1,175 @@ +import { Bot, Calendar, Clock, AlertCircle, CheckCircle } from "lucide-react" +import { Switch } from "@/components/ui/switch" + +interface BackgroundTaskSchedule { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string +} + +interface BackgroundTaskDetailProps { + name: string + description?: string + schedule: BackgroundTaskSchedule + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + onToggleEnabled: (enabled: boolean) => void +} + +function formatScheduleDescription(schedule: BackgroundTaskSchedule): string { + switch (schedule.type) { + case "cron": + return `Runs on cron schedule: ${schedule.expression}` + case "window": + return `Runs once between ${schedule.startTime} and ${schedule.endTime} based on: ${schedule.cron}` + case "once": + return `Runs once at ${schedule.runAt}` + default: + return "Unknown schedule type" + } +} + +function formatDateTime(isoString: string | null | undefined): string { + if (!isoString) return "Never" + try { + const date = new Date(isoString) + return date.toLocaleString() + } catch { + return isoString + } +} + +export function BackgroundTaskDetail({ + name, + description, + schedule, + enabled, + status, + nextRunAt, + lastRunAt, + lastError, + runCount = 0, + onToggleEnabled, +}: BackgroundTaskDetailProps) { + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{name}

+

Background Agent

+
+
+
+ + {/* Content */} +
+ {/* Description */} + {description && ( +
+

Description

+

{description}

+
+ )} + + {/* Schedule */} +
+

Schedule

+
+
+ + {schedule.type} Schedule +
+

+ {formatScheduleDescription(schedule)} +

+
+
+ + {/* Enabled Toggle - hide for completed one-time schedules */} + {status === "triggered" ? ( +
+

Status

+
+
+ +

Completed

+
+

+ This one-time agent has finished running and will not run again. +

+
+
+ ) : ( +
+

Status

+
+
+

{enabled ? "Enabled" : "Disabled"}

+

+ {enabled ? "This agent will run according to its schedule" : "This agent is paused and will not run"} +

+
+ +
+
+ )} + + {/* Run Statistics */} +
+

Run History

+
+
+

{runCount}

+

Total Runs

+
+
+

{formatDateTime(lastRunAt)}

+

Last Run

+
+
+
+ + {/* Next Run */} + {nextRunAt && schedule.type !== "once" && ( +
+

Next Scheduled Run

+
+
+ + {formatDateTime(nextRunAt)} +
+
+
+ )} + + {/* Last Error */} + {lastError && ( +
+

Last Error

+
+
+ +

{lastError}

+
+
+
+ )} +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-button.tsx b/apps/x/apps/renderer/src/components/chat-button.tsx index a69cf3cc..0e18a8f2 100644 --- a/apps/x/apps/renderer/src/components/chat-button.tsx +++ b/apps/x/apps/renderer/src/components/chat-button.tsx @@ -32,7 +32,7 @@ export function ChatInputBar({ onSubmit, onOpen }: ChatInputBarProps) { return (
-
+
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void + onOpenKnowledgeFile?: (path: string) => void } export function ChatSidebar({ @@ -155,6 +161,7 @@ export function ChatSidebar({ permissionResponses = new Map(), onPermissionResponse, onAskHumanResponse, + onOpenKnowledgeFile, }: ChatSidebarProps) { const [width, setWidth] = useState(defaultWidth) const [isResizing, setIsResizing] = useState(false) @@ -390,7 +397,7 @@ export function ChatSidebar({ {item.role === 'assistant' ? ( - {item.content} + {item.content} ) : ( item.content )} @@ -457,30 +464,32 @@ export function ChatSidebar({ {showContent && ( <> {/* Header - minimal, expand and new chat buttons */} -
+
+ + + + + New chat + {onOpenFullScreen && ( - Full screen chat )} - - - - - New chat -
{/* Conversation area */} + {})}>
- + + {!hasConversation ? ( @@ -536,7 +545,7 @@ export function ChatSidebar({ {currentAssistantMessage && ( - {currentAssistantMessage} + {currentAssistantMessage} )} @@ -565,7 +574,7 @@ export function ChatSidebar({ className="mb-3" /> )} -
+
{mentionHighlights.hasHighlights && (
+ )}
diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 7033a2ac..607bee4b 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -447,7 +447,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
diff --git a/apps/x/apps/renderer/src/components/editor-toolbar.tsx b/apps/x/apps/renderer/src/components/editor-toolbar.tsx index 95853e5c..bf258633 100644 --- a/apps/x/apps/renderer/src/components/editor-toolbar.tsx +++ b/apps/x/apps/renderer/src/components/editor-toolbar.tsx @@ -22,8 +22,6 @@ import { MinusIcon, LinkIcon, CodeSquareIcon, - ChevronLeftIcon, - ChevronRightIcon, ExternalLinkIcon, Trash2Icon, ImageIcon, @@ -33,20 +31,12 @@ interface EditorToolbarProps { editor: Editor | null onSelectionHighlight?: (range: { from: number; to: number } | null) => void onImageUpload?: (file: File) => Promise | void - onNavigateBack?: () => void - onNavigateForward?: () => void - canNavigateBack?: boolean - canNavigateForward?: boolean } export function EditorToolbar({ editor, onSelectionHighlight, onImageUpload, - onNavigateBack, - onNavigateForward, - canNavigateBack, - canNavigateForward, }: EditorToolbarProps) { const [linkUrl, setLinkUrl] = useState('') const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) @@ -117,35 +107,13 @@ export function EditorToolbar({ return (
- {/* Back / Forward Navigation */} - - - -
- {/* Text formatting */} -
- ) - - // Step 1: LLM Setup + // Step 0: LLM Setup const LlmSetupStep = () => { - const providerOptions: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ + const [showMoreProviders, setShowMoreProviders] = useState(false) + + const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ { id: "openai", name: "OpenAI", description: "Use your OpenAI API key" }, { id: "anthropic", name: "Anthropic", description: "Use your Anthropic API key" }, - { id: "google", name: "Google", description: "Use your Google AI Studio key" }, + { id: "google", name: "Gemini", description: "Use your Google AI Studio key" }, + { id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" }, + ] + + const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ { id: "openrouter", name: "OpenRouter", description: "Access multiple models with one key" }, { id: "aigateway", name: "AI Gateway (Vercel)", description: "Use Vercel's AI Gateway" }, - { id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" }, { id: "openai-compatible", name: "OpenAI-Compatible", description: "Local or hosted OpenAI-compatible API" }, ] + const isMoreProvider = moreProviders.some(p => p.id === llmProvider) + const modelsForProvider = modelsCatalog[llmProvider] || [] const showModelInput = isLocalProvider || modelsForProvider.length === 0 + const renderProviderCard = (provider: { id: LlmProviderFlavor; name: string; description: string }) => ( + + ) + return (
- +
+ Your AI coworker, with memory +
+ Choose your model - - Select your provider and model to power Rowboat’s AI. - -
+
Provider
- {providerOptions.map((provider) => ( - - ))} + {primaryProviders.map(renderProviderCard)}
+ {(showMoreProviders || isMoreProvider) ? ( +
+ {moreProviders.map(renderProviderCard)} +
+ ) : ( + + )}
@@ -741,48 +721,36 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )}
-
+ {testState.status === "error" && ( +
+ {testState.error || "Connection test failed"} +
+ )} + +
- {testState.status === "success" && ( - Connected - )} - {testState.status === "error" && ( - - {testState.error || "Test failed"} - - )} -
- -
-
) } - // Step 2: Connect Accounts + // Step 1: Connect Accounts const AccountConnectionStep = () => (
Connect Your Accounts - Connect your accounts to start syncing your data. You can always add more later. + Connect your accounts to start syncing your data locally. You can always add more later. @@ -812,13 +780,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
- {/* Team Communication Section */} -
-
- Team Communication -
- {renderSlackRow()} -
)}
@@ -834,7 +795,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Step 3: Completion + // Step 2: Completion const CompletionStep = () => { const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected @@ -847,7 +808,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { You're All Set! {hasConnections ? ( - <>Your workspace will populate over the next ~30 minutes as we sync your data. + <>Give me 30 minutes to build your context graph.
I can still help with other things on your computer. ) : ( <>You can connect your accounts anytime from the sidebar to start syncing data. )} @@ -917,10 +878,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onEscapeKeyDown={(e) => e.preventDefault()} > - {currentStep === 0 && } - {currentStep === 1 && } - {currentStep === 2 && } - {currentStep === 3 && } + {currentStep === 0 && } + {currentStep === 1 && } + {currentStep === 2 && } diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index b60ec736..a48c280a 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -1,8 +1,8 @@ "use client" import * as React from "react" -import { useState, useEffect } from "react" -import { Server, Key, Shield } from "lucide-react" +import { useState, useEffect, useCallback } from "react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2 } from "lucide-react" import { Dialog, @@ -10,15 +10,25 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { cn } from "@/lib/utils" +import { useTheme } from "@/contexts/theme-context" +import { toast } from "sonner" -type ConfigTab = "models" | "mcp" | "security" +type ConfigTab = "models" | "mcp" | "security" | "appearance" interface TabConfig { id: ConfigTab label: string icon: React.ElementType - path: string + path?: string description: string } @@ -44,12 +54,411 @@ const tabs: TabConfig[] = [ path: "config/security.json", description: "Configure allowed shell commands", }, + { + id: "appearance", + label: "Appearance", + icon: Palette, + description: "Customize the look and feel", + }, ] interface SettingsDialogProps { children: React.ReactNode } +// --- Theme option for Appearance tab --- + +function ThemeOption({ + label, + icon: Icon, + isSelected, + onClick, +}: { + label: string + icon: React.ElementType + isSelected: boolean + onClick: () => void +}) { + return ( + + ) +} + +function AppearanceSettings() { + const { theme, setTheme } = useTheme() + + return ( +
+
+

Theme

+

+ Select your preferred color scheme +

+
+ setTheme("light")} + /> + setTheme("dark")} + /> + setTheme("system")} + /> +
+
+
+ ) +} + +// --- Model Settings UI --- + +type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" + +interface LlmModelOption { + id: string + name?: string + release_date?: string +} + +const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ + { id: "openai", name: "OpenAI", description: "GPT models" }, + { id: "anthropic", name: "Anthropic", description: "Claude models" }, + { id: "google", name: "Gemini", description: "Google AI Studio" }, + { id: "ollama", name: "Ollama (Local)", description: "Run models locally" }, +] + +const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [ + { id: "openrouter", name: "OpenRouter", description: "Multiple models, one key" }, + { id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway" }, + { id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API" }, +] + +const preferredDefaults: Partial> = { + openai: "gpt-5.2", + anthropic: "claude-opus-4-5-20251101", +} + +const defaultBaseURLs: Partial> = { + ollama: "http://localhost:11434", + "openai-compatible": "http://localhost:1234/v1", +} + +function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { + const [provider, setProvider] = useState("openai") + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "" }, + anthropic: { apiKey: "", baseURL: "", model: "" }, + google: { apiKey: "", baseURL: "", model: "" }, + openrouter: { apiKey: "", baseURL: "", model: "" }, + aigateway: { apiKey: "", baseURL: "", model: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, + }) + const [modelsCatalog, setModelsCatalog] = useState>({}) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" }) + const [configLoading, setConfigLoading] = useState(true) + const [showMoreProviders, setShowMoreProviders] = useState(false) + + const activeConfig = providerConfigs[provider] + const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" + const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway" + const requiresBaseURL = provider === "ollama" || provider === "openai-compatible" + const isLocalProvider = provider === "ollama" || provider === "openai-compatible" + const modelsForProvider = modelsCatalog[provider] || [] + const showModelInput = isLocalProvider || modelsForProvider.length === 0 + const isMoreProvider = moreProviders.some(p => p.id === provider) + + const canTest = + activeConfig.model.trim().length > 0 && + (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && + (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) + + const updateConfig = useCallback( + (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { + setProviderConfigs(prev => ({ + ...prev, + [prov]: { ...prev[prov], ...updates }, + })) + setTestState({ status: "idle" }) + }, + [] + ) + + // Load current config from file + useEffect(() => { + if (!dialogOpen) return + + async function loadCurrentConfig() { + try { + setConfigLoading(true) + const result = await window.ipc.invoke("workspace:readFile", { + path: "config/models.json", + }) + const parsed = JSON.parse(result.data) + if (parsed?.provider?.flavor && parsed?.model) { + const flavor = parsed.provider.flavor as LlmProviderFlavor + setProvider(flavor) + setProviderConfigs(prev => ({ + ...prev, + [flavor]: { + apiKey: parsed.provider.apiKey || "", + baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), + model: parsed.model, + }, + })) + } + } catch { + // No existing config or parse error - use defaults + } finally { + setConfigLoading(false) + } + } + + loadCurrentConfig() + }, [dialogOpen]) + + // Load models catalog + useEffect(() => { + if (!dialogOpen) return + + async function loadModels() { + try { + setModelsLoading(true) + setModelsError(null) + const result = await window.ipc.invoke("models:list", null) + const catalog: Record = {} + for (const p of result.providers || []) { + catalog[p.id] = p.models || [] + } + setModelsCatalog(catalog) + } catch { + setModelsError("Failed to load models list") + setModelsCatalog({}) + } finally { + setModelsLoading(false) + } + } + + loadModels() + }, [dialogOpen]) + + // Set default models from catalog when catalog loads + useEffect(() => { + if (Object.keys(modelsCatalog).length === 0) return + setProviderConfigs(prev => { + const next = { ...prev } + const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] + for (const prov of cloudProviders) { + const models = modelsCatalog[prov] + if (models?.length && !next[prov].model) { + const preferred = preferredDefaults[prov] + const hasPreferred = preferred && models.some(m => m.id === preferred) + next[prov] = { ...next[prov], model: hasPreferred ? preferred : (models[0]?.id || "") } + } + } + return next + }) + }, [modelsCatalog]) + + const handleTestAndSave = useCallback(async () => { + if (!canTest) return + setTestState({ status: "testing" }) + try { + const providerConfig = { + provider: { + flavor: provider, + apiKey: activeConfig.apiKey.trim() || undefined, + baseURL: activeConfig.baseURL.trim() || undefined, + }, + model: activeConfig.model.trim(), + } + const result = await window.ipc.invoke("models:test", providerConfig) + if (result.success) { + await window.ipc.invoke("models:saveConfig", providerConfig) + setTestState({ status: "success" }) + toast.success("Model configuration saved") + } else { + setTestState({ status: "error", error: result.error }) + toast.error(result.error || "Connection test failed") + } + } catch { + setTestState({ status: "error", error: "Connection test failed" }) + toast.error("Connection test failed") + } + }, [canTest, provider, activeConfig]) + + const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => ( + + ) + + if (configLoading) { + return ( +
+ + Loading... +
+ ) + } + + return ( +
+ {/* Provider selection */} +
+ Provider +
+ {primaryProviders.map(renderProviderCard)} +
+ {(showMoreProviders || isMoreProvider) ? ( +
+ {moreProviders.map(renderProviderCard)} +
+ ) : ( + + )} +
+ + {/* Model selection */} +
+ Model + {modelsLoading ? ( +
+ + Loading models... +
+ ) : showModelInput ? ( + updateConfig(provider, { model: e.target.value })} + placeholder="Enter model ID" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ + {/* API Key */} + {requiresApiKey && ( +
+ API Key + updateConfig(provider, { apiKey: e.target.value })} + placeholder="Paste your API key" + /> +
+ )} + + {/* Base URL */} + {showBaseURL && ( +
+ Base URL + updateConfig(provider, { baseURL: e.target.value })} + placeholder={ + provider === "ollama" + ? "http://localhost:11434" + : provider === "openai-compatible" + ? "http://localhost:1234/v1" + : "https://ai-gateway.vercel.sh/v1" + } + /> +
+ )} + + {/* Test status */} + {testState.status === "error" && ( +
+ {testState.error || "Connection test failed"} +
+ )} + {testState.status === "success" && ( +
+ + Connected and saved +
+ )} + + {/* Test & Save button */} + +
+ ) +} + +// --- Main Settings Dialog --- + export function SettingsDialog({ children }: SettingsDialogProps) { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState("models") @@ -60,9 +469,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const [error, setError] = useState(null) const activeTabConfig = tabs.find((t) => t.id === activeTab)! + const isJsonTab = activeTab === "mcp" || activeTab === "security" - const loadConfig = async (tab: ConfigTab) => { + const formatJson = (jsonString: string): string => { + try { + return JSON.stringify(JSON.parse(jsonString), null, 2) + } catch { + return jsonString + } + } + + const loadConfig = useCallback(async (tab: ConfigTab) => { + if (tab === "appearance" || tab === "models") return const tabConfig = tabs.find((t) => t.id === tab)! + if (!tabConfig.path) return setLoading(true) setError(null) try { @@ -72,20 +492,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const formattedContent = formatJson(result.data) setContent(formattedContent) setOriginalContent(formattedContent) - } catch (err) { + } catch { setError(`Failed to load ${tabConfig.label} config`) setContent("") setOriginalContent("") } finally { setLoading(false) } - } + }, []) const saveConfig = async () => { + if (!isJsonTab || !activeTabConfig.path) return setSaving(true) setError(null) try { - // Validate JSON before saving JSON.parse(content) await window.ipc.invoke("workspace:writeFile", { path: activeTabConfig.path, @@ -103,14 +523,6 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } } - const formatJson = (jsonString: string): string => { - try { - return JSON.stringify(JSON.parse(jsonString), null, 2) - } catch { - return jsonString - } - } - const handleFormat = () => { setContent(formatJson(content)) } @@ -118,13 +530,13 @@ export function SettingsDialog({ children }: SettingsDialogProps) { const hasChanges = content !== originalContent useEffect(() => { - if (open) { + if (open && isJsonTab) { loadConfig(activeTab) } - }, [open, activeTab]) + }, [open, activeTab, isJsonTab, loadConfig]) const handleTabChange = (tab: ConfigTab) => { - if (hasChanges) { + if (isJsonTab && hasChanges) { if (!confirm("You have unsaved changes. Discard them?")) { return } @@ -136,9 +548,9 @@ export function SettingsDialog({ children }: SettingsDialogProps) { {children} -
+
{/* Sidebar */}
@@ -164,7 +576,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
{/* Main content */} -
+
{/* Header */}

{activeTabConfig.label}

@@ -173,9 +585,13 @@ export function SettingsDialog({ children }: SettingsDialogProps) {

- {/* Editor */} -
- {loading ? ( + {/* Content */} +
+ {activeTab === "models" ? ( + + ) : activeTab === "appearance" ? ( + + ) : loading ? (
Loading...
@@ -190,36 +606,38 @@ export function SettingsDialog({ children }: SettingsDialogProps) { )}
- {/* Footer */} -
-
- {error && ( - {error} - )} - {hasChanges && !error && ( - - Unsaved changes - - )} + {/* Footer - only show for JSON config tabs */} + {isJsonTab && ( +
+
+ {error && ( + {error} + )} + {hasChanges && !error && ( + + Unsaved changes + + )} +
+
+ + +
-
- - -
-
+ )}
diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index af0d1896..c765e7bb 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -1,8 +1,9 @@ "use client" import * as React from "react" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { + Bot, ChevronRight, ChevronsDownUp, ChevronsUpDown, @@ -11,10 +12,13 @@ import { FilePlus, Folder, FolderPlus, - MessageSquare, + HelpCircle, Mic, Network, Pencil, + Plug, + LoaderIcon, + Settings, Square, SquarePen, Trash2, @@ -28,6 +32,7 @@ import { import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, @@ -36,7 +41,13 @@ import { SidebarMenuItem, SidebarMenuSub, SidebarRail, + useSidebar, } from "@/components/ui/sidebar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" import { Tooltip, TooltipContent, @@ -50,8 +61,14 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu" import { Input } from "@/components/ui/input" -import { useSidebarSection } from "@/contexts/sidebar-context" +import { cn } from "@/lib/utils" +import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context" +import { ConnectorsPopover } from "@/components/connectors-popover" +import { HelpPopover } from "@/components/help-popover" +import { SettingsDialog } from "@/components/settings-dialog" import { toast } from "@/lib/toast" +import { ServiceEvent } from "@x/shared/src/service-events.js" +import z from "zod" interface TreeNode { path: string @@ -79,9 +96,41 @@ type RunListItem = { agentId: string } +type BackgroundTaskItem = { + name: string + description?: string + schedule: { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string + } + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null +} + +type ServiceEventType = z.infer + +const MAX_SYNC_EVENTS = 1000 +const RUN_STALE_MS = 2 * 60 * 60 * 1000 + +const SERVICE_LABELS: Record = { + gmail: "Syncing Gmail", + calendar: "Syncing Calendar", + fireflies: "Syncing Fireflies", + granola: "Syncing Granola", + graph: "Updating knowledge", + voice_memo: "Processing voice memo", +} + type TasksActions = { onNewChat: () => void onSelectRun: (runId: string) => void + onSelectBackgroundTask?: (taskName: string) => void } type SidebarContentPanelProps = { @@ -93,12 +142,199 @@ type SidebarContentPanelProps = { onVoiceNoteCreated?: (path: string) => void runs?: RunListItem[] currentRunId?: string | null + processingRunIds?: Set tasksActions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null } & React.ComponentProps -const sectionTitles = { - knowledge: "Knowledge", - tasks: "Chats", +const sectionTabs: { id: ActiveSection; label: string }[] = [ + { id: "tasks", label: "Chat" }, + { id: "knowledge", label: "Knowledge" }, +] + +function formatEventTime(ts: string): string { + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return "" + return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) +} + +function SyncStatusBar() { + const { state, isMobile } = useSidebar() + const [activeServices, setActiveServices] = useState>(new Map()) + const [popoverOpen, setPopoverOpen] = useState(false) + const [logEvents, setLogEvents] = useState([]) + const [logLoading, setLogLoading] = useState(false) + const runTimeoutsRef = useRef>>(new Map()) + + // Track active runs from real-time events + useEffect(() => { + const cleanup = window.ipc.on('services:events', (event) => { + const nextEvent = event as ServiceEventType + if (nextEvent.type === 'run_start') { + setActiveServices((prev) => { + const next = new Map(prev) + next.set(nextEvent.runId, nextEvent.service) + return next + }) + const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) + if (existingTimeout) clearTimeout(existingTimeout) + const timeout = setTimeout(() => { + setActiveServices((prev) => { + if (!prev.has(nextEvent.runId)) return prev + const next = new Map(prev) + next.delete(nextEvent.runId) + return next + }) + runTimeoutsRef.current.delete(nextEvent.runId) + }, RUN_STALE_MS) + runTimeoutsRef.current.set(nextEvent.runId, timeout) + } else if (nextEvent.type === 'run_complete') { + setActiveServices((prev) => { + const next = new Map(prev) + next.delete(nextEvent.runId) + return next + }) + const existingTimeout = runTimeoutsRef.current.get(nextEvent.runId) + if (existingTimeout) { + clearTimeout(existingTimeout) + runTimeoutsRef.current.delete(nextEvent.runId) + } + } + }) + return cleanup + }, []) + + useEffect(() => { + return () => { + runTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout)) + runTimeoutsRef.current.clear() + } + }, []) + + // Load logs from JSONL file when popover opens + useEffect(() => { + if (!popoverOpen) return + let cancelled = false + async function loadLogs() { + setLogLoading(true) + try { + const result = await window.ipc.invoke('workspace:readFile', { + path: 'logs/services.jsonl', + encoding: 'utf8', + }) + if (cancelled) return + const lines = result.data.trim().split('\n').filter(Boolean) + const parsed: ServiceEventType[] = [] + for (const line of lines) { + try { + parsed.push(JSON.parse(line)) + } catch { + // skip malformed lines + } + } + // Newest first, limit to 1000 + setLogEvents(parsed.reverse().slice(0, MAX_SYNC_EVENTS)) + } catch { + if (!cancelled) setLogEvents([]) + } finally { + if (!cancelled) setLogLoading(false) + } + } + loadLogs() + return () => { cancelled = true } + }, [popoverOpen]) + + const isSyncing = activeServices.size > 0 + const isCollapsed = state === "collapsed" + + // Build status label from active services + const activeServiceNames = [...new Set(activeServices.values())] + const statusLabel = isSyncing + ? activeServiceNames.map((s) => SERVICE_LABELS[s] || s).join(", ") + : "All caught up" + + return ( + <> + {!isMobile && isCollapsed && isSyncing && ( +
+ +
+ )} + + + + + + +
+

Sync Activity

+

+ {isSyncing ? statusLabel : "All services up to date"} +

+
+
+ {logLoading ? ( +
+ +
+ ) : logEvents.length === 0 ? ( +
+ No recent activity. +
+ ) : ( +
+ {logEvents.map((event, idx) => ( +
+ + {formatEventTime(event.ts)} + + + + {SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service} + + + {event.message} +
+ ))} +
+ )} +
+
+
+
+ + ) } export function SidebarContentPanel({ @@ -110,16 +346,37 @@ export function SidebarContentPanel({ onVoiceNoteCreated, runs = [], currentRunId, + processingRunIds, tasksActions, + backgroundTasks = [], + selectedBackgroundTask, ...props }: SidebarContentPanelProps) { - const { activeSection } = useSidebarSection() + const { activeSection, setActiveSection } = useSidebarSection() return ( - -
- {sectionTitles[activeSection]} + + {/* Top spacer to clear the traffic lights + fixed toggle row */} +
+ {/* Tab switcher - centered below the traffic lights row */} +
+
+ {sectionTabs.map((tab) => ( + + ))} +
@@ -137,10 +394,37 @@ export function SidebarContentPanel({ )} + {/* Bottom actions */} +
+
+ + + + + + + + + +
+
+ ) @@ -179,12 +463,25 @@ async function transcribeWithDeepgram(audioBlob: Blob): Promise { // Voice Note Recording Button function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) { const [isRecording, setIsRecording] = React.useState(false) + const [hasDeepgramKey, setHasDeepgramKey] = React.useState(false) const mediaRecorderRef = React.useRef(null) const chunksRef = React.useRef([]) const notePathRef = React.useRef(null) const timestampRef = React.useRef(null) const relativePathRef = React.useRef(null) + React.useEffect(() => { + window.ipc.invoke('workspace:readFile', { + path: 'config/deepgram.json', + encoding: 'utf8', + }).then((result: { data: string }) => { + const { apiKey } = JSON.parse(result.data) as { apiKey: string } + setHasDeepgramKey(!!apiKey) + }).catch(() => { + setHasDeepgramKey(false) + }) + }, []) + const startRecording = async () => { try { // Generate timestamp and paths immediately @@ -346,6 +643,8 @@ ${transcript} setIsRecording(false) } + if (!hasDeepgramKey) return null + return ( @@ -654,15 +953,42 @@ function Tree({ ) } +// Get status indicator color +function getStatusColor(status?: string, enabled?: boolean): string { + // Disabled agents always show gray + if (enabled === false) { + return "bg-gray-400" + } + switch (status) { + case "running": + return "bg-blue-500" + case "finished": + return "bg-green-500" + case "failed": + return "bg-red-500" + case "triggered": + return "bg-gray-400" + case "scheduled": + default: + return "bg-yellow-500" + } +} + // Tasks Section function TasksSection({ runs, currentRunId, + processingRunIds, actions, + backgroundTasks = [], + selectedBackgroundTask, }: { runs: RunListItem[] currentRunId?: string | null + processingRunIds?: Set actions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null }) { return ( @@ -678,9 +1004,38 @@ function TasksSection({
- {runs.length > 0 && ( + {/* Background Tasks Section */} + {backgroundTasks.length > 0 && ( <>
+ Background Tasks +
+ + {backgroundTasks.map((task) => ( + + actions?.onSelectBackgroundTask?.(task.name)} + className="gap-2" + > +
+ + +
+ + {task.name} + +
+
+ ))} +
+ + )} + {runs.length > 0 && ( + <> +
Chat history
@@ -689,10 +1044,13 @@ function TasksSection({ actions?.onSelectRun(run.id)} - className="gap-2" > - - {run.title || '(Untitled chat)'} +
+ {processingRunIds?.has(run.id) ? ( + + ) : null} + {run.title || '(Untitled chat)'} +
))} @@ -703,4 +1061,3 @@ function TasksSection({ ) } - diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx deleted file mode 100644 index 54aa0622..00000000 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client" - -import * as React from "react" -import { - Brain, - HelpCircle, - MessageSquare, - Plug, - Settings, -} from "lucide-react" - -import { cn } from "@/lib/utils" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { type ActiveSection, useSidebarSection } from "@/contexts/sidebar-context" -import { ConnectorsPopover } from "@/components/connectors-popover" -import { HelpPopover } from "@/components/help-popover" -import { SettingsDialog } from "@/components/settings-dialog" - -type NavItem = { - id: ActiveSection - title: string - icon: React.ElementType -} - -const navItems: NavItem[] = [ - { id: "tasks", title: "Chats", icon: MessageSquare }, - { id: "knowledge", title: "Knowledge", icon: Brain }, -] - -export function SidebarIcon() { - const { activeSection, setActiveSection } = useSidebarSection() - - return ( -
- {/* Main navigation */} - - - {/* Secondary navigation (bottom) */} - -
- ) -} diff --git a/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx b/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx index eaed9baf..5f710b3f 100644 --- a/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx +++ b/apps/x/apps/renderer/src/components/ui/dropdown-menu.tsx @@ -31,7 +31,7 @@ function DropdownMenuTrigger({ function DropdownMenuContent({ className, - sideOffset = 4, + sideOffset = 8, ...props }: React.ComponentProps) { return ( @@ -40,7 +40,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg", className )} {...props} @@ -72,7 +72,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -90,7 +90,7 @@ function DropdownMenuCheckboxItem({ ) { return ( @@ -30,7 +30,7 @@ function HoverCardContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-lg outline-hidden", className )} {...props} diff --git a/apps/x/apps/renderer/src/components/ui/input-group.tsx b/apps/x/apps/renderer/src/components/ui/input-group.tsx index 6a2767e7..49cbdb4f 100644 --- a/apps/x/apps/renderer/src/components/ui/input-group.tsx +++ b/apps/x/apps/renderer/src/components/ui/input-group.tsx @@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="input-group" role="group" className={cn( - "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", + "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-sm border shadow-none transition-[color,box-shadow] outline-none", "h-9 min-w-0 has-[>textarea]:h-auto", // Variants based on alignment. diff --git a/apps/x/apps/renderer/src/components/ui/popover.tsx b/apps/x/apps/renderer/src/components/ui/popover.tsx index 6d51b6ce..27f1af3c 100644 --- a/apps/x/apps/renderer/src/components/ui/popover.tsx +++ b/apps/x/apps/renderer/src/components/ui/popover.tsx @@ -18,7 +18,7 @@ function PopoverTrigger({ function PopoverContent({ className, align = "center", - sideOffset = 4, + sideOffset = 8, ...props }: React.ComponentProps) { return ( @@ -28,7 +28,7 @@ function PopoverContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-lg outline-hidden", className )} {...props} diff --git a/apps/x/apps/renderer/src/contexts/file-card-context.tsx b/apps/x/apps/renderer/src/contexts/file-card-context.tsx new file mode 100644 index 00000000..08910b12 --- /dev/null +++ b/apps/x/apps/renderer/src/contexts/file-card-context.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, type ReactNode } from 'react' + +interface FileCardContextType { + onOpenKnowledgeFile: (path: string) => void +} + +const FileCardContext = createContext(null) + +export function useFileCard() { + const ctx = useContext(FileCardContext) + if (!ctx) throw new Error('useFileCard must be used within FileCardProvider') + return ctx +} + +export function FileCardProvider({ + onOpenKnowledgeFile, + children, +}: { + onOpenKnowledgeFile: (path: string) => void + children: ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx new file mode 100644 index 00000000..1149cb42 --- /dev/null +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -0,0 +1,93 @@ +"use client" + +import * as React from "react" + +export type Theme = "light" | "dark" | "system" + +type ThemeContextProps = { + theme: Theme + resolvedTheme: "light" | "dark" + setTheme: (theme: Theme) => void +} + +const ThemeContext = React.createContext(null) + +const STORAGE_KEY = "rowboat-theme" + +function getSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light" + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" +} + +export function useTheme() { + const context = React.useContext(ThemeContext) + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider.") + } + return context +} + +export function ThemeProvider({ + defaultTheme = "system", + children, +}: { + defaultTheme?: Theme + children: React.ReactNode +}) { + const [theme, setThemeState] = React.useState(() => { + if (typeof window === "undefined") return defaultTheme + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null + return stored || defaultTheme + }) + + const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { + if (theme === "system") return getSystemTheme() + return theme + }) + + // Apply theme to document + React.useEffect(() => { + const root = document.documentElement + const resolved = theme === "system" ? getSystemTheme() : theme + + root.classList.remove("light", "dark") + root.classList.add(resolved) + setResolvedTheme(resolved) + }, [theme]) + + // Listen for system theme changes + React.useEffect(() => { + if (theme !== "system") return + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handleChange = () => { + const resolved = getSystemTheme() + document.documentElement.classList.remove("light", "dark") + document.documentElement.classList.add(resolved) + setResolvedTheme(resolved) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, [theme]) + + const setTheme = React.useCallback((newTheme: Theme) => { + localStorage.setItem(STORAGE_KEY, newTheme) + setThemeState(newTheme) + }, []) + + const contextValue = React.useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + }), + [theme, resolvedTheme, setTheme] + ) + + return ( + + {children} + + ) +} diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index 0feeaa41..7ad7ac86 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { PostHogProvider } from 'posthog-js/react' +import { ThemeProvider } from '@/contexts/theme-context' const options = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, @@ -12,7 +13,9 @@ const options = { createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 7fb0bc68..743354cc 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -12,9 +12,9 @@ "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", - "@composio/core": "^0.6.0", "@ai-sdk/openai-compatible": "^1.0.27", "@ai-sdk/provider": "^2.0.0", + "@composio/core": "^0.6.0", "@google-cloud/local-auth": "^3.0.1", "@modelcontextprotocol/sdk": "^1.25.1", "@openrouter/ai-sdk-provider": "^1.2.6", @@ -24,17 +24,24 @@ "ai": "^5.0.102", "awilix": "^12.0.5", "chokidar": "^4.0.3", + "cron-parser": "^5.5.0", "glob": "^13.0.0", "google-auth-library": "^10.5.0", "googleapis": "^169.0.0", + "mammoth": "^1.11.0", "node-html-markdown": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4", "openid-client": "^6.8.1", + "papaparse": "^5.5.3", + "pdf-parse": "^2.4.5", "react": "^19.2.3", + "xlsx": "^0.18.5", "yaml": "^2.8.2", "zod": "^4.2.1" }, "devDependencies": { - "@types/node": "^25.0.3" + "@types/node": "^25.0.3", + "@types/papaparse": "^5.5.2", + "@types/pdf-parse": "^1.1.5" } } diff --git a/apps/x/packages/core/src/agent-schedule/repo.ts b/apps/x/packages/core/src/agent-schedule/repo.ts new file mode 100644 index 00000000..f32eb0ae --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/repo.ts @@ -0,0 +1,43 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULES: z.infer["agents"] = {}; + +export interface IAgentScheduleRepo { + ensureConfig(): Promise; + getConfig(): Promise>; + upsert(agentName: string, entry: z.infer): Promise; + delete(agentName: string): Promise; +} + +export class FSAgentScheduleRepo implements IAgentScheduleRepo { + private readonly configPath = path.join(WorkDir, "config", "agent-schedule.json"); + + async ensureConfig(): Promise { + try { + await fs.access(this.configPath); + } catch { + await fs.writeFile(this.configPath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULES }, null, 2)); + } + } + + async getConfig(): Promise> { + const config = await fs.readFile(this.configPath, "utf8"); + return AgentScheduleConfig.parse(JSON.parse(config)); + } + + async upsert(agentName: string, entry: z.infer): Promise { + const conf = await this.getConfig(); + conf.agents[agentName] = entry; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } + + async delete(agentName: string): Promise { + const conf = await this.getConfig(); + delete conf.agents[agentName]; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } +} diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts new file mode 100644 index 00000000..4eab6081 --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -0,0 +1,335 @@ +import { CronExpressionParser } from "cron-parser"; +import container from "../di/container.js"; +import { IAgentScheduleRepo } from "./repo.js"; +import { IAgentScheduleStateRepo } from "./state-repo.js"; +import { IRunsRepo } from "../runs/repo.js"; +import { IAgentRuntime } from "../agents/runtime.js"; +import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; +import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; +import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; +import { MessageEvent } from "@x/shared/dist/runs.js"; +import z from "zod"; + +const DEFAULT_STARTING_MESSAGE = "go"; + +const POLL_INTERVAL_MS = 60 * 1000; // 1 minute +const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Convert a Date to local ISO 8601 string (without Z suffix). + * Example: "2024-02-05T08:30:00" + */ +function toLocalISOString(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +// --- Wake Signal for Immediate Run Trigger --- +let wakeResolve: (() => void) | null = null; + +export function triggerRun(): void { + if (wakeResolve) { + console.log("[AgentRunner] Triggered - waking up immediately"); + wakeResolve(); + wakeResolve = null; + } +} + +function interruptibleSleep(ms: number): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); +} + +/** + * Calculate the next run time for a schedule. + * Returns ISO datetime string or null if schedule shouldn't run again. + */ +function calculateNextRunAt( + schedule: z.infer["schedule"] +): string | null { + const now = new Date(); + + switch (schedule.type) { + case "cron": { + try { + const interval = CronExpressionParser.parse(schedule.expression, { + currentDate: now, + }); + return toLocalISOString(interval.next().toDate()); + } catch (error) { + console.error("[AgentRunner] Invalid cron expression:", schedule.expression, error); + return null; + } + } + case "window": { + try { + // Parse base cron to get the next occurrence date + const interval = CronExpressionParser.parse(schedule.cron, { + currentDate: now, + }); + const nextDate = interval.next().toDate(); + + // Parse start and end times + const [startHour, startMin] = schedule.startTime.split(":").map(Number); + const [endHour, endMin] = schedule.endTime.split(":").map(Number); + + // Pick a random time within the window + const startMinutes = startHour * 60 + startMin; + const endMinutes = endHour * 60 + endMin; + const randomMinutes = startMinutes + Math.floor(Math.random() * (endMinutes - startMinutes)); + + nextDate.setHours(Math.floor(randomMinutes / 60), randomMinutes % 60, 0, 0); + return toLocalISOString(nextDate); + } catch (error) { + console.error("[AgentRunner] Invalid window schedule:", error); + return null; + } + } + case "once": { + // Once schedules don't have a "next" run - they're done after first run + return null; + } + } +} + +/** + * Check if an agent should run now based on its schedule and state. + */ +function shouldRunNow( + entry: z.infer, + state: z.infer | null +): boolean { + // Don't run if disabled + if (entry.enabled === false) { + return false; + } + + // Don't run if already running + if (state?.status === "running") { + return false; + } + + // Don't run once-schedules that are already triggered + if (entry.schedule.type === "once" && state?.status === "triggered") { + return false; + } + + const now = new Date(); + + // For once-schedules without state, check if runAt time has passed + if (entry.schedule.type === "once") { + const runAt = new Date(entry.schedule.runAt); + return now >= runAt; + } + + // For cron and window schedules, check nextRunAt + if (!state?.nextRunAt) { + // No nextRunAt set - needs to be initialized, so run now + return true; + } + + const nextRunAt = new Date(state.nextRunAt); + return now >= nextRunAt; +} + +/** + * Run a single agent. + */ +async function runAgent( + agentName: string, + entry: z.infer, + stateRepo: IAgentScheduleStateRepo, + runsRepo: IRunsRepo, + agentRuntime: IAgentRuntime, + idGenerator: IMonotonicallyIncreasingIdGenerator +): Promise { + console.log(`[AgentRunner] Starting agent: ${agentName}`); + + const startedAt = toLocalISOString(new Date()); + + // Update state to running with startedAt timestamp + await stateRepo.updateAgentState(agentName, { + status: "running", + startedAt: startedAt, + }); + + try { + // Create a new run + const run = await runsRepo.create({ agentId: agentName }); + console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`); + + // Add the starting message as a user message + const startingMessage = entry.startingMessage ?? DEFAULT_STARTING_MESSAGE; + const messageEvent: z.infer = { + runId: run.id, + type: "message", + messageId: await idGenerator.next(), + message: { + role: "user", + content: startingMessage, + }, + subflow: [], + }; + await runsRepo.appendEvents(run.id, [messageEvent]); + console.log(`[AgentRunner] Sent starting message to agent ${agentName}: "${startingMessage}"`); + + // Trigger the run + await agentRuntime.trigger(run.id); + + // Calculate next run time + const nextRunAt = calculateNextRunAt(entry.schedule); + + // Update state to finished (clear startedAt) + const currentState = await stateRepo.getAgentState(agentName); + await stateRepo.updateAgentState(agentName, { + status: entry.schedule.type === "once" ? "triggered" : "finished", + startedAt: null, + lastRunAt: toLocalISOString(new Date()), + nextRunAt: nextRunAt, + lastError: null, + runCount: (currentState?.runCount ?? 0) + 1, + }); + + console.log(`[AgentRunner] Finished agent: ${agentName}`); + } catch (error) { + console.error(`[AgentRunner] Error running agent ${agentName}:`, error); + + // Calculate next run time even on failure (for retry) + const nextRunAt = calculateNextRunAt(entry.schedule); + + // Update state to failed (clear startedAt) + const currentState = await stateRepo.getAgentState(agentName); + await stateRepo.updateAgentState(agentName, { + status: "failed", + startedAt: null, + lastRunAt: toLocalISOString(new Date()), + nextRunAt: nextRunAt, + lastError: error instanceof Error ? error.message : String(error), + runCount: (currentState?.runCount ?? 0) + 1, + }); + } +} + +/** + * Check for timed-out agents and mark them as failed. + */ +async function checkForTimeouts( + state: z.infer, + config: z.infer, + stateRepo: IAgentScheduleStateRepo +): Promise { + const now = new Date(); + + for (const [agentName, agentState] of Object.entries(state.agents)) { + if (agentState.status === "running" && agentState.startedAt) { + const startedAt = new Date(agentState.startedAt); + const elapsed = now.getTime() - startedAt.getTime(); + + if (elapsed > TIMEOUT_MS) { + console.log(`[AgentRunner] Agent ${agentName} timed out after ${Math.round(elapsed / 1000 / 60)} minutes`); + + // Get schedule entry for calculating next run + const entry = config.agents[agentName]; + const nextRunAt = entry ? calculateNextRunAt(entry.schedule) : null; + + await stateRepo.updateAgentState(agentName, { + status: "failed", + startedAt: null, + lastRunAt: toLocalISOString(now), + nextRunAt: nextRunAt, + lastError: `Timed out after ${Math.round(elapsed / 1000 / 60)} minutes`, + runCount: (agentState.runCount ?? 0) + 1, + }); + } + } + } +} + +/** + * Main polling loop. + */ +async function pollAndRun(): Promise { + const scheduleRepo = container.resolve("agentScheduleRepo"); + const stateRepo = container.resolve("agentScheduleStateRepo"); + const runsRepo = container.resolve("runsRepo"); + const agentRuntime = container.resolve("agentRuntime"); + const idGenerator = container.resolve("idGenerator"); + + // Load config and state + let config: z.infer; + let state: z.infer; + + try { + config = await scheduleRepo.getConfig(); + state = await stateRepo.getState(); + } catch (error) { + console.error("[AgentRunner] Error loading config/state:", error); + return; + } + + // Check for timed-out agents first + await checkForTimeouts(state, config, stateRepo); + + // Reload state after timeout checks (state may have changed) + try { + state = await stateRepo.getState(); + } catch (error) { + console.error("[AgentRunner] Error reloading state:", error); + return; + } + + // Check each agent + for (const [agentName, entry] of Object.entries(config.agents)) { + const agentState = state.agents[agentName] ?? null; + + // Initialize state if needed (set nextRunAt for new agents) + if (!agentState && entry.schedule.type !== "once") { + const nextRunAt = calculateNextRunAt(entry.schedule); + if (nextRunAt) { + await stateRepo.updateAgentState(agentName, { + status: "scheduled", + startedAt: null, + lastRunAt: null, + nextRunAt: nextRunAt, + lastError: null, + runCount: 0, + }); + console.log(`[AgentRunner] Initialized state for ${agentName}, next run at ${nextRunAt}`); + } + continue; // Don't run immediately on first initialization + } + + if (shouldRunNow(entry, agentState)) { + // Run agent (don't await - let it run in background) + runAgent(agentName, entry, stateRepo, runsRepo, agentRuntime, idGenerator).catch((error) => { + console.error(`[AgentRunner] Unhandled error in runAgent for ${agentName}:`, error); + }); + } + } +} + +/** + * Initialize the background agent runner service. + * Polls every minute to check for agents that need to run. + */ +export async function init(): Promise { + console.log("[AgentRunner] Starting background agent runner service"); + + while (true) { + try { + await pollAndRun(); + } catch (error) { + console.error("[AgentRunner] Error in main loop:", error); + } + + await interruptibleSleep(POLL_INTERVAL_MS); + } +} diff --git a/apps/x/packages/core/src/agent-schedule/state-repo.ts b/apps/x/packages/core/src/agent-schedule/state-repo.ts new file mode 100644 index 00000000..38c8f034 --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/state-repo.ts @@ -0,0 +1,64 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULE_STATE: z.infer["agents"] = {}; + +export interface IAgentScheduleStateRepo { + ensureState(): Promise; + getState(): Promise>; + getAgentState(agentName: string): Promise | null>; + updateAgentState(agentName: string, entry: Partial>): Promise; + setAgentState(agentName: string, entry: z.infer): Promise; + deleteAgentState(agentName: string): Promise; +} + +export class FSAgentScheduleStateRepo implements IAgentScheduleStateRepo { + private readonly statePath = path.join(WorkDir, "config", "agent-schedule-state.json"); + + async ensureState(): Promise { + try { + await fs.access(this.statePath); + } catch { + await fs.writeFile(this.statePath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULE_STATE }, null, 2)); + } + } + + async getState(): Promise> { + const state = await fs.readFile(this.statePath, "utf8"); + return AgentScheduleState.parse(JSON.parse(state)); + } + + async getAgentState(agentName: string): Promise | null> { + const state = await this.getState(); + return state.agents[agentName] ?? null; + } + + async updateAgentState(agentName: string, entry: Partial>): Promise { + const state = await this.getState(); + const existing = state.agents[agentName] ?? { + status: "scheduled" as const, + startedAt: null, + lastRunAt: null, + nextRunAt: null, + lastError: null, + runCount: 0, + }; + state.agents[agentName] = { ...existing, ...entry }; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async setAgentState(agentName: string, entry: z.infer): Promise { + const state = await this.getState(); + state.agents[agentName] = entry; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async deleteAgentState(agentName: string): Promise { + const state = await this.getState(); + delete state.agents[agentName]; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } +} diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 57f3a446..1a70f28f 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -158,6 +158,8 @@ When a user asks for ANY task that might require external capabilities (web sear - \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations - \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search - \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management +- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute paths or workspace-relative paths — no need to copy files into the workspace first. Best for well-structured digital documents. +- \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images. - \`analyzeAgent\` - Agent analysis - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading @@ -179,4 +181,25 @@ When a user asks for ANY task that might require external capabilities (web sear **Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. -Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.`; +Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do. + +## File Path References + +When you reference a file path in your response (whether a knowledge base file or a file on the user's system), ALWAYS wrap it in a filepath code block: + +\`\`\`filepath +knowledge/People/Sarah Chen.md +\`\`\` + +\`\`\`filepath +~/Desktop/report.pdf +\`\`\` + +This renders as an interactive card in the UI that the user can click to open the file. Use this format for: +- Knowledge base file paths (knowledge/...) +- Files on the user's machine (~/Desktop/..., /Users/..., etc.) +- Audio files, images, documents, or any file reference + +**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully. + +Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`; diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts new file mode 100644 index 00000000..7ac1b89e --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts @@ -0,0 +1,555 @@ +export const skill = String.raw` +# Background Agents + +Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace. + +## Core Concepts + +**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. + +- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter +- Agents configure a model, tools (in frontmatter), and instructions (in the body) +- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents** +- **"Workflows" are just agents that orchestrate other agents** by having them as tools +- **Background agents run on schedules** defined in ` + "`~/.rowboat/config/agent-schedule.json`" + ` + +## How multi-agent workflows work + +1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + ` +2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below) +3. The orchestrator calls other agents as tools when needed +4. Data flows through tool call parameters and responses + +## Scheduling Background Agents + +Background agents run automatically based on schedules defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `. + +### Schedule Configuration File + +` + "```json" + ` +{ + "agents": { + "agent_name": { + "schedule": { ... }, + "enabled": true + } + } +} +` + "```" + ` + +### Schedule Types + +**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat). + +**1. Cron Schedule** - Runs at exact times defined by cron expression +` + "```json" + ` +{ + "schedule": { + "type": "cron", + "expression": "0 8 * * *" + }, + "enabled": true +} +` + "```" + ` + +Common cron expressions: +- ` + "`*/5 * * * *`" + ` - Every 5 minutes +- ` + "`0 8 * * *`" + ` - Every day at 8am +- ` + "`0 9 * * 1`" + ` - Every Monday at 9am +- ` + "`0 0 1 * *`" + ` - First day of every month at midnight + +**2. Window Schedule** - Runs once during a time window +` + "```json" + ` +{ + "schedule": { + "type": "window", + "cron": "0 0 * * *", + "startTime": "08:00", + "endTime": "10:00" + }, + "enabled": true +} +` + "```" + ` + +The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am"). + +**3. Once Schedule** - Runs exactly once at a specific time +` + "```json" + ` +{ + "schedule": { + "type": "once", + "runAt": "2024-02-05T10:30:00" + }, + "enabled": true +} +` + "```" + ` + +Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix). + +### Starting Message + +You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `. + +` + "```json" + ` +{ + "schedule": { "type": "cron", "expression": "0 8 * * *" }, + "enabled": true, + "startingMessage": "Please summarize my emails from the last 24 hours" +} +` + "```" + ` + +### Description + +You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI. + +` + "```json" + ` +{ + "schedule": { "type": "cron", "expression": "0 8 * * *" }, + "enabled": true, + "description": "Summarizes emails and calendar events every morning" +} +` + "```" + ` + +### Complete Schedule Example + +` + "```json" + ` +{ + "agents": { + "daily_digest": { + "schedule": { + "type": "cron", + "expression": "0 8 * * *" + }, + "enabled": true, + "description": "Daily email and calendar summary", + "startingMessage": "Summarize my emails and calendar for today" + }, + "morning_briefing": { + "schedule": { + "type": "window", + "cron": "0 0 * * *", + "startTime": "07:00", + "endTime": "09:00" + }, + "enabled": true, + "description": "Morning news and updates briefing" + }, + "one_time_setup": { + "schedule": { + "type": "once", + "runAt": "2024-12-01T12:00:00" + }, + "enabled": true, + "description": "One-time data migration task" + } + } +} +` + "```" + ` + +### Schedule State (Read-Only) + +**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. + +The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: +- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) +- ` + "`lastRunAt`" + `: When the agent last ran +- ` + "`nextRunAt`" + `: When the agent will run next +- ` + "`lastError`" + `: Error message if the last run failed +- ` + "`runCount`" + `: Total number of runs + +When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `. + +## Agent File Format + +Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. + +### Basic Structure +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + tool_key: + type: builtin + name: tool_name +--- +# Instructions + +Your detailed instructions go here in Markdown format. +` + "```" + ` + +### Frontmatter Fields +- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') +- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json +- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions + +### Instructions (Body) +The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. + +### Naming Rules +- Agent filename determines the agent name (without .md extension) +- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent" +- Use lowercase with underscores for multi-word names +- No spaces or special characters in names +- **The agent name in agent-schedule.json must match the filename** (without .md) + +### Agent Format Example +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string + description: Search query + required: + - query +--- +# Web Search Agent + +You are a web search agent. When asked a question: + +1. Use the search tool to find relevant information +2. Summarize the results clearly +3. Cite your sources + +Be concise and accurate. +` + "```" + ` + +## Tool Types & Schemas + +Tools in agents must follow one of three types. Each has specific required fields. + +### 1. Builtin Tools +Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: builtin + name: tool_name +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "builtin" +- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile") + +**Example:** +` + "```yaml" + ` +bash: + type: builtin + name: executeCommand +` + "```" + ` + +**Available builtin tools:** +- ` + "`executeCommand`" + ` - Execute shell commands +- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations +- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations +- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management +- ` + "`analyzeAgent`" + ` - Analyze agent structure +- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management +- ` + "`loadSkill`" + ` - Load skill guidance + +### 2. MCP Tools +Tools from external MCP servers (APIs, databases, web scraping, etc.) + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: mcp + name: tool_name_from_server + description: What the tool does + mcpServerName: server_name_from_config + inputSchema: + type: object + properties: + param: + type: string + description: Parameter description + required: + - param +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "mcp" +- ` + "`name`" + `: Exact tool name from MCP server +- ` + "`description`" + `: What the tool does (helps agent understand when to use it) +- ` + "`mcpServerName`" + `: Server name from config/mcp.json +- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters + +**Example:** +` + "```yaml" + ` +search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string + description: Search query + required: + - query +` + "```" + ` + +**Important:** +- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server +- Copy the schema exactly—don't modify property types or structure +- Only include ` + "`required`" + ` array if parameters are mandatory + +### 3. Agent Tools (for chaining agents) +Reference other agents as tools to build multi-agent workflows + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: agent + name: target_agent_name +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "agent" +- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory) + +**Example:** +` + "```yaml" + ` +summariser: + type: agent + name: summariser_agent +` + "```" + ` + +**How it works:** +- Use ` + "`type: agent`" + ` to call other agents as tools +- The target agent will be invoked with the parameters you pass +- Results are returned as tool output +- This is how you build multi-agent workflows +- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `) + +## Complete Multi-Agent Workflow Example + +**Email digest workflow** - This is all done through agents calling other agents: + +**1. Task-specific agent** (` + "`agents/email_reader.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + read_file: + type: builtin + name: workspace-readFile + list_dir: + type: builtin + name: workspace-readdir +--- +# Email Reader Agent + +Read emails from the gmail_sync folder and extract key information. +Look for unread or recent emails and summarize the sender, subject, and key points. +Don't ask for human input. +` + "```" + ` + +**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + email_reader: + type: agent + name: email_reader + write_file: + type: builtin + name: workspace-writeFile +--- +# Daily Summary Agent + +1. Use the email_reader tool to get email summaries +2. Create a consolidated daily digest +3. Save the digest to ~/Desktop/daily_digest.md + +Don't ask for human input. +` + "```" + ` + +Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions. + +**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + daily_summary: + type: agent + name: daily_summary + search: + type: mcp + name: search + mcpServerName: exa + description: Search the web for news + inputSchema: + type: object + properties: + query: + type: string + description: Search query +--- +# Morning Briefing Workflow + +Create a morning briefing: + +1. Get email digest using daily_summary +2. Search for relevant news using the search tool +3. Compile a comprehensive morning briefing + +Execute these steps in sequence. Don't ask for human input. +` + "```" + ` + +**4. Schedule the workflow** in ` + "`~/.rowboat/config/agent-schedule.json`" + `: +` + "```json" + ` +{ + "agents": { + "morning_briefing": { + "schedule": { + "type": "cron", + "expression": "0 7 * * *" + }, + "enabled": true, + "startingMessage": "Create my morning briefing for today" + } + } +} +` + "```" + ` + +This schedules the morning briefing workflow to run every day at 7am local time. + +## Naming and organization rules +- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter +- Agent filename (without .md) becomes the agent name +- When referencing an agent as a tool, use its filename without extension +- When scheduling an agent, use its filename without extension in agent-schedule.json +- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users + +## Best practices for background agents +1. **Single responsibility**: Each agent should do one specific thing well +2. **Clear delegation**: Agent instructions should explicitly say when to call other agents +3. **Autonomous operation**: Add "Don't ask for human input" for background agents +4. **Data passing**: Make it clear what data to extract and pass between agents +5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") +6. **Orchestration**: Create a top-level agent that coordinates the workflow +7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks +8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene +9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations +10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md" + +## Validation & Best Practices + +### CRITICAL: Schema Compliance +- Agent files MUST be valid Markdown with YAML frontmatter +- Agent filename (without .md) becomes the agent name +- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent") +- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema +- Agent tools MUST reference existing agent files +- Invalid agents will fail to load and prevent workflow execution + +### File Creation/Update Process +1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter +2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + ` +3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent +4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `) +5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + ` + +### Common Validation Errors to Avoid + +❌ **WRONG - Missing frontmatter delimiters:** +` + "```markdown" + ` +model: gpt-5.1 +# My Agent +Instructions here +` + "```" + ` + +❌ **WRONG - Invalid YAML indentation:** +` + "```markdown" + ` +--- +tools: +bash: + type: builtin +--- +` + "```" + ` +(bash should be indented under tools) + +❌ **WRONG - Invalid tool type:** +` + "```yaml" + ` +tools: + tool1: + type: custom + name: something +` + "```" + ` +(type must be builtin, mcp, or agent) + +❌ **WRONG - Unquoted strings containing colons:** +` + "```yaml" + ` +tools: + search: + description: Number of results (default: 8) +` + "```" + ` +(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `) + +❌ **WRONG - MCP tool missing required fields:** +` + "```yaml" + ` +tools: + search: + type: mcp + name: firecrawl_search +` + "```" + ` +(Missing: description, mcpServerName, inputSchema) + +✅ **CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +--- +# Simple Agent + +Do simple tasks as instructed. +` + "```" + ` + +✅ **CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string +--- +# Search Agent + +Use the search tool to find information on the web. +` + "```" + ` + +## Capabilities checklist +1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing +2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes +3. Validate YAML frontmatter syntax before creating/updating agents +4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update +5. When creating multi-agent workflows, create an orchestrator agent +6. Add other agents as tools with ` + "`type: agent`" + ` for chaining +7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations +8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) +9. Confirm work done and outline next steps once changes are complete +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts index 594c0f7b..97f801c8 100644 --- a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts @@ -7,20 +7,22 @@ Activate when the user wants to create presentations, slide decks, or pitch deck ## Workflow -1. Check ~/.rowboat/knowledge/ for relevant context about the company, product, team, etc. +1. Use workspace-readFile to check knowledge/ for relevant context about the company, product, team, etc. 2. Ensure Playwright is installed: 'npm install playwright && npx playwright install chromium' -3. Create an HTML file (e.g., /tmp/presentation.html) with slides (1280x720px each) -4. Create a Node.js script to convert HTML to PDF: +3. Use workspace-getRoot to get the workspace root path. +4. Use workspace-writeFile to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each). +5. Use workspace-writeFile to create a Node.js conversion script at tmp/convert.js (workspace-relative): ~~~javascript -// save as /tmp/convert.js +// save as tmp/convert.js via workspace-writeFile const { chromium } = require('playwright'); const path = require('path'); (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); - await page.goto('file:///tmp/presentation.html', { waitUntil: 'networkidle' }); + // Use the workspace root path from workspace-getRoot + await page.goto('file:///tmp/presentation.html', { waitUntil: 'networkidle' }); await page.pdf({ path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'), width: '1280px', @@ -32,10 +34,13 @@ const path = require('path'); })(); ~~~ -5. Run it: 'node /tmp/convert.js' -6. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" +Replace with the actual absolute path returned by workspace-getRoot. + +6. Run it: 'node /tmp/convert.js' +7. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" Do NOT show HTML code to the user. Do NOT explain how to export. Just create the PDF and deliver it. +Use workspace-writeFile and workspace-readFile for ALL file operations. Do NOT use executeCommand to write or read files. ## PDF Export Rules @@ -45,6 +50,7 @@ Do NOT show HTML code to the user. Do NOT explain how to export. Just create the 2. **No box-shadow** - Use borders instead: \`border: 1px solid #e5e7eb\` 3. **Bullets via CSS only** - Use \`li::before\` pseudo-elements, not separate DOM elements 4. **Content must fit** - Slides are 1280x720px with 60px padding. Safe area is 1160x600px. Use \`overflow: hidden\`. +5. **No footers or headers** - Never add fixed/absolute-positioned footer or header elements to slides. They overlap with content in PDF rendering. If you need a slide number or title, include it as part of the normal content flow. ## Required CSS diff --git a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts index ba17f545..159ce638 100644 --- a/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/doc-collab/skill.ts @@ -13,6 +13,10 @@ You are an expert document assistant helping the user create, edit, and refine d **Strictly follow their choice for the entire session.** Don't switch modes without asking. +## CRITICAL: Re-read Before Every Response + +**Before every response, you MUST use workspace-readFile to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version. + ## Core Principles **Be concise and direct:** @@ -90,6 +94,8 @@ workspace-createFile({ ### Step 2: Understand the Request +**IMPORTANT: Never make unsolicited edits.** If the user hasn't specified what they want to do with the document, ask them: "What would you like to change?" Do NOT proactively improve, restructure, or suggest edits unless the user has explicitly asked for changes. + **Types of requests:** 1. **Direct edits** - "Change the title to X", "Add a bullet point about Y", "Remove the pricing section" @@ -104,6 +110,9 @@ workspace-createFile({ 4. **Research-backed additions** - "Add context about [Person]", "Include what we discussed with [Company]" → Search knowledge base first, then add relevant context +5. **No clear request** - User just says "let's work on X" with no specific ask + → Read the document, then ask: "What would you like to change?" + ### Step 3: Execute Changes **For edits, use workspace-editFile:** diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 6ef19e8d..0d167a52 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -8,9 +8,8 @@ import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; import slackSkill from "./slack/skill.js"; -import workflowAuthoringSkill from "./workflow-authoring/skill.js"; +import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; -import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -66,10 +65,10 @@ const definitions: SkillDefinition[] = [ content: slackSkill, }, { - id: "workflow-authoring", - title: "Workflow Authoring", - summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", - content: workflowAuthoringSkill, + id: "background-agents", + title: "Background Agents", + summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.", + content: backgroundAgentsSkill, }, { id: "builtin-tools", @@ -89,12 +88,6 @@ const definitions: SkillDefinition[] = [ summary: "Following the confirmation process before removing workflows or agents and their dependencies.", content: deletionGuardrailsSkill, }, - { - id: "workflow-run-ops", - title: "Workflow Run Operations", - summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.", - content: workflowRunOpsSkill, - }, ]; const skillEntries = definitions.map((definition) => ({ diff --git a/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts b/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts index bfdf8cf2..aecf976f 100644 --- a/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/organize-files/skill.ts @@ -72,6 +72,15 @@ grep -r "search term" ~/Documents --include="*.txt" --include="*.md" find ~/Downloads -name "*.pdf" -exec basename {} \; \`\`\` +**Extracting content from documents:** +When users want to read or summarize a document's contents (PDF, Excel, CSV, Word .docx), use the \`parseFile\` builtin tool. It extracts text from binary formats so you can answer questions about them. +- Accepts absolute paths (e.g., \`~/Downloads/report.pdf\`) or workspace-relative paths — no need to copy files first. +- Supported formats: \`.pdf\`, \`.xlsx\`, \`.xls\`, \`.csv\`, \`.docx\` + +For scanned PDFs, images with text, complex layouts, or presentations where local parsing falls short, use the \`LLMParse\` builtin tool instead. It sends the file to the configured LLM as a multimodal attachment and returns well-structured markdown. +- Supports everything \`parseFile\` does plus images (\`.png\`, \`.jpg\`, \`.gif\`, \`.webp\`, \`.svg\`, \`.bmp\`, \`.tiff\`), PowerPoint (\`.pptx\`), HTML, and plain text. +- Also accepts an optional \`prompt\` parameter for custom extraction instructions. + ## Organizing Files **Create destination folder:** diff --git a/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts b/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts deleted file mode 100644 index bcd50258..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts +++ /dev/null @@ -1,384 +0,0 @@ -export const skill = String.raw` -# Agent and Workflow Authoring - -Load this skill whenever a user wants to inspect, create, or update agents inside the Rowboat workspace. - -## Core Concepts - -**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. - -- **All definitions live in \`agents/*.md\`** - Markdown files with YAML frontmatter -- Agents configure a model, tools (in frontmatter), and instructions (in the body) -- Tools can be: builtin (like \`executeCommand\`), MCP integrations, or **other agents** -- **"Workflows" are just agents that orchestrate other agents** by having them as tools - -## How multi-agent workflows work - -1. **Create an orchestrator agent** that has other agents in its \`tools\` -2. **Run the orchestrator**: \`rowboatx --agent orchestrator_name\` -3. The orchestrator calls other agents as tools when needed -4. Data flows through tool call parameters and responses - -## Agent File Format - -Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. - -### Basic Structure -\`\`\`markdown ---- -model: gpt-5.1 -tools: - tool_key: - type: builtin - name: tool_name ---- -# Instructions - -Your detailed instructions go here in Markdown format. -\`\`\` - -### Frontmatter Fields -- \`model\`: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') -- \`provider\`: (OPTIONAL) Provider alias from models.json -- \`tools\`: (OPTIONAL) Object containing tool definitions - -### Instructions (Body) -The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. - -### Naming Rules -- Agent filename determines the agent name (without .md extension) -- Example: \`summariser_agent.md\` creates an agent named "summariser_agent" -- Use lowercase with underscores for multi-word names -- No spaces or special characters in names - -### Agent Format Example -\`\`\`markdown ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query ---- -# Web Search Agent - -You are a web search agent. When asked a question: - -1. Use the search tool to find relevant information -2. Summarize the results clearly -3. Cite your sources - -Be concise and accurate. -\`\`\` - -## Tool Types & Schemas - -Tools in agents must follow one of three types. Each has specific required fields. - -### 1. Builtin Tools -Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: builtin - name: tool_name -\`\`\` - -**Required fields:** -- \`type\`: Must be "builtin" -- \`name\`: Builtin tool name (e.g., "executeCommand", "workspace-readFile") - -**Example:** -\`\`\`yaml -bash: - type: builtin - name: executeCommand -\`\`\` - -**Available builtin tools:** -- \`executeCommand\` - Execute shell commands -- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-remove\` - File operations -- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\` - Directory operations -- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management -- \`analyzeAgent\` - Analyze agent structure -- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\` - MCP management -- \`loadSkill\` - Load skill guidance - -### 2. MCP Tools -Tools from external MCP servers (APIs, databases, web scraping, etc.) - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: mcp - name: tool_name_from_server - description: What the tool does - mcpServerName: server_name_from_config - inputSchema: - type: object - properties: - param: - type: string - description: Parameter description - required: - - param -\`\`\` - -**Required fields:** -- \`type\`: Must be "mcp" -- \`name\`: Exact tool name from MCP server -- \`description\`: What the tool does (helps agent understand when to use it) -- \`mcpServerName\`: Server name from config/mcp.json -- \`inputSchema\`: Full JSON Schema object for tool parameters - -**Example:** -\`\`\`yaml -search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query -\`\`\` - -**Important:** -- Use \`listMcpTools\` to get the exact inputSchema from the server -- Copy the schema exactly—don't modify property types or structure -- Only include \`required\` array if parameters are mandatory - -### 3. Agent Tools (for chaining agents) -Reference other agents as tools to build multi-agent workflows - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: agent - name: target_agent_name -\`\`\` - -**Required fields:** -- \`type\`: Must be "agent" -- \`name\`: Name of the target agent (must exist in agents/ directory) - -**Example:** -\`\`\`yaml -summariser: - type: agent - name: summariser_agent -\`\`\` - -**How it works:** -- Use \`type: agent\` to call other agents as tools -- The target agent will be invoked with the parameters you pass -- Results are returned as tool output -- This is how you build multi-agent workflows -- The referenced agent file must exist (e.g., \`agents/summariser_agent.md\`) - -## Complete Multi-Agent Workflow Example - -**Podcast creation workflow** - This is all done through agents calling other agents: - -**1. Task-specific agent** (\`agents/summariser_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - bash: - type: builtin - name: executeCommand ---- -# Summariser Agent - -Download and summarise an arxiv paper. Use curl to fetch the PDF. -Output just the GIST in two lines. Don't ask for human input. -\`\`\` - -**2. Agent that delegates to other agents** (\`agents/summarise-a-few.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - summariser: - type: agent - name: summariser_agent ---- -# Summarise Multiple Papers - -Pick 2 interesting papers and summarise each using the summariser tool. -Pass the paper URL to the tool. Don't ask for human input. -\`\`\` - -**3. Orchestrator agent** (\`agents/podcast_workflow.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - bash: - type: builtin - name: executeCommand - summarise_papers: - type: agent - name: summarise-a-few - text_to_speech: - type: mcp - name: text_to_speech - mcpServerName: elevenLabs - description: Generate audio from text - inputSchema: - type: object - properties: - text: - type: string - description: Text to convert to speech ---- -# Podcast Workflow - -Create a podcast from arXiv papers: - -1. Fetch arXiv papers about agents using bash -2. Pick papers and summarise them using summarise_papers -3. Create a podcast transcript -4. Generate audio using text_to_speech - -Execute these steps in sequence. -\`\`\` - -**To run this workflow**: \`rowboatx --agent podcast_workflow\` - -## Naming and organization rules -- **All agents live in \`agents/*.md\`** - Markdown files with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- When referencing an agent as a tool, use its filename without extension -- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users - -## Best practices for multi-agent design -1. **Single responsibility**: Each agent should do one specific thing well -2. **Clear delegation**: Agent instructions should explicitly say when to call other agents -3. **Autonomous operation**: Add "Don't ask for human input" for autonomous workflows -4. **Data passing**: Make it clear what data to extract and pass between agents -5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") -6. **Orchestration**: Create a top-level agent that coordinates the workflow - -## Validation & Best Practices - -### CRITICAL: Schema Compliance -- Agent files MUST be valid Markdown with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- Tools in frontmatter MUST have valid \`type\` ("builtin", "mcp", or "agent") -- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema -- Agent tools MUST reference existing agent files -- Invalid agents will fail to load and prevent workflow execution - -### File Creation/Update Process -1. When creating an agent, use \`workspace-writeFile\` with valid Markdown + YAML frontmatter -2. When updating an agent, read it first with \`workspace-readFile\`, modify, then use \`workspace-writeFile\` -3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent -4. **Quote strings containing colons** (e.g., \`description: "Default: 8"\` not \`description: Default: 8\`) -5. Test agent loading after creation/update by using \`analyzeAgent\` - -### Common Validation Errors to Avoid - -❌ **WRONG - Missing frontmatter delimiters:** -\`\`\`markdown -model: gpt-5.1 -# My Agent -Instructions here -\`\`\` - -❌ **WRONG - Invalid YAML indentation:** -\`\`\`markdown ---- -tools: -bash: - type: builtin ---- -\`\`\` -(bash should be indented under tools) - -❌ **WRONG - Invalid tool type:** -\`\`\`yaml -tools: - tool1: - type: custom - name: something -\`\`\` -(type must be builtin, mcp, or agent) - -❌ **WRONG - Unquoted strings containing colons:** -\`\`\`yaml -tools: - search: - description: Number of results (default: 8) -\`\`\` -(Strings with colons must be quoted: \`description: "Number of results (default: 8)"\`) - -❌ **WRONG - MCP tool missing required fields:** -\`\`\`yaml -tools: - search: - type: mcp - name: firecrawl_search -\`\`\` -(Missing: description, mcpServerName, inputSchema) - -✅ **CORRECT - Minimal valid agent** (\`agents/simple_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 ---- -# Simple Agent - -Do simple tasks as instructed. -\`\`\` - -✅ **CORRECT - Agent with MCP tool** (\`agents/search_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string ---- -# Search Agent - -Use the search tool to find information on the web. -\`\`\` - -## Capabilities checklist -1. Explore \`agents/\` directory to understand existing agents before editing -2. Read existing agents with \`workspace-readFile\` before making changes -3. Validate YAML frontmatter syntax before creating/updating agents -4. Use \`analyzeAgent\` to verify agent structure after creation/update -5. When creating multi-agent workflows, create an orchestrator agent -6. Add other agents as tools with \`type: agent\` for chaining -7. Use \`listMcpServers\` and \`listMcpTools\` when adding MCP integrations -8. Confirm work done and outline next steps once changes are complete -`; - -export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts b/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts deleted file mode 100644 index 25f62267..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts +++ /dev/null @@ -1,95 +0,0 @@ -export const skill = String.raw` -# Agent Run Operations - -Package of repeatable commands for running agents, inspecting agent run history under ~/.rowboat/runs, and managing cron schedules. Load this skill whenever a user asks about running agents, execution history, paused runs, or scheduling. - -## When to use -- User wants to run an agent (including multi-agent workflows) -- User wants to list or filter agent runs (all runs, by agent, time range, or paused for input) -- User wants to inspect cron jobs or change agent schedules -- User asks how to set up monitoring for waiting runs - -## Running Agents - -**To run any agent**: -\`\`\`bash -rowboatx --agent -\`\`\` - -**With input**: -\`\`\`bash -rowboatx --agent --input "your input here" -\`\`\` - -**Non-interactive** (for automation/cron): -\`\`\`bash -rowboatx --agent --input "input" --no-interactive -\`\`\` - -**Note**: Multi-agent workflows are just agents that have other agents in their tools. Run the orchestrator agent to trigger the whole workflow. - -## Run monitoring examples -Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed. - -Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges. - -Each line of the run file contains a running log with the first line containing information about the agent run. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","agent":"agent_name","interactive":true,"ts":"2025-11-12T08:02:41.168Z"}' - -If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below. - -1. **List all runs** - - ls ~/.rowboat/runs - - -2. **Filter by agent** - - grep -rl '"agent":""' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r - - Replace with the desired agent name. - -3. **Filter by time window** - To the previous commands add the below through unix pipe - - awk -F'/' '$NF >= "2025-11-12T08-03" && $NF <= "2025-11-12T08-10"' - - Use the correct timestamps. - -4. **Show runs waiting for human input** - - awk 'FNR==1{if (NR>1) print fn, last; fn=FILENAME} {last=$0} END{print fn, last}' ~/.rowboat/runs/*.jsonl | grep 'pause-for-human-input' | awk '{print $1}' - - Prints the files whose last line equals 'pause-for-human-input'. - -## Cron management examples - -For scheduling agents to run automatically at specific times. - -1. **View current cron schedule** - \`\`\`bash - crontab -l 2>/dev/null || echo 'No crontab entries configured.' - \`\`\` - -2. **Schedule an agent to run periodically** - \`\`\`bash - (crontab -l 2>/dev/null; echo '0 10 * * * cd /path/to/cli && rowboatx --agent --input "input" --no-interactive >> ~/.rowboat/logs/.log 2>&1') | crontab - - \`\`\` - - Example (runs daily at 10 AM): - \`\`\`bash - (crontab -l 2>/dev/null; echo '0 10 * * * cd ~/rowboat-V2/apps/cli && rowboatx --agent podcast_workflow --no-interactive >> ~/.rowboat/logs/podcast.log 2>&1') | crontab - - \`\`\` - -3. **Unschedule/remove an agent** - \`\`\`bash - crontab -l | grep -v '' | crontab - - \`\`\` - -## Common cron schedule patterns -- \`0 10 * * *\` - Daily at 10 AM -- \`0 */6 * * *\` - Every 6 hours -- \`0 9 * * 1\` - Every Monday at 9 AM -- \`*/30 * * * *\` - Every 30 minutes -`; - -export default skill; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 9411eb41..19fbc4e5 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,5 +1,6 @@ import { z, ZodType } from "zod"; import * as path from "path"; +import * as fs from "fs/promises"; import { execSync } from "child_process"; import { glob } from "glob"; import { executeCommand, executeCommandAbortable } from "./command-executor.js"; @@ -15,6 +16,14 @@ import { composioAccountsRepo } from "../../composio/repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js"; import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js"; import type { ToolContext } from "./exec-tool.js"; +import { generateText } from "ai"; +import { createProvider } from "../../models/models.js"; +import { IModelConfigRepo } from "../../models/repo.js"; +// Parser libraries are loaded dynamically inside parseFile.execute() +// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle. +// Import paths are computed so esbuild cannot statically resolve them. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _importDynamic = new Function('mod', 'return import(mod)') as (mod: string) => Promise; // eslint-disable-next-line @typescript-eslint/no-unused-vars const BuiltinToolsSchema = z.record(z.string(), z.object({ @@ -252,6 +261,26 @@ const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => { return allSlug; }; +const LLMPARSE_MIME_TYPES: Record = { + '.pdf': 'application/pdf', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.doc': 'application/msword', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xls': 'application/vnd.ms-excel', + '.csv': 'text/csv', + '.txt': 'text/plain', + '.html': 'text/html', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', +}; + export const BuiltinTools: z.infer = { loadSkill: { description: "Load a Rowboat skill definition into context by fetching its guidance string", @@ -690,6 +719,182 @@ export const BuiltinTools: z.infer = { }, }, + 'parseFile': { + description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.', + inputSchema: z.object({ + path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'), + }), + execute: async ({ path: filePath }: { path: string }) => { + try { + const fileName = path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + const supportedExts = ['.pdf', '.xlsx', '.xls', '.csv', '.docx']; + + if (!supportedExts.includes(ext)) { + return { + success: false, + error: `Unsupported file format '${ext}'. Supported formats: ${supportedExts.join(', ')}`, + }; + } + + // Read file as buffer — support both absolute and workspace-relative paths + let buffer: Buffer; + if (path.isAbsolute(filePath)) { + buffer = await fs.readFile(filePath); + } else { + const result = await workspace.readFile(filePath, 'base64'); + buffer = Buffer.from(result.data, 'base64'); + } + + if (ext === '.pdf') { + const { PDFParse } = await _importDynamic("pdf-parse"); + const parser = new PDFParse({ data: new Uint8Array(buffer) }); + try { + const textResult = await parser.getText(); + const infoResult = await parser.getInfo(); + return { + success: true, + fileName, + format: 'pdf', + content: textResult.text, + metadata: { + pages: textResult.total, + title: infoResult.info?.Title || undefined, + author: infoResult.info?.Author || undefined, + }, + }; + } finally { + await parser.destroy(); + } + } + + if (ext === '.xlsx' || ext === '.xls') { + const XLSX = await _importDynamic("xlsx"); + const workbook = XLSX.read(buffer, { type: 'buffer' }); + const sheets: Record = {}; + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + sheets[sheetName] = XLSX.utils.sheet_to_csv(sheet); + } + return { + success: true, + fileName, + format: ext === '.xlsx' ? 'xlsx' : 'xls', + content: Object.values(sheets).join('\n\n'), + metadata: { + sheetNames: workbook.SheetNames, + sheetCount: workbook.SheetNames.length, + }, + sheets, + }; + } + + if (ext === '.csv') { + const Papa = (await _importDynamic("papaparse")).default; + const text = buffer.toString('utf8'); + const parsed = Papa.parse(text, { header: true, skipEmptyLines: true }); + return { + success: true, + fileName, + format: 'csv', + content: text, + metadata: { + rowCount: parsed.data.length, + headers: parsed.meta.fields || [], + }, + data: parsed.data, + }; + } + + if (ext === '.docx') { + const mammoth = (await _importDynamic("mammoth")).default; + const docResult = await mammoth.extractRawText({ buffer }); + return { + success: true, + fileName, + format: 'docx', + content: docResult.value, + }; + } + + return { success: false, error: 'Unexpected error' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + + 'LLMParse': { + description: 'Send a file to the configured LLM as a multimodal attachment and ask it to extract content as markdown. Best for scanned PDFs, images with text, complex layouts, or any format where local parsing falls short. Supports documents (PDF, Word, Excel, PowerPoint, CSV, TXT, HTML) and images (PNG, JPG, GIF, WebP, SVG, BMP, TIFF).', + inputSchema: z.object({ + path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'), + prompt: z.string().optional().describe('Custom instruction for the LLM (defaults to "Convert this file to well-structured markdown.")'), + }), + execute: async ({ path: filePath, prompt }: { path: string; prompt?: string }) => { + try { + const fileName = path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeType = LLMPARSE_MIME_TYPES[ext]; + + if (!mimeType) { + return { + success: false, + error: `Unsupported file format '${ext}'. Supported formats: ${Object.keys(LLMPARSE_MIME_TYPES).join(', ')}`, + }; + } + + // Read file as buffer — support both absolute and workspace-relative paths + let buffer: Buffer; + if (path.isAbsolute(filePath)) { + buffer = await fs.readFile(filePath); + } else { + const result = await workspace.readFile(filePath, 'base64'); + buffer = Buffer.from(result.data, 'base64'); + } + + const base64 = buffer.toString('base64'); + + // Resolve model config from DI container + const modelConfigRepo = container.resolve('modelConfigRepo'); + const modelConfig = await modelConfigRepo.getConfig(); + const provider = createProvider(modelConfig.provider); + const model = provider.languageModel(modelConfig.model); + + const userPrompt = prompt || 'Convert this file to well-structured markdown.'; + + const response = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: userPrompt }, + { type: 'file', data: base64, mediaType: mimeType }, + ], + }, + ], + }); + + return { + success: true, + fileName, + format: ext.slice(1), + mimeType, + content: response.text, + usage: response.usage, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + analyzeAgent: { description: 'Read and analyze an agent file to understand its structure, tools, and configuration', inputSchema: z.object({ diff --git a/apps/x/packages/core/src/config/initConfigs.ts b/apps/x/packages/core/src/config/initConfigs.ts index 1c447e37..adfb8b24 100644 --- a/apps/x/packages/core/src/config/initConfigs.ts +++ b/apps/x/packages/core/src/config/initConfigs.ts @@ -1,6 +1,8 @@ import container from "../di/container.js"; import type { IModelConfigRepo } from "../models/repo.js"; import type { IMcpConfigRepo } from "../mcp/repo.js"; +import type { IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import type { IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { ensureSecurityConfig } from "./security.js"; /** @@ -11,10 +13,14 @@ export async function initConfigs(): Promise { // Resolve repos and explicitly call their ensureConfig methods const modelConfigRepo = container.resolve("modelConfigRepo"); const mcpConfigRepo = container.resolve("mcpConfigRepo"); + const agentScheduleRepo = container.resolve("agentScheduleRepo"); + const agentScheduleStateRepo = container.resolve("agentScheduleStateRepo"); await Promise.all([ modelConfigRepo.ensureConfig(), mcpConfigRepo.ensureConfig(), + agentScheduleRepo.ensureConfig(), + agentScheduleStateRepo.ensureState(), ensureSecurityConfig(), ]); } diff --git a/apps/x/packages/core/src/config/note_creation_config.ts b/apps/x/packages/core/src/config/note_creation_config.ts index da4b3d02..a86e8c00 100644 --- a/apps/x/packages/core/src/config/note_creation_config.ts +++ b/apps/x/packages/core/src/config/note_creation_config.ts @@ -28,6 +28,7 @@ function readConfig(): NoteCreationConfig { ? config.strictness : DEFAULT_STRICTNESS, configured: config.configured === true, + onboardingComplete: config.onboardingComplete === true, }; } catch { return { strictness: DEFAULT_STRICTNESS, configured: false }; @@ -83,7 +84,10 @@ export function markStrictnessConfigured(): void { * Set strictness and mark as configured in one operation. */ export function setStrictnessAndMarkConfigured(strictness: NoteCreationStrictness): void { - writeConfig({ strictness, configured: true }); + const config = readConfig(); + config.strictness = strictness; + config.configured = true; + writeConfig(config); } /** diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 2b3fd2d7..d02ca7e6 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -12,6 +12,8 @@ import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js"; import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; +import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -33,6 +35,8 @@ container.register({ oauthRepo: asClass(FSOAuthRepo).singleton(), clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(), granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(), + agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(), + agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index 1c954bdc..a1b7e135 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js'; import { autoConfigureStrictnessIfNeeded } from '../config/strictness_analyzer.js'; import { createRun, createMessage } from '../runs/runs.js'; import { bus } from '../runs/bus.js'; +import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { loadState, saveState, @@ -13,6 +14,7 @@ import { type GraphState, } from './graph_state.js'; import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js'; +import { limitEventItems } from './limit_event_items.js'; /** * Build obsidian-style knowledge graph by running topic extraction @@ -33,6 +35,15 @@ const SOURCE_FOLDERS = [ // Voice memos are now created directly in knowledge/Voice Memos// const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos'); +function extractPathFromToolInput(input: string): string | null { + try { + const parsed = JSON.parse(input) as { path?: string }; + return typeof parsed.path === 'string' ? parsed.path : null; + } catch { + return null; + } +} + /** * Get unprocessed voice memo files from knowledge/Voice Memos/ * Voice memos are created directly in this directory by the UI. @@ -148,7 +159,11 @@ async function waitForRunCompletion(runId: string): Promise { /** * Run note creation agent on a batch of files to extract entities and create/update notes */ -async function createNotesFromBatch(files: { path: string; content: string }[], batchNumber: number, knowledgeIndex: string): Promise { +async function createNotesFromBatch( + files: { path: string; content: string }[], + batchNumber: number, + knowledgeIndex: string +): Promise<{ runId: string; notesCreated: Set; notesModified: Set }> { // Ensure notes output directory exists if (!fs.existsSync(NOTES_OUTPUT_DIR)) { fs.mkdirSync(NOTES_OUTPUT_DIR, { recursive: true }); @@ -182,18 +197,155 @@ async function createNotesFromBatch(files: { path: string; content: string }[], message += `\n\n---\n\n`; }); + const notesCreated = new Set(); + const notesModified = new Set(); + + const unsubscribe = await bus.subscribe(run.id, async (event) => { + if (event.type !== "tool-invocation") { + return; + } + if (event.toolName !== "workspace-writeFile" && event.toolName !== "workspace-edit") { + return; + } + const toolPath = extractPathFromToolInput(event.input); + if (!toolPath) { + return; + } + if (event.toolName === "workspace-writeFile") { + notesCreated.add(toolPath); + } else if (event.toolName === "workspace-edit") { + notesModified.add(toolPath); + } + }); + await createMessage(run.id, message); // Wait for the run to complete await waitForRunCompletion(run.id); + unsubscribe(); - return run.id; + return { runId: run.id, notesCreated, notesModified }; } /** * Build the knowledge graph from all content files in the specified source directory * Only processes new or changed files based on state tracking */ +type BatchResult = { + processedFiles: string[]; + notesCreated: Set; + notesModified: Set; + hadError: boolean; +}; + +async function buildGraphWithFiles( + sourceDir: string, + filesToProcess: string[], + state: GraphState, + run?: ServiceRunContext +): Promise { + console.log(`[buildGraph] Starting build for directory: ${sourceDir}`); + + if (filesToProcess.length === 0) { + console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`); + return { processedFiles: [], notesCreated: new Set(), notesModified: new Set(), hadError: false }; + } + + console.log(`[buildGraph] Found ${filesToProcess.length} new/changed files to process in ${path.basename(sourceDir)}`); + + // Read file contents + const contentFiles = await readFileContents(filesToProcess); + + if (contentFiles.length === 0) { + console.log(`No files could be read from ${sourceDir}`); + return { processedFiles: [], notesCreated: new Set(), notesModified: new Set(), hadError: false }; + } + + const BATCH_SIZE = 10; // Reduced from 25 to 10 files per agent run for faster processing + const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE); + + console.log(`Processing ${contentFiles.length} files in ${totalBatches} batches (${BATCH_SIZE} files per batch)...`); + + const processedFiles: string[] = []; + const notesCreated = new Set(); + const notesModified = new Set(); + let hadError = false; + + // Process files in batches + for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) { + const batch = contentFiles.slice(i, i + BATCH_SIZE); + const batchNumber = Math.floor(i / BATCH_SIZE) + 1; + + try { + // Build fresh index before each batch to include notes from previous batches + console.log(`Building knowledge index for batch ${batchNumber}...`); + const indexStartTime = Date.now(); + const index = buildKnowledgeIndex(); + const indexForPrompt = formatIndexForPrompt(index); + const indexDuration = ((Date.now() - indexStartTime) / 1000).toFixed(2); + console.log(`Index built in ${indexDuration}s: ${index.people.length} people, ${index.organizations.length} orgs, ${index.projects.length} projects, ${index.topics.length} topics, ${index.other.length} other`); + + console.log(`Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`); + if (run) { + await serviceLogger.log({ + type: 'progress', + service: run.service, + runId: run.runId, + level: 'info', + message: `Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)`, + step: 'batch', + current: batchNumber, + total: totalBatches, + details: { filesInBatch: batch.length }, + }); + } + const agentStartTime = Date.now(); + const batchResult = await createNotesFromBatch(batch, batchNumber, indexForPrompt); + const agentDuration = ((Date.now() - agentStartTime) / 1000).toFixed(2); + console.log(`Batch ${batchNumber}/${totalBatches} complete in ${agentDuration}s`); + + for (const note of batchResult.notesCreated) { + notesCreated.add(note); + } + for (const note of batchResult.notesModified) { + notesModified.add(note); + } + + // Mark files in this batch as processed + for (const file of batch) { + markFileAsProcessed(file.path, state); + processedFiles.push(file.path); + } + + // Save state after each successful batch + // This ensures partial progress is saved even if later batches fail + saveState(state); + } catch (error) { + hadError = true; + console.error(`Error processing batch ${batchNumber}:`, error); + if (run) { + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: `Error processing batch ${batchNumber}`, + error: error instanceof Error ? error.message : String(error), + context: { batchNumber }, + }); + } + // Continue with next batch (without saving state for failed batch) + } + } + + // Update state with last build time and save + state.lastBuildTime = new Date().toISOString(); + saveState(state); + + console.log(`Knowledge graph build complete. Processed ${processedFiles.length} files.`); + return { processedFiles, notesCreated, notesModified, hadError }; +} + export async function buildGraph(sourceDir: string): Promise { console.log(`[buildGraph] Starting build for directory: ${sourceDir}`); @@ -210,62 +362,7 @@ export async function buildGraph(sourceDir: string): Promise { return; } - console.log(`[buildGraph] Found ${filesToProcess.length} new/changed files to process in ${path.basename(sourceDir)}`); - - // Read file contents - const contentFiles = await readFileContents(filesToProcess); - - if (contentFiles.length === 0) { - console.log(`No files could be read from ${sourceDir}`); - return; - } - - const BATCH_SIZE = 10; // Reduced from 25 to 10 files per agent run for faster processing - const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE); - - console.log(`Processing ${contentFiles.length} files in ${totalBatches} batches (${BATCH_SIZE} files per batch)...`); - - // Process files in batches - const processedFiles: string[] = []; - for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) { - const batch = contentFiles.slice(i, i + BATCH_SIZE); - const batchNumber = Math.floor(i / BATCH_SIZE) + 1; - - try { - // Build fresh index before each batch to include notes from previous batches - console.log(`Building knowledge index for batch ${batchNumber}...`); - const indexStartTime = Date.now(); - const index = buildKnowledgeIndex(); - const indexForPrompt = formatIndexForPrompt(index); - const indexDuration = ((Date.now() - indexStartTime) / 1000).toFixed(2); - console.log(`Index built in ${indexDuration}s: ${index.people.length} people, ${index.organizations.length} orgs, ${index.projects.length} projects, ${index.topics.length} topics, ${index.other.length} other`); - - console.log(`Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`); - const agentStartTime = Date.now(); - await createNotesFromBatch(batch, batchNumber, indexForPrompt); - const agentDuration = ((Date.now() - agentStartTime) / 1000).toFixed(2); - console.log(`Batch ${batchNumber}/${totalBatches} complete in ${agentDuration}s`); - - // Mark files in this batch as processed - for (const file of batch) { - markFileAsProcessed(file.path, state); - processedFiles.push(file.path); - } - - // Save state after each successful batch - // This ensures partial progress is saved even if later batches fail - saveState(state); - } catch (error) { - console.error(`Error processing batch ${batchNumber}:`, error); - // Continue with next batch (without saving state for failed batch) - } - } - - // Update state with last build time and save - state.lastBuildTime = new Date().toISOString(); - saveState(state); - - console.log(`Knowledge graph build complete. Processed ${processedFiles.length} files.`); + await buildGraphWithFiles(sourceDir, filesToProcess, state); } /** @@ -287,10 +384,39 @@ async function processVoiceMemosForKnowledge(): Promise { console.log(`[GraphBuilder] Processing ${unprocessedFiles.length} voice memo transcripts for entity extraction...`); console.log(`[GraphBuilder] Files to process: ${unprocessedFiles.map(f => path.basename(f)).join(', ')}`); + const run = await serviceLogger.startRun({ + service: 'voice_memo', + message: `Processing ${unprocessedFiles.length} voice memo${unprocessedFiles.length === 1 ? '' : 's'}`, + trigger: 'timer', + }); + + const relativeVoiceMemos = unprocessedFiles.map(filePath => path.relative(WorkDir, filePath)); + const limitedVoiceMemos = limitEventItems(relativeVoiceMemos); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${unprocessedFiles.length} new voice memo${unprocessedFiles.length === 1 ? '' : 's'}`, + counts: { voiceMemos: unprocessedFiles.length }, + items: limitedVoiceMemos.items, + truncated: limitedVoiceMemos.truncated, + }); + // Read the files const contentFiles = await readFileContents(unprocessedFiles); if (contentFiles.length === 0) { + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'info', + message: 'No voice memos could be read', + durationMs: Date.now() - run.startedAt, + outcome: 'error', + summary: { processedFiles: 0 }, + }); return false; } @@ -298,6 +424,10 @@ async function processVoiceMemosForKnowledge(): Promise { const BATCH_SIZE = 10; const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE); + const notesCreated = new Set(); + const notesModified = new Set(); + let hadError = false; + for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) { const batch = contentFiles.slice(i, i + BATCH_SIZE); const batchNumber = Math.floor(i / BATCH_SIZE) + 1; @@ -309,9 +439,27 @@ async function processVoiceMemosForKnowledge(): Promise { const indexForPrompt = formatIndexForPrompt(index); console.log(`[GraphBuilder] Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`); - await createNotesFromBatch(batch, batchNumber, indexForPrompt); + await serviceLogger.log({ + type: 'progress', + service: run.service, + runId: run.runId, + level: 'info', + message: `Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)`, + step: 'batch', + current: batchNumber, + total: totalBatches, + details: { filesInBatch: batch.length }, + }); + const batchResult = await createNotesFromBatch(batch, batchNumber, indexForPrompt); console.log(`[GraphBuilder] Batch ${batchNumber}/${totalBatches} complete`); + for (const note of batchResult.notesCreated) { + notesCreated.add(note); + } + for (const note of batchResult.notesModified) { + notesModified.add(note); + } + // Mark files as processed for (const file of batch) { markFileAsProcessed(file.path, state); @@ -320,7 +468,17 @@ async function processVoiceMemosForKnowledge(): Promise { // Save state after each batch saveState(state); } catch (error) { + hadError = true; console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error); + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: `Error processing voice memo batch ${batchNumber}`, + error: error instanceof Error ? error.message : String(error), + context: { batchNumber }, + }); } } @@ -328,6 +486,21 @@ async function processVoiceMemosForKnowledge(): Promise { state.lastBuildTime = new Date().toISOString(); saveState(state); + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: hadError ? 'error' : 'info', + message: `Voice memos processed: ${contentFiles.length} files, ${notesCreated.size} created, ${notesModified.size} updated`, + durationMs: Date.now() - run.startedAt, + outcome: hadError ? 'error' : 'ok', + summary: { + processedFiles: contentFiles.length, + notesCreated: notesCreated.size, + notesModified: notesModified.size, + }, + }); + return true; } @@ -352,6 +525,11 @@ async function processAllSources(): Promise { console.error('[GraphBuilder] Error processing voice memos:', error); } + const state = loadState(); + const folderChanges: { folder: string; sourceDir: string; files: string[] }[] = []; + const countsByFolder: Record = {}; + const allFiles: string[] = []; + for (const folder of SOURCE_FOLDERS) { const sourceDir = path.join(WorkDir, folder); @@ -362,14 +540,13 @@ async function processAllSources(): Promise { } try { - // Quick check if there are any files to process before doing the full build - const state = loadState(); const filesToProcess = getFilesToProcess(sourceDir, state); if (filesToProcess.length > 0) { console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`); - await buildGraph(sourceDir); - anyFilesProcessed = true; + folderChanges.push({ folder, sourceDir, files: filesToProcess }); + countsByFolder[folder] = filesToProcess.length; + allFiles.push(...filesToProcess); } } catch (error) { console.error(`[GraphBuilder] Error processing ${folder}:`, error); @@ -377,6 +554,63 @@ async function processAllSources(): Promise { } } + if (allFiles.length > 0) { + const run = await serviceLogger.startRun({ + service: 'graph', + message: 'Syncing knowledge graph', + trigger: 'timer', + config: { sources: SOURCE_FOLDERS }, + }); + + const relativeFiles = allFiles.map(filePath => path.relative(WorkDir, filePath)); + const limitedFiles = limitEventItems(relativeFiles); + const foldersList = Object.keys(countsByFolder).join(', '); + const folderMessage = foldersList ? ` across ${foldersList}` : ''; + + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${allFiles.length} changed file${allFiles.length === 1 ? '' : 's'}${folderMessage}`, + counts: countsByFolder, + items: limitedFiles.items, + truncated: limitedFiles.truncated, + }); + + const notesCreated = new Set(); + const notesModified = new Set(); + const processedFiles: string[] = []; + let hadError = false; + + for (const entry of folderChanges) { + const result = await buildGraphWithFiles(entry.sourceDir, entry.files, state, run); + result.processedFiles.forEach(file => processedFiles.push(file)); + result.notesCreated.forEach(note => notesCreated.add(note)); + result.notesModified.forEach(note => notesModified.add(note)); + if (result.hadError) { + hadError = true; + } + } + + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: hadError ? 'error' : 'info', + message: `Graph sync complete: ${processedFiles.length} files, ${notesCreated.size} created, ${notesModified.size} updated`, + durationMs: Date.now() - run.startedAt, + outcome: hadError ? 'error' : 'ok', + summary: { + processedFiles: processedFiles.length, + notesCreated: notesCreated.size, + notesModified: notesModified.size, + }, + }); + + anyFilesProcessed = true; + } + if (!anyFilesProcessed) { console.log('[GraphBuilder] No new content to process'); } else { diff --git a/apps/x/packages/core/src/knowledge/granola/sync.ts b/apps/x/packages/core/src/knowledge/granola/sync.ts index 6c736085..f03a8e06 100644 --- a/apps/x/packages/core/src/knowledge/granola/sync.ts +++ b/apps/x/packages/core/src/knowledge/granola/sync.ts @@ -4,6 +4,8 @@ import { homedir } from 'os'; import { WorkDir } from '../../config/config.js'; import container from '../../di/container.js'; import { IGranolaConfigRepo } from './repo.js'; +import { serviceLogger } from '../../services/service_logger.js'; +import { limitEventItems } from '../limit_event_items.js'; import { GetDocumentsResponse, SyncState, @@ -325,102 +327,169 @@ function documentToMarkdown(doc: Document): string { 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(); - - let newCount = 0; - let updatedCount = 0; - let offset = 0; - let hasMore = true; - // Fetch documents with pagination - while (hasMore) { - // Delay before API call (except first) - if (offset > 0) { - await sleep(API_DELAY_MS); + let runId: string | null = null; + let runStartedAt = 0; + const ensureRun = async () => { + if (!runId) { + const run = await serviceLogger.startRun({ + service: 'granola', + message: 'Syncing Granola notes', + trigger: 'timer', + }); + runId = run.runId; + runStartedAt = run.startedAt; + } + }; + + try { + // Check if enabled + const granolaRepo = container.resolve('granolaConfigRepo'); + const config = await granolaRepo.getConfig(); + if (!config.enabled) { + console.log('[Granola] Sync disabled in config'); + return; } - const docsResponse = await getDocuments(accessToken, MAX_BATCH_SIZE, offset); - if (!docsResponse) { - console.log('[Granola] Failed to fetch documents'); - break; + // Extract access token + const accessToken = extractAccessToken(); + if (!accessToken) { + console.log('[Granola] No access token available'); + return; } - if (docsResponse.docs.length === 0) { - console.log('[Granola] No more documents to fetch'); - hasMore = false; - break; - } + // Ensure sync directory exists + ensureDir(SYNC_DIR); - // Process each document - for (const doc of docsResponse.docs) { - const docUpdatedAt = doc.updated_at || doc.created_at; - const lastSyncedAt = state.syncedDocs[doc.id]; + // Load state + const state = loadState(); - // Check if needs sync (new or updated) - const needsSync = !lastSyncedAt || lastSyncedAt !== docUpdatedAt; + let newCount = 0; + let updatedCount = 0; + let offset = 0; + let hasMore = true; + const changedTitles: string[] = []; - if (!needsSync) { - continue; + // Fetch documents with pagination + while (hasMore) { + // Delay before API call (except first) + if (offset > 0) { + await sleep(API_DELAY_MS); } - // 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(SYNC_DIR, filename); - - fs.writeFileSync(filePath, markdown); - - if (lastSyncedAt) { - console.log(`[Granola] Updated: ${filename}`); - updatedCount++; - } else { - console.log(`[Granola] Saved: ${filename}`); - newCount++; + const docsResponse = await getDocuments(accessToken, MAX_BATCH_SIZE, offset); + if (!docsResponse) { + console.log('[Granola] Failed to fetch documents'); + break; } - // Update state - state.syncedDocs[doc.id] = docUpdatedAt; + if (docsResponse.docs.length === 0) { + console.log('[Granola] No more documents to fetch'); + hasMore = false; + break; + } + + // 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; + } + + await ensureRun(); + const docTitle = doc.title || 'Untitled'; + changedTitles.push(docTitle); + + // Convert to markdown and save + const markdown = documentToMarkdown(doc); + const filename = `${doc.id}_${cleanFilename(docTitle)}.md`; + const filePath = path.join(SYNC_DIR, 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; + } + + // Move to next page + offset += docsResponse.docs.length; + + // Stop if we got fewer docs than requested (last page) + if (docsResponse.docs.length < MAX_BATCH_SIZE) { + hasMore = false; + } } - // Move to next page - offset += docsResponse.docs.length; + // Save state + state.lastSyncDate = new Date().toISOString(); + saveState(state); - // Stop if we got fewer docs than requested (last page) - if (docsResponse.docs.length < MAX_BATCH_SIZE) { - hasMore = false; + console.log(`[Granola] Sync complete: ${newCount} new, ${updatedCount} updated`); + + if (runId) { + const totalChanges = newCount + updatedCount; + const limitedTitles = limitEventItems(changedTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: 'granola', + runId, + level: 'info', + message: `Granola updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + counts: { newNotes: newCount, updatedNotes: updatedCount }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'granola', + runId, + level: 'info', + message: `Granola sync complete: ${newCount} new, ${updatedCount} updated`, + durationMs: Date.now() - runStartedAt, + outcome: 'ok', + summary: { newNotes: newCount, updatedNotes: updatedCount }, + }); } - } - // 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) { - // Graph building is now handled by the independent graph builder service + // Build knowledge graph if there were changes + if (newCount > 0 || updatedCount > 0) { + // Graph building is now handled by the independent graph builder service + } + } catch (error) { + console.error('[Granola] Error in sync:', error); + if (runId) { + await serviceLogger.log({ + type: 'error', + service: 'granola', + runId, + level: 'error', + message: 'Granola sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'granola', + runId, + level: 'error', + message: 'Granola sync failed', + durationMs: Date.now() - runStartedAt, + outcome: 'error', + }); + } + throw error; } } @@ -443,4 +512,3 @@ export async function init(): Promise { await interruptibleSleep(SYNC_INTERVAL_MS); } } - diff --git a/apps/x/packages/core/src/knowledge/limit_event_items.ts b/apps/x/packages/core/src/knowledge/limit_event_items.ts new file mode 100644 index 00000000..b3935112 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/limit_event_items.ts @@ -0,0 +1,8 @@ +export const MAX_EVENT_ITEMS = 50; + +export function limitEventItems(items: string[], max: number = MAX_EVENT_ITEMS): { items: string[]; truncated: boolean } { + if (items.length <= max) { + return { items, truncated: false }; + } + return { items: items.slice(0, max), truncated: true }; +} diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index 46ec2e1e..cf2f6387 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -5,6 +5,8 @@ import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; +import { serviceLogger } from '../services/service_logger.js'; +import { limitEventItems } from './limit_event_items.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -14,7 +16,6 @@ const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', 'https://www.googleapis.com/auth/drive.readonly' ]; - const nhm = new NodeHtmlMarkdown(); // --- Wake Signal for Immediate Sync Trigger --- @@ -49,10 +50,11 @@ function cleanFilename(name: string): string { // --- Sync Logic --- -function cleanUpOldFiles(currentEventIds: Set, syncDir: string) { - if (!fs.existsSync(syncDir)) return; +function cleanUpOldFiles(currentEventIds: Set, syncDir: string): string[] { + if (!fs.existsSync(syncDir)) return []; const files = fs.readdirSync(syncDir); + const deleted: string[] = []; for (const filename of files) { if (filename === 'sync_state.json') continue; @@ -79,36 +81,49 @@ function cleanUpOldFiles(currentEventIds: Set, syncDir: string) { try { fs.unlinkSync(path.join(syncDir, filename)); console.log(`Removed old/out-of-window file: ${filename}`); + deleted.push(filename); } catch (e) { console.error(`Error deleting file ${filename}:`, e); } } } + return deleted; } -async function saveEvent(event: cal.Schema$Event, syncDir: string): Promise { +async function saveEvent(event: cal.Schema$Event, syncDir: string): Promise<{ changed: boolean; isNew: boolean; title: string }> { const eventId = event.id; - if (!eventId) return false; + if (!eventId) return { changed: false, isNew: false, title: 'Unknown' }; const filePath = path.join(syncDir, `${eventId}.json`); + const content = JSON.stringify(event, null, 2); + const exists = fs.existsSync(filePath); try { - fs.writeFileSync(filePath, JSON.stringify(event, null, 2)); - return true; + if (exists) { + const existing = fs.readFileSync(filePath, 'utf-8'); + if (existing === content) { + return { changed: false, isNew: false, title: event.summary || eventId }; + } + } + + fs.writeFileSync(filePath, content); + return { changed: true, isNew: !exists, title: event.summary || eventId }; } catch (e) { console.error(`Error saving event ${eventId}:`, e); - return false; + return { changed: false, isNew: false, title: event.summary || eventId }; } } -async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, syncDir: string) { - if (!event.attachments || event.attachments.length === 0) return; +async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, syncDir: string): Promise { + if (!event.attachments || event.attachments.length === 0) return 0; const eventId = event.id; const eventTitle = event.summary || 'Untitled'; const eventDate = event.start?.dateTime || event.start?.date || 'Unknown'; const organizer = event.organizer?.email || 'Unknown'; + let savedCount = 0; + for (const att of event.attachments) { // We only care about Google Docs if (att.mimeType === 'application/vnd.google-apps.document') { @@ -145,12 +160,14 @@ async function processAttachments(drive: drive.Drive, event: cal.Schema$Event, s ].join('\n'); fs.writeFileSync(filePath, frontmatter + md); + savedCount++; console.log(`Synced Note: ${att.title} for event ${eventTitle}`); } catch (e) { console.error(`Failed to download note ${att.title}:`, e); } } } + return savedCount; } async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackDays: number) { @@ -167,6 +184,26 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD const calendar = google.calendar({ version: 'v3', auth }); const drive = google.drive({ version: 'v3', auth }); + let runId: string | null = null; + let runStartedAt = 0; + let newCount = 0; + let updatedCount = 0; + let deletedCount = 0; + let attachmentCount = 0; + const changedTitles: string[] = []; + + const ensureRun = async () => { + if (!runId) { + const run = await serviceLogger.startRun({ + service: 'calendar', + message: 'Syncing calendar', + trigger: 'timer', + }); + runId = run.runId; + runStartedAt = run.startedAt; + } + }; + try { const res = await calendar.events.list({ calendarId: 'primary', @@ -185,17 +222,90 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD console.log(`Found ${events.length} events.`); for (const event of events) { if (event.id) { - await saveEvent(event, syncDir); - await processAttachments(drive, event, syncDir); + const result = await saveEvent(event, syncDir); + const attachmentsSaved = await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); + + if (result.changed) { + await ensureRun(); + changedTitles.push(result.title); + if (result.isNew) { + newCount++; + } else { + updatedCount++; + } + } + + if (attachmentsSaved > 0) { + await ensureRun(); + attachmentCount += attachmentsSaved; + } } } } - cleanUpOldFiles(currentEventIds, syncDir); + const deletedFiles = cleanUpOldFiles(currentEventIds, syncDir); + if (deletedFiles.length > 0) { + await ensureRun(); + deletedCount = deletedFiles.length; + } + + if (runId) { + const totalChanges = newCount + updatedCount + deletedCount + attachmentCount; + const limitedTitles = limitEventItems(changedTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: 'calendar', + runId, + level: 'info', + message: `Calendar updates: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + counts: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + attachments: attachmentCount, + }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'calendar', + runId, + level: 'info', + message: `Calendar sync complete: ${totalChanges} change${totalChanges === 1 ? '' : 's'}`, + durationMs: Date.now() - runStartedAt, + outcome: 'ok', + summary: { + newEvents: newCount, + updatedEvents: updatedCount, + deletedFiles: deletedCount, + attachments: attachmentCount, + }, + }); + } } catch (error) { console.error("An error occurred during calendar sync:", error); + if (runId) { + await serviceLogger.log({ + type: 'error', + service: 'calendar', + runId, + level: 'error', + message: 'Calendar sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: 'calendar', + runId, + level: 'error', + message: 'Calendar sync failed', + durationMs: Date.now() - runStartedAt, + outcome: 'error', + }); + } // If 401, clear tokens to force re-auth next run const e = error as { response?: { status?: number } }; if (e.response?.status === 401) { @@ -256,4 +366,4 @@ export async function init() { console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); await interruptibleSleep(SYNC_INTERVAL_MS); } -} \ No newline at end of file +} diff --git a/apps/x/packages/core/src/knowledge/sync_fireflies.ts b/apps/x/packages/core/src/knowledge/sync_fireflies.ts index e65529f6..5e0cca07 100644 --- a/apps/x/packages/core/src/knowledge/sync_fireflies.ts +++ b/apps/x/packages/core/src/knowledge/sync_fireflies.ts @@ -2,6 +2,8 @@ import fs from 'fs'; import path from 'path'; import { WorkDir } from '../config/config.js'; import { FirefliesClientFactory } from './fireflies-client-factory.js'; +import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; +import { limitEventItems } from './limit_event_items.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'fireflies_transcripts'); @@ -414,6 +416,8 @@ async function syncMeetings() { console.log(`[Fireflies] Fetching meetings from ${fromDateStr} to ${toDateStr}...`); + let run: ServiceRunContext | null = null; + try { // Step 1: Get list of transcripts with rate limiting const transcriptsResult = await callWithRateLimit( @@ -456,6 +460,31 @@ async function syncMeetings() { } console.log(`[Fireflies] Found ${meetings.length} transcripts`); + + const newMeetings = meetings.filter(m => m.id && !syncedIds.has(m.id)); + if (newMeetings.length === 0) { + console.log('[Fireflies] No new transcripts to sync'); + saveState(toDateStr, Array.from(syncedIds), new Date().toISOString()); + return; + } + + run = await serviceLogger.startRun({ + service: 'fireflies', + message: 'Syncing Fireflies transcripts', + trigger: 'timer', + }); + const meetingTitles = newMeetings.map(m => m.title || m.id); + const limitedTitles = limitEventItems(meetingTitles); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${newMeetings.length} new transcript${newMeetings.length === 1 ? '' : 's'}`, + counts: { transcripts: newMeetings.length }, + items: limitedTitles.items, + truncated: limitedTitles.truncated, + }); // Step 2: Fetch and save each transcript let newCount = 0; @@ -559,9 +588,39 @@ async function syncMeetings() { // Save state with updated timestamp saveState(toDateStr, Array.from(syncedIds), new Date().toISOString()); + + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'info', + message: `Fireflies sync complete: ${newCount} transcript${newCount === 1 ? '' : 's'}`, + durationMs: Date.now() - run.startedAt, + outcome: newCount > 0 ? 'ok' : 'idle', + summary: { transcripts: newCount }, + }); } catch (error) { console.error('[Fireflies] Error during sync:', error); + if (run) { + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: 'Fireflies sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: 'error', + message: 'Fireflies sync failed', + durationMs: Date.now() - run.startedAt, + outcome: 'error', + }); + } // Check if it's an auth error const errorMessage = error instanceof Error ? error.message : String(error); @@ -600,4 +659,3 @@ export async function init() { await interruptibleSleep(SYNC_INTERVAL_MS); } } - diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index d1782a96..de73c016 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -5,12 +5,13 @@ import { NodeHtmlMarkdown } from 'node-html-markdown' import { OAuth2Client } from 'google-auth-library'; import { WorkDir } from '../config/config.js'; import { GoogleClientFactory } from './google-client-factory.js'; +import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; +import { limitEventItems } from './limit_event_items.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; - const nhm = new NodeHtmlMarkdown(); // --- Wake Signal for Immediate Sync Trigger --- @@ -192,39 +193,120 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str console.log(`Performing full sync of last ${lookbackDays} days...`); const gmail = google.gmail({ version: 'v1', auth }); - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - lookbackDays); - const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); + let run: ServiceRunContext | null = null; + const ensureRun = async () => { + if (!run) { + run = await serviceLogger.startRun({ + service: 'gmail', + message: 'Syncing Gmail', + trigger: 'timer', + }); + } + }; - // Get History ID - const profile = await gmail.users.getProfile({ userId: 'me' }); - const currentHistoryId = profile.data.historyId!; + try { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - lookbackDays); + const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); - let pageToken: string | undefined; - do { - const res = await gmail.users.threads.list({ - userId: 'me', - q: `after:${dateQuery}`, - pageToken + // Get History ID + const profile = await gmail.users.getProfile({ userId: 'me' }); + const currentHistoryId = profile.data.historyId!; + + const threadIds: string[] = []; + let pageToken: string | undefined; + do { + const res = await gmail.users.threads.list({ + userId: 'me', + q: `after:${dateQuery}`, + pageToken + }); + + const threads = res.data.threads; + if (threads) { + for (const thread of threads) { + if (thread.id) { + threadIds.push(thread.id); + } + } + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + if (threadIds.length === 0) { + saveState(currentHistoryId, stateFile); + console.log("Full sync complete. No threads found."); + return; + } + + await ensureRun(); + const limitedThreads = limitEventItems(threadIds); + await serviceLogger.log({ + type: 'changes_identified', + service: run!.service, + runId: run!.runId, + level: 'info', + message: `Found ${threadIds.length} thread${threadIds.length === 1 ? '' : 's'} to sync`, + counts: { threads: threadIds.length }, + items: limitedThreads.items, + truncated: limitedThreads.truncated, }); - const threads = res.data.threads; - if (threads) { - for (const thread of threads) { - await processThread(auth, thread.id!, syncDir, attachmentsDir); - } + for (const threadId of threadIds) { + await processThread(auth, threadId, syncDir, attachmentsDir); } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); - saveState(currentHistoryId, stateFile); - console.log("Full sync complete."); + saveState(currentHistoryId, stateFile); + await serviceLogger.log({ + type: 'run_complete', + service: run!.service, + runId: run!.runId, + level: 'info', + message: `Gmail sync complete: ${threadIds.length} thread${threadIds.length === 1 ? '' : 's'}`, + durationMs: Date.now() - run!.startedAt, + outcome: 'ok', + summary: { threads: threadIds.length }, + }); + console.log("Full sync complete."); + } catch (error) { + console.error("Error during full sync:", error); + await ensureRun(); + await serviceLogger.log({ + type: 'error', + service: run!.service, + runId: run!.runId, + level: 'error', + message: 'Gmail sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: run!.service, + runId: run!.runId, + level: 'error', + message: 'Gmail sync failed', + durationMs: Date.now() - run!.startedAt, + outcome: 'error', + }); + throw error; + } } async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { console.log(`Checking updates since historyId ${startHistoryId}...`); const gmail = google.gmail({ version: 'v1', auth }); + let run: ServiceRunContext | null = null; + const ensureRun = async () => { + if (!run) { + run = await serviceLogger.startRun({ + service: 'gmail', + message: 'Syncing Gmail', + trigger: 'timer', + }); + } + }; + try { const res = await gmail.users.history.list({ userId: 'me', @@ -253,25 +335,74 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: } } - for (const tid of threadIds) { + if (threadIds.size === 0) { + const profile = await gmail.users.getProfile({ userId: 'me' }); + saveState(profile.data.historyId!, stateFile); + return; + } + + await ensureRun(); + const threadIdList = Array.from(threadIds); + const limitedThreads = limitEventItems(threadIdList); + await serviceLogger.log({ + type: 'changes_identified', + service: run!.service, + runId: run!.runId, + level: 'info', + message: `Found ${threadIdList.length} new thread${threadIdList.length === 1 ? '' : 's'}`, + counts: { threads: threadIdList.length }, + items: limitedThreads.items, + truncated: limitedThreads.truncated, + }); + + for (const tid of threadIdList) { await processThread(auth, tid, syncDir, attachmentsDir); } const profile = await gmail.users.getProfile({ userId: 'me' }); saveState(profile.data.historyId!, stateFile); + await serviceLogger.log({ + type: 'run_complete', + service: run!.service, + runId: run!.runId, + level: 'info', + message: `Gmail sync complete: ${threadIdList.length} thread${threadIdList.length === 1 ? '' : 's'}`, + durationMs: Date.now() - run!.startedAt, + outcome: 'ok', + summary: { threads: threadIdList.length }, + }); } catch (error: unknown) { const e = error as { response?: { status?: number } }; if (e.response?.status === 404) { console.log("History ID expired. Falling back to full sync."); await fullSync(auth, syncDir, attachmentsDir, stateFile, lookbackDays); - } else { - console.error("Error during partial sync:", error); - // If 401, clear tokens to force re-auth next run - if (e.response?.status === 401) { - console.log("401 Unauthorized, clearing cache"); - GoogleClientFactory.clearCache(); - } + return; + } + + console.error("Error during partial sync:", error); + await ensureRun(); + await serviceLogger.log({ + type: 'error', + service: run!.service, + runId: run!.runId, + level: 'error', + message: 'Gmail sync error', + error: error instanceof Error ? error.message : String(error), + }); + await serviceLogger.log({ + type: 'run_complete', + service: run!.service, + runId: run!.runId, + level: 'error', + message: 'Gmail sync failed', + durationMs: Date.now() - run!.startedAt, + outcome: 'error', + }); + // If 401, clear tokens to force re-auth next run + if (e.response?.status === 401) { + console.log("401 Unauthorized, clearing cache"); + GoogleClientFactory.clearCache(); } } } diff --git a/apps/x/packages/core/src/services/service_bus.ts b/apps/x/packages/core/src/services/service_bus.ts new file mode 100644 index 00000000..66b7c662 --- /dev/null +++ b/apps/x/packages/core/src/services/service_bus.ts @@ -0,0 +1,24 @@ +import type { ServiceEventType } from "@x/shared/dist/service-events.js"; + +type ServiceEventHandler = (event: ServiceEventType) => Promise | void; + +export class ServiceBus { + private subscribers: ServiceEventHandler[] = []; + + async publish(event: ServiceEventType): Promise { + const pending = this.subscribers.map(async (handler) => handler(event)); + await Promise.all(pending); + } + + async subscribe(handler: ServiceEventHandler): Promise<() => void> { + this.subscribers.push(handler); + return () => { + const idx = this.subscribers.indexOf(handler); + if (idx >= 0) { + this.subscribers.splice(idx, 1); + } + }; + } +} + +export const serviceBus = new ServiceBus(); diff --git a/apps/x/packages/core/src/services/service_logger.ts b/apps/x/packages/core/src/services/service_logger.ts new file mode 100644 index 00000000..886cf66d --- /dev/null +++ b/apps/x/packages/core/src/services/service_logger.ts @@ -0,0 +1,118 @@ +import fs from "fs"; +import fsp from "fs/promises"; +import path from "path"; +import { WorkDir } from "../config/config.js"; +import { IdGen } from "../application/lib/id-gen.js"; +import type { ServiceEventType } from "@x/shared/dist/service-events.js"; +import { serviceBus } from "./service_bus.js"; + +type ServiceNameType = ServiceEventType["service"]; +type DistributiveOmit = T extends unknown ? Omit : never; +type ServiceEventInput = DistributiveOmit; + +const LOG_DIR = path.join(WorkDir, "logs"); +const LOG_FILE = path.join(LOG_DIR, "services.jsonl"); +const MAX_LOG_BYTES = 10 * 1024 * 1024; + +export type ServiceRunContext = { + runId: string; + service: ServiceNameType; + startedAt: number; +}; + +function safeTimestampForFile(ts: string): string { + return ts.replace(/[:.]/g, "-"); +} + +export class ServiceLogger { + private idGen = new IdGen(); + private stream: fs.WriteStream | null = null; + private currentSize = 0; + private initialized = false; + private writeQueue: Promise = Promise.resolve(); + + private async ensureReady(): Promise { + if (this.initialized) return; + await fsp.mkdir(LOG_DIR, { recursive: true }); + try { + const stats = await fsp.stat(LOG_FILE); + this.currentSize = stats.size; + } catch { + this.currentSize = 0; + } + this.stream = fs.createWriteStream(LOG_FILE, { flags: "a", encoding: "utf8" }); + this.initialized = true; + } + + private async rotateIfNeeded(nextBytes: number): Promise { + if (this.currentSize + nextBytes <= MAX_LOG_BYTES) return; + if (this.stream) { + const stream = this.stream; + this.stream = null; + await new Promise((resolve) => { + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(); + }; + stream.once("error", done); + stream.end(done); + }); + } + const ts = safeTimestampForFile(new Date().toISOString()); + const rotatedPath = path.join(LOG_DIR, `services.${ts}.jsonl`); + try { + await fsp.rename(LOG_FILE, rotatedPath); + } catch { + // Ignore if file doesn't exist or rename fails + } + this.currentSize = 0; + this.stream = fs.createWriteStream(LOG_FILE, { flags: "a", encoding: "utf8" }); + } + + async log(event: ServiceEventInput): Promise { + const payload = { + ...event, + ts: new Date().toISOString(), + } as ServiceEventType; + const line = JSON.stringify(payload) + "\n"; + const bytes = Buffer.byteLength(line, "utf8"); + + this.writeQueue = this.writeQueue.then(async () => { + await this.ensureReady(); + await this.rotateIfNeeded(bytes); + this.stream?.write(line); + this.currentSize += bytes; + try { + await serviceBus.publish(payload); + } catch { + // Ignore publish errors to avoid blocking log writes + } + }); + + return this.writeQueue; + } + + async startRun(opts: { + service: ServiceNameType; + message: string; + trigger?: "timer" | "manual" | "startup"; + config?: Record; + }): Promise { + const runId = `${opts.service}_${await this.idGen.next()}`; + const startedAt = Date.now(); + await this.log({ + type: "run_start", + service: opts.service, + runId, + level: "info", + message: opts.message, + trigger: opts.trigger, + config: opts.config, + }); + return { runId, service: opts.service, startedAt }; + } +} + +export const serviceLogger = new ServiceLogger(); diff --git a/apps/x/packages/shared/src/agent-schedule-state.ts b/apps/x/packages/shared/src/agent-schedule-state.ts new file mode 100644 index 00000000..09e9037c --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule-state.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +// "triggered" is terminal state for once-schedules (will not run again) +export const AgentScheduleStatus = z.enum(["scheduled", "running", "finished", "failed", "triggered"]); + +export const AgentScheduleStateEntry = z.object({ + status: AgentScheduleStatus, + startedAt: z.string().nullable(), // When current run started (for timeout detection) + lastRunAt: z.string().nullable(), // ISO 8601 local datetime + nextRunAt: z.string().nullable(), // ISO 8601 local datetime + lastError: z.string().nullable(), + runCount: z.number().default(0), +}); + +export const AgentScheduleState = z.object({ + agents: z.record(z.string(), AgentScheduleStateEntry), +}); diff --git a/apps/x/packages/shared/src/agent-schedule.ts b/apps/x/packages/shared/src/agent-schedule.ts new file mode 100644 index 00000000..62184083 --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule.ts @@ -0,0 +1,44 @@ +import z from "zod"; + +// Cron schedule - runs at exact times defined by cron expression. +// Examples: +// - Every 5 minutes: "*/5 * * * *" +// - Everyday at 8am: "0 8 * * *" +// - Every Monday at 9am: "0 9 * * 1" +export const CronSchedule = z.object({ + type: z.literal("cron"), + expression: z.string(), +}); + +// Window schedule - runs once during a time window. +// The agent will run once at a random time within the specified window. +// Examples: +// - Daily between 8am and 10am: cron="0 0 * * *", startTime="08:00", endTime="10:00" +// - Weekly on Monday between 9am-12pm: cron="0 0 * * 1", startTime="09:00", endTime="12:00" +export const WindowSchedule = z.object({ + type: z.literal("window"), + cron: z.string(), // Base frequency cron expression + startTime: z.string(), // "HH:MM" format + endTime: z.string(), // "HH:MM" format +}); + +// Once schedule - runs exactly once at a specific time, then never again. +// Examples: +// - Run once at specific datetime: runAt="2024-02-05T10:30:00" +export const OnceSchedule = z.object({ + type: z.literal("once"), + runAt: z.string(), // ISO 8601 datetime (local time, e.g., "2024-02-05T10:30:00") +}); + +export const ScheduleDefinition = z.union([CronSchedule, WindowSchedule, OnceSchedule]); + +export const AgentScheduleEntry = z.object({ + schedule: ScheduleDefinition, + enabled: z.boolean().optional().default(true), + startingMessage: z.string().optional(), // Message sent to agent when run starts (defaults to "go") + description: z.string().optional(), // Brief description of what the agent does (for UI display) +}); + +export const AgentScheduleConfig = z.object({ + agents: z.record(z.string(), AgentScheduleEntry), +}); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 3bca8969..4f10fc82 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -4,4 +4,7 @@ export * as ipc from './ipc.js'; export * as models from './models.js'; export * as workspace from './workspace.js'; export * as mcp from './mcp.js'; +export * as agentSchedule from './agent-schedule.js'; +export * as agentScheduleState from './agent-schedule-state.js'; +export * as serviceEvents from './service-events.js'; export { PrefixLogger }; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 2835a90b..175c409e 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -3,6 +3,9 @@ import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, Work import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; import { LlmModelConfig } from './models.js'; +import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; +import { AgentScheduleState } from './agent-schedule-state.js'; +import { ServiceEvent } from './service-events.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -174,6 +177,10 @@ const ipcSchemas = { req: z.null(), res: z.null(), }, + 'services:events': { + req: ServiceEvent, + res: z.null(), + }, 'models:list': { req: z.null(), res: z.object({ @@ -353,6 +360,41 @@ const ipcSchemas = { }), res: z.null(), }, + // Agent schedule channels + 'agent-schedule:getConfig': { + req: z.null(), + res: AgentScheduleConfig, + }, + 'agent-schedule:getState': { + req: z.null(), + res: AgentScheduleState, + }, + 'agent-schedule:updateAgent': { + req: z.object({ + agentName: z.string(), + entry: AgentScheduleEntry, + }), + res: z.object({ + success: z.literal(true), + }), + }, + 'agent-schedule:deleteAgent': { + req: z.object({ + agentName: z.string(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + // Shell integration channels + 'shell:openPath': { + req: z.object({ path: z.string() }), + res: z.object({ error: z.string().optional() }), + }, + 'shell:readFileBase64': { + req: z.object({ path: z.string() }), + res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), + }, } as const; // ============================================================================ diff --git a/apps/x/packages/shared/src/service-events.ts b/apps/x/packages/shared/src/service-events.ts new file mode 100644 index 00000000..d214472c --- /dev/null +++ b/apps/x/packages/shared/src/service-events.ts @@ -0,0 +1,65 @@ +import z from 'zod'; + +export const ServiceName = z.enum([ + 'graph', + 'gmail', + 'calendar', + 'fireflies', + 'granola', + 'voice_memo', +]); + +const ServiceEventBase = z.object({ + service: ServiceName, + runId: z.string(), + ts: z.iso.datetime(), + level: z.enum(['info', 'warn', 'error']), + message: z.string(), +}); + +export const ServiceRunStartEvent = ServiceEventBase.extend({ + type: z.literal('run_start'), + trigger: z.enum(['timer', 'manual', 'startup']).optional(), + config: z.record(z.string(), z.unknown()).optional(), +}); + +export const ServiceChangesIdentifiedEvent = ServiceEventBase.extend({ + type: z.literal('changes_identified'), + counts: z.record(z.string(), z.number()).optional(), + items: z.array(z.string()).optional(), + truncated: z.boolean().optional(), +}); + +export const ServiceProgressEvent = ServiceEventBase.extend({ + type: z.literal('progress'), + step: z.string().optional(), + current: z.number().optional(), + total: z.number().optional(), + details: z.record(z.string(), z.unknown()).optional(), +}); + +export const ServiceRunCompleteEvent = ServiceEventBase.extend({ + type: z.literal('run_complete'), + durationMs: z.number(), + outcome: z.enum(['ok', 'idle', 'skipped', 'error']), + summary: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + items: z.array(z.string()).optional(), + truncated: z.boolean().optional(), +}); + +export const ServiceErrorEvent = ServiceEventBase.extend({ + type: z.literal('error'), + error: z.string(), + context: z.record(z.string(), z.unknown()).optional(), +}); + +export const ServiceEvent = z.union([ + ServiceRunStartEvent, + ServiceChangesIdentifiedEvent, + ServiceProgressEvent, + ServiceRunCompleteEvent, + ServiceErrorEvent, +]); + +export type ServiceNameType = z.infer; +export type ServiceEventType = z.infer; diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 5995b0ea..7afa3ff3 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -50,9 +50,24 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + electron-squirrel-startup: + specifier: ^1.0.1 + version: 1.0.1 + mammoth: + specifier: ^1.11.0 + version: 1.11.0 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 + pdf-parse: + specifier: ^2.4.5 + version: 2.4.5 update-electron-app: specifier: ^3.1.2 version: 3.1.2 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: specifier: ^4.2.1 version: 4.2.1 @@ -61,20 +76,29 @@ importers: specifier: ^7.10.2 version: 7.11.1(encoding@0.1.13)(esbuild@0.24.2) '@electron-forge/maker-deb': - specifier: ^7.10.2 + specifier: ^7.11.1 version: 7.11.1 '@electron-forge/maker-dmg': specifier: ^7.10.2 version: 7.11.1 + '@electron-forge/maker-rpm': + specifier: ^7.11.1 + version: 7.11.1 '@electron-forge/maker-squirrel': specifier: ^7.10.2 version: 7.11.1 '@electron-forge/maker-zip': specifier: ^7.10.2 version: 7.11.1 + '@electron-forge/publisher-github': + specifier: ^7.11.1 + version: 7.11.1 '@electron-forge/publisher-s3': specifier: ^7.10.2 version: 7.11.1 + '@types/electron-squirrel-startup': + specifier: ^1.0.2 + version: 1.0.2 '@types/node': specifier: ^25.0.3 version: 25.0.3 @@ -320,6 +344,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + cron-parser: + specifier: ^5.5.0 + version: 5.5.0 glob: specifier: ^13.0.0 version: 13.0.0 @@ -329,6 +356,9 @@ importers: googleapis: specifier: ^169.0.0 version: 169.0.0 + mammoth: + specifier: ^1.11.0 + version: 1.11.0 node-html-markdown: specifier: ^2.0.0 version: 2.0.0 @@ -338,9 +368,18 @@ importers: openid-client: specifier: ^6.8.1 version: 6.8.1 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 + pdf-parse: + specifier: ^2.4.5 + version: 2.4.5 react: specifier: ^19.2.3 version: 19.2.3 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -351,6 +390,12 @@ importers: '@types/node': specifier: ^25.0.3 version: 25.0.3 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 + '@types/pdf-parse': + specifier: ^1.1.5 + version: 1.1.5 packages/shared: dependencies: @@ -715,6 +760,10 @@ packages: resolution: {integrity: sha512-7zs5/Ewz1PcOl4N1102stFgBiFGWxU18+UPFUSd/fgf9MErBl4HBWuVNMIHyeJ/56rdfkcmTxTqE+9TBEYrZcg==} engines: {node: '>= 16.4.0'} + '@electron-forge/maker-rpm@7.11.1': + resolution: {integrity: sha512-iEfJPRQQyaTqk2EbUfZgulChNWvxGXeYUH0xBX/r5cj1pL4vcJXt3jLMQBVn3mk/0Ytv9UWRs8R/XuNWX6sf2w==} + engines: {node: '>= 16.4.0'} + '@electron-forge/maker-squirrel@7.11.1': resolution: {integrity: sha512-oSg7fgad6l+X0DjtRkSpMzB0AjzyDO4mb2gzM4kTodkP1ADeiMi08bxy0ZeCESqLm5+fG72cAPmEr3BAPvI1yw==} engines: {node: '>= 16.4.0'} @@ -731,6 +780,10 @@ packages: resolution: {integrity: sha512-rXE9oMFGMtdQrixnumWYH5TTGsp99iPHZb3jI74YWq518ctCh6DlIgWlhf6ok2X0+lhWovcIb45KJucUFAQ13w==} engines: {node: '>= 16.4.0'} + '@electron-forge/publisher-github@7.11.1': + resolution: {integrity: sha512-3S7DS1NZRrYvf59eqH0F2ke9oLD5FQqW5+t6kY1EuEo6I8HF+u6dOkGnvzhRh+uvKkjy4ynV3j735PlqBbClGQ==} + engines: {node: '>= 16.4.0'} + '@electron-forge/publisher-s3@7.11.1': resolution: {integrity: sha512-80XQnCC6SvzX96Y2uW0nsm7cLuN3S8W1OeS+DdEb8bITR+o017PFOjfs2634DYsTYdx2+TFtpadVhUI04ATdtQ==} engines: {node: '>= 16.4.0'} @@ -1346,6 +1399,70 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/canvas-android-arm64@0.1.80': + resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.80': + resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.80': + resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.80': + resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1367,6 +1484,70 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs + '@octokit/auth-token@4.0.0': + resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==} + engines: {node: '>= 18'} + + '@octokit/core@5.2.2': + resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@9.0.6': + resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==} + engines: {node: '>= 18'} + + '@octokit/graphql@7.1.1': + resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@12.11.0': + resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} + + '@octokit/openapi-types@24.2.0': + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + + '@octokit/plugin-paginate-rest@11.4.4-cjs.2': + resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-request-log@4.0.1': + resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1': + resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^5 + + '@octokit/plugin-retry@6.1.0': + resolution: {integrity: sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '5' + + '@octokit/request-error@5.1.1': + resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==} + engines: {node: '>= 18'} + + '@octokit/request@8.4.1': + resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==} + engines: {node: '>= 18'} + + '@octokit/rest@20.1.2': + resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==} + engines: {node: '>= 18'} + + '@octokit/types@13.10.0': + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + + '@octokit/types@6.41.0': + resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} + '@openrouter/ai-sdk-provider@1.5.4': resolution: {integrity: sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw==} engines: {node: '>=18'} @@ -2775,6 +2956,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/electron-squirrel-startup@1.0.2': + resolution: {integrity: sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2844,6 +3028,12 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + + '@types/pdf-parse@1.1.5': + resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3029,6 +3219,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3108,6 +3302,9 @@ packages: os: [darwin] hasBin: true + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3160,6 +3357,9 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -3169,6 +3369,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -3183,6 +3386,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -3262,6 +3468,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3356,6 +3566,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@0.5.3: resolution: {integrity: sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==} @@ -3439,6 +3653,9 @@ packages: core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -3449,9 +3666,18 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -3696,6 +3922,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3716,6 +3945,9 @@ packages: dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -3738,6 +3970,9 @@ packages: ds-store@0.1.6: resolution: {integrity: sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw==} + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3766,6 +4001,15 @@ packages: engines: {node: '>= 16'} hasBin: true + electron-installer-redhat@3.4.0: + resolution: {integrity: sha512-gEISr3U32Sgtj+fjxUAlSDo3wyGGq6OBx7rF5UdpIgbnpUvMN4W5uYb0ThpnAZ42VEJh/3aODQXHbFS4f5J3Iw==} + engines: {node: '>= 10.0.0'} + os: [darwin, linux] + hasBin: true + + electron-squirrel-startup@1.0.1: + resolution: {integrity: sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -4101,6 +4345,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + framer-motion@12.23.26: resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} peerDependencies: @@ -4248,6 +4496,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -4256,12 +4505,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4477,6 +4726,9 @@ packages: engines: {node: '>=6.9.0'} hasBin: true + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4618,6 +4870,9 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -4700,6 +4955,9 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + junk@3.1.0: resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==} engines: {node: '>=8'} @@ -4734,6 +4992,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -4867,6 +5128,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -4898,6 +5162,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + macos-alias@0.2.12: resolution: {integrity: sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==} os: [darwin] @@ -4909,6 +5177,11 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + mammoth@1.11.0: + resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==} + engines: {node: '>=12.0.0'} + hasBin: true + map-age-cleaner@0.1.3: resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} engines: {node: '>=6'} @@ -5406,6 +5679,9 @@ packages: openid-client@6.8.1: resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5473,6 +5749,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5548,6 +5827,15 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-parse@2.4.5: + resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} + engines: {node: '>=20.16.0 <21 || >=22.3.0'} + hasBin: true + + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + pe-library@1.0.1: resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} engines: {node: '>=14', npm: '>=7'} @@ -5618,6 +5906,9 @@ packages: resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -5812,6 +6103,9 @@ packages: resolution: {integrity: sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA==} engines: {node: '>=4'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -5978,6 +6272,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -6028,6 +6325,9 @@ packages: server-destroy@1.0.1: resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -6128,9 +6428,16 @@ packages: spdx-license-ids@3.0.22: resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + ssri@9.0.1: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -6159,6 +6466,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -6233,7 +6543,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp@0.9.4: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} @@ -6387,6 +6697,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6431,6 +6744,9 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -6641,10 +6957,18 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -6660,6 +6984,15 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -7543,6 +7876,16 @@ snapshots: - bluebird - supports-color + '@electron-forge/maker-rpm@7.11.1': + dependencies: + '@electron-forge/maker-base': 7.11.1 + '@electron-forge/shared-types': 7.11.1 + optionalDependencies: + electron-installer-redhat: 3.4.0 + transitivePeerDependencies: + - bluebird + - supports-color + '@electron-forge/maker-squirrel@7.11.1': dependencies: '@electron-forge/maker-base': 7.11.1 @@ -7579,6 +7922,24 @@ snapshots: - bluebird - supports-color + '@electron-forge/publisher-github@7.11.1': + dependencies: + '@electron-forge/publisher-base': 7.11.1 + '@electron-forge/shared-types': 7.11.1 + '@octokit/core': 5.2.2 + '@octokit/plugin-retry': 6.1.0(@octokit/core@5.2.2) + '@octokit/request-error': 5.1.1 + '@octokit/rest': 20.1.2 + '@octokit/types': 6.41.0 + chalk: 4.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + log-symbols: 4.1.0 + mime-types: 2.1.35 + transitivePeerDependencies: + - bluebird + - supports-color + '@electron-forge/publisher-s3@7.11.1': dependencies: '@aws-sdk/client-s3': 3.971.0 @@ -8252,6 +8613,49 @@ snapshots: - hono - supports-color + '@napi-rs/canvas-android-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + optional: true + + '@napi-rs/canvas@0.1.80': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.80 + '@napi-rs/canvas-darwin-arm64': 0.1.80 + '@napi-rs/canvas-darwin-x64': 0.1.80 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.80 + '@napi-rs/canvas-linux-arm64-musl': 0.1.80 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-musl': 0.1.80 + '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8274,6 +8678,82 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@octokit/auth-token@4.0.0': {} + + '@octokit/core@5.2.2': + dependencies: + '@octokit/auth-token': 4.0.0 + '@octokit/graphql': 7.1.1 + '@octokit/request': 8.4.1 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@9.0.6': + dependencies: + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@7.1.1': + dependencies: + '@octokit/request': 8.4.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/openapi-types@12.11.0': {} + + '@octokit/openapi-types@24.2.0': {} + + '@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + + '@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + + '@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/types': 13.10.0 + + '@octokit/plugin-retry@6.1.0(@octokit/core@5.2.2)': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + bottleneck: 2.19.5 + + '@octokit/request-error@5.1.1': + dependencies: + '@octokit/types': 13.10.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@8.4.1': + dependencies: + '@octokit/endpoint': 9.0.6 + '@octokit/request-error': 5.1.1 + '@octokit/types': 13.10.0 + universal-user-agent: 6.0.1 + + '@octokit/rest@20.1.2': + dependencies: + '@octokit/core': 5.2.2 + '@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2) + '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2) + '@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2) + + '@octokit/types@13.10.0': + dependencies: + '@octokit/openapi-types': 24.2.0 + + '@octokit/types@6.41.0': + dependencies: + '@octokit/openapi-types': 12.11.0 + '@openrouter/ai-sdk-provider@1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1)': dependencies: '@openrouter/sdk': 0.1.27 @@ -9861,6 +10341,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/electron-squirrel-startup@1.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -9938,6 +10420,14 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 25.0.3 + + '@types/pdf-parse@1.1.5': + dependencies: + '@types/node': 25.0.3 + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -10176,6 +10666,8 @@ snapshots: acorn@8.15.0: {} + adler-32@1.3.1: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -10261,6 +10753,10 @@ snapshots: repeat-string: 1.6.1 optional: true + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -10306,6 +10802,8 @@ snapshots: baseline-browser-mapping@2.9.11: {} + before-after-hook@2.2.3: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -10318,6 +10816,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + bluebird@3.7.2: {} body-parser@2.2.1: @@ -10339,6 +10839,8 @@ snapshots: boolean@3.2.0: optional: true + bottleneck@2.19.5: {} + bowser@2.13.1: {} bplist-creator@0.0.8: @@ -10449,6 +10951,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10544,6 +11051,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codepage@1.15.0: {} + color-convert@0.5.3: optional: true @@ -10605,6 +11114,8 @@ snapshots: core-js@3.47.0: {} + core-util-is@1.0.3: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -10618,8 +11129,14 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@1.2.2: {} + crelt@1.0.6: {} + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + cross-dirname@0.1.0: {} cross-spawn@6.0.6: @@ -10886,6 +11403,8 @@ snapshots: depd@2.0.0: {} + deprecation@2.3.1: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -10901,6 +11420,8 @@ snapshots: dfa@1.2.0: {} + dingbat-to-unicode@1.0.1: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -10935,6 +11456,10 @@ snapshots: tn1150: 0.1.0 optional: true + duck@0.1.12: + dependencies: + underscore: 1.13.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10991,6 +11516,25 @@ snapshots: - supports-color optional: true + electron-installer-redhat@3.4.0: + dependencies: + '@malept/cross-spawn-promise': 1.1.1 + debug: 4.4.3 + electron-installer-common: 0.10.4 + fs-extra: 9.1.0 + lodash: 4.17.21 + word-wrap: 1.2.5 + yargs: 16.2.0 + transitivePeerDependencies: + - supports-color + optional: true + + electron-squirrel-startup@1.0.1: + dependencies: + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + electron-to-chromium@1.5.267: {} electron-winstaller@5.4.0: @@ -11438,6 +11982,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.23.23 @@ -11982,6 +12528,8 @@ snapshots: image-size@0.7.5: optional: true + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -12085,6 +12633,8 @@ snapshots: dependencies: is-docker: 2.2.1 + isarray@1.0.0: {} + isbinaryfile@4.0.10: {} isexe@2.0.0: {} @@ -12163,6 +12713,13 @@ snapshots: jsonpointer@5.0.1: optional: true + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + junk@3.1.0: {} jwa@2.0.1: @@ -12203,6 +12760,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -12321,6 +12882,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.7 + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -12345,6 +12912,8 @@ snapshots: dependencies: react: 19.2.3 + luxon@3.7.2: {} + macos-alias@0.2.12: dependencies: nan: 2.24.0 @@ -12376,6 +12945,19 @@ snapshots: - bluebird - supports-color + mammoth@1.11.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.7 + xmlbuilder: 10.1.1 + map-age-cleaner@0.1.3: dependencies: p-defer: 1.0.0 @@ -13089,6 +13671,8 @@ snapshots: jose: 6.1.3 oauth4webapi: 3.8.3 + option@0.2.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -13152,6 +13736,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13224,6 +13810,15 @@ snapshots: pathe@2.0.3: {} + pdf-parse@2.4.5: + dependencies: + '@napi-rs/canvas': 0.1.80 + pdfjs-dist: 5.4.296 + + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.80 + pe-library@1.0.1: {} pend@1.2.0: {} @@ -13293,6 +13888,8 @@ snapshots: proc-log@2.0.1: {} + process-nextick-args@2.0.1: {} + progress@2.0.3: {} promise-inflight@1.0.1: {} @@ -13536,6 +14133,16 @@ snapshots: normalize-package-data: 2.5.0 path-type: 2.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -13768,6 +14375,8 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -13828,6 +14437,8 @@ snapshots: server-destroy@1.0.1: {} + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} shebang-command@1.2.0: @@ -13941,9 +14552,15 @@ snapshots: spdx-license-ids@3.0.22: {} + sprintf-js@1.0.3: {} + sprintf-js@1.1.3: optional: true + ssf@0.11.2: + dependencies: + frac: 1.1.2 + ssri@9.0.1: dependencies: minipass: 3.3.6 @@ -14001,6 +14618,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -14210,6 +14831,8 @@ snapshots: ufo@1.6.1: {} + underscore@1.13.7: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -14275,6 +14898,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-user-agent@6.0.1: {} + universalify@0.1.2: {} universalify@2.0.1: {} @@ -14472,8 +15097,12 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -14494,6 +15123,18 @@ snapshots: wrappy@1.0.2: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + + xmlbuilder@10.1.1: {} + xmlbuilder@15.1.1: {} xtend@4.0.2: diff --git a/google-setup.md b/google-setup.md new file mode 100644 index 00000000..27ff7b32 --- /dev/null +++ b/google-setup.md @@ -0,0 +1,138 @@ +# Connecting Google to Rowboat + +Rowboat requires a Google OAuth Client ID to connect to Gmail, Calendar, and Drive. Follow the steps below to generate your Client ID correctly. + +--- + +## 1️⃣ Open Google Cloud Console + +Go to: + +https://console.cloud.google.com/ + +Make sure you're logged into the Google account you want to use. + +--- + +## 2️⃣ Create a New Project + +Go to: + +https://console.cloud.google.com/projectcreate + +- Click **Create Project** +- Give it a name (e.g. `Rowboat Integration`) +- Click **Create** + +Once created, make sure the new project is selected in the top project dropdown. + +![Select the new project in the dropdown](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/01-select-project-dropdown.png) + +--- + +## 3️⃣ Enable Required APIs + +Enable the following APIs for your project: + +- Gmail API + + https://console.cloud.google.com/apis/api/gmail.googleapis.com + +- Google Calendar API + + https://console.cloud.google.com/apis/api/calendar-json.googleapis.com + +- Google Drive API + + https://console.cloud.google.com/apis/api/drive.googleapis.com + + +For each API: + +- Click **Enable** + + ![Enable the API](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/02-enable-api.png) + + +--- + +## 4️⃣ Configure OAuth Consent Screen + +Go to: + +https://console.cloud.google.com/auth/branding + +### App Information + +- App name: (e.g. `Rowboat`) +- User support email: Your email + +### Audience + +- Choose **External** + +### Contact Information + +- Add your email address + +Click **Save and Continue** through the remaining steps. + +You do NOT need to publish the app — keeping it in **Testing** mode is fine. + +![OAuth consent screen](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/03-oauth-consent-screen.png) + +--- + +## 5️⃣ Add Test Users + +If your app is in Testing mode, you must add users manually. + +Go to: + +https://console.cloud.google.com/auth/audience + +Under **Test Users**: + +- Click **Add Users** +- Add the email address you plan to connect with Rowboat + +Save changes. + +![Add test users](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/04-add-test-users.png) + +--- + +## 6️⃣ Create OAuth Client ID + +Go to: + +https://console.cloud.google.com/auth/clients + +Click **Create Credentials → OAuth Client ID** + +### Application Type + +Select: + +**Universal Windows Platform (UWP)** + +- Name it anything (e.g. `Rowboat Desktop`) +- Store ID can be anything (e.g. `test` ) +- Click **Create** + +![Create OAuth Client ID (UWP)](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png) + +--- + +## 7️⃣ Copy the Client ID + +After creation, Google will show: + +- **Client ID** +- **Client Secret** + +Copy the **Client ID** and paste it into Rowboat where prompted. + +![Copy Client ID](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/06-copy-client-id.png) + +---