Merge pull request #333 from rowboatlabs/dev

Dev
This commit is contained in:
Ramnique Singh 2026-02-10 18:03:06 +05:30 committed by GitHub
commit dd0862b367
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 6293 additions and 1352 deletions

View file

@ -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

View file

@ -1,9 +1,6 @@
name: Publish to npm
on:
push:
branches:
- main
on: workflow_dispatch
permissions:
id-token: write # Required for OIDC

View file

@ -1,4 +1,4 @@
<img width="1409" height="605" alt="Work knowledge graph" src="https://github.com/user-attachments/assets/707d0452-459d-4710-be85-b5322b433151" />
<img width="1339" height="607" alt="rowboat-github-2" src="https://github.com/user-attachments/assets/fc463b99-01b3-401c-b4a4-044dad480901" />
<h5 align="center">
@ -28,35 +28,47 @@
</h5>
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 (its 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 dont want to re-explain (people, projects, decisions, commitments)
- **Understand** whats 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 Rowboats 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 its 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 youre looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/).
---
<div align="center">
Made with ❤️ by the Rowboat team
[Discord](https://discord.com/invite/htdKpBZF) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -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
}
}
],

View file

@ -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"
}
}

View file

@ -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<typeof RunEvent>): void {
}
}
function emitServiceEvent(event: z.infer<typeof ServiceEvent>): 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<void> {
});
}
let servicesWatcher: (() => void) | null = null;
export async function startServicesWatcher(): Promise<void> {
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<IAgentScheduleRepo>('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<IAgentScheduleStateRepo>('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<IAgentScheduleRepo>('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<IAgentScheduleRepo>('agentScheduleRepo');
const stateRepo = container.resolve<IAgentScheduleStateRepo>('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<string, string> = {
'.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 };
},
});
}

View file

@ -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();
});
stopRunsWatcher();
stopServicesWatcher();
});

View file

@ -5,6 +5,22 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rowboat</title>
<style>
/* Prevent flash of white background before CSS loads */
html, body { margin: 0; padding: 0; }
html.dark, html.dark body { background-color: #252525; }
html.light, html.light body { background-color: #fff; }
</style>
<script>
// Apply theme class immediately before render
(function() {
var stored = localStorage.getItem('rowboat-theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || 'system';
var resolved = theme === 'system' ? (prefersDark ? 'dark' : 'light') : theme;
document.documentElement.classList.add(resolved);
})();
</script>
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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<typeof StickToBottom>;
// Context to share scroll preservation state
interface ScrollPreservationContextValue {
registerScrollContainer: (container: HTMLElement | null) => void;
markUserEngaged: () => void;
resetEngagement: () => void;
}
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
role="log"
{...props}
/>
);
const ScrollPreservationContext = createContext<ScrollPreservationContextValue | null>(null);
export type ConversationProps = ComponentProps<typeof StickToBottom> & {
children?: ReactNode;
};
export const Conversation = ({ className, children, ...props }: ConversationProps) => {
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null);
const isUserEngagedRef = useRef(false);
const savedScrollTopRef = useRef<number>(0);
const lastScrollHeightRef = useRef<number>(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 (
<ScrollPreservationContext.Provider value={contextValue}>
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
>
{children}
</StickToBottom>
</ScrollPreservationContext.Provider>
);
};
/**
* 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

View file

@ -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 (
<div
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onKeyDown={onClick ? (e) => { 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"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">{title}</div>
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
</div>
{action}
</div>
)
}
// --- 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 (
<CardShell
icon={<BookOpen className="h-5 w-5 text-muted-foreground" />}
title={label}
subtitle={extLabel ? `Knowledge \u00b7 ${extLabel}` : 'Knowledge'}
onClick={() => { setActiveSection('knowledge'); onOpenKnowledgeFile(filePath) }}
action={
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
Open
</Button>
}
/>
)
}
// --- Audio File Card ---
function AudioFileCard({ filePath }: { filePath: string }) {
const [isPlaying, setIsPlaying] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(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 (
<CardShell
icon={
<button
onClick={handlePlayPause}
disabled={isLoading}
className="flex h-full w-full items-center justify-center"
>
{isPlaying
? <Pause className="h-5 w-5 text-muted-foreground" />
: <Play className="h-5 w-5 text-muted-foreground" />
}
</button>
}
title={getFileNameWithoutExt(filePath)}
subtitle={`Audio \u00b7 ${extLabel}`}
onClick={handleOpen}
action={
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
Open
</Button>
}
/>
)
}
// --- System File Card ---
function SystemFileCard({ filePath }: { filePath: string }) {
const ext = getExtension(filePath)
const isImage = IMAGE_EXTENSIONS.has(ext)
const [thumbnail, setThumbnail] = useState<string | null>(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 (
<CardShell
icon={
thumbnail
? <img src={thumbnail} alt="" className="h-10 w-10 rounded-lg object-cover" />
: <CategoryIcon className="h-5 w-5 text-muted-foreground" />
}
title={getFileNameWithoutExt(filePath)}
subtitle={extLabel ? `${categoryLabel} \u00b7 ${extLabel}` : categoryLabel}
onClick={handleOpen}
action={
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
Open
</Button>
}
/>
)
}
// --- Main FilePathCard ---
export function FilePathCard({ filePath }: { filePath: string }) {
const trimmed = filePath.trim()
if (trimmed.startsWith('knowledge/')) {
return <KnowledgeFileCard filePath={trimmed} />
}
const ext = getExtension(trimmed)
if (AUDIO_EXTENSIONS.has(ext)) {
return <AudioFileCard filePath={trimmed} />
}
return <SystemFileCard filePath={trimmed} />
}

View file

@ -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 <code> 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 <FilePathCard filePath={text} />
}
}
}
// Passthrough for all other code blocks - return children directly
// so Streamdown's own rendering (syntax highlighting, etc.) is preserved
return <pre {...rest}>{children}</pre>
}

View file

@ -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}

View file

@ -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: <FolderOpen className="h-4 w-4" />,
},
{
id: 'create-presentation',
label: 'Create a presentation',
prompt: 'Create a pdf presentation on [topic]',
icon: <Presentation className="h-4 w-4" />,
},
]
interface SuggestionsProps {

View file

@ -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 (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10">
<Bot className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold truncate">{name}</h1>
<p className="text-sm text-muted-foreground">Background Agent</p>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Description */}
{description && (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Description</h2>
<p className="text-sm">{description}</p>
</section>
)}
{/* Schedule */}
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Schedule</h2>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize">{schedule.type} Schedule</span>
</div>
<p className="text-sm text-muted-foreground">
{formatScheduleDescription(schedule)}
</p>
</div>
</section>
{/* Enabled Toggle - hide for completed one-time schedules */}
{status === "triggered" ? (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Status</h2>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="size-4 text-green-500" />
<p className="text-sm font-medium">Completed</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
This one-time agent has finished running and will not run again.
</p>
</div>
</section>
) : (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Status</h2>
<div className="flex items-center justify-between bg-muted/50 rounded-lg p-4">
<div>
<p className="text-sm font-medium">{enabled ? "Enabled" : "Disabled"}</p>
<p className="text-xs text-muted-foreground">
{enabled ? "This agent will run according to its schedule" : "This agent is paused and will not run"}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={onToggleEnabled}
/>
</div>
</section>
)}
{/* Run Statistics */}
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Run History</h2>
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-2xl font-semibold">{runCount}</p>
<p className="text-xs text-muted-foreground">Total Runs</p>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium">{formatDateTime(lastRunAt)}</p>
<p className="text-xs text-muted-foreground">Last Run</p>
</div>
</div>
</section>
{/* Next Run */}
{nextRunAt && schedule.type !== "once" && (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Next Scheduled Run</h2>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
<span className="text-sm">{formatDateTime(nextRunAt)}</span>
</div>
</div>
</section>
)}
{/* Last Error */}
{lastError && (
<section>
<h2 className="text-sm font-medium text-red-500 mb-2">Last Error</h2>
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-red-500 mt-0.5 shrink-0" />
<p className="text-sm text-red-700 dark:text-red-400">{lastError}</p>
</div>
</div>
</section>
)}
</div>
</div>
)
}

View file

@ -32,7 +32,7 @@ export function ChatInputBar({ onSubmit, onOpen }: ChatInputBarProps) {
return (
<div className="fixed bottom-6 right-6 z-50">
<div className="flex items-center gap-2 bg-background border border-border rounded-full shadow-xl px-4 py-2.5 w-80">
<div className="flex items-center gap-2 bg-background border border-border rounded-lg shadow-none px-4 py-2.5 w-80">
<input
type="text"
value={message}

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUp, Expand, LoaderIcon, Plus, Square } from 'lucide-react'
import { ArrowUp, Expand, LoaderIcon, SquarePen, Square } from 'lucide-react'
import type { ToolUIPart } from 'ai'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@ -12,6 +12,7 @@ import {
Conversation,
ConversationContent,
ConversationEmptyState,
ScrollPositionPreserver,
} from '@/components/ai-elements/conversation'
import {
Message,
@ -32,6 +33,8 @@ import { getMentionHighlightSegments } from '@/lib/mention-highlights'
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
import z from 'zod'
import React from 'react'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
interface ChatMessage {
id: string
@ -102,6 +105,8 @@ const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: Too
return output
}
const streamdownComponents = { pre: MarkdownPreOverride }
const MIN_WIDTH = 300
const MAX_WIDTH = 700
const DEFAULT_WIDTH = 400
@ -130,6 +135,7 @@ interface ChatSidebarProps {
permissionResponses?: Map<string, 'approve' | 'deny'>
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({
<Message key={item.id} from={item.role}>
<MessageContent>
{item.role === 'assistant' ? (
<MessageResponse>{item.content}</MessageResponse>
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
) : (
item.content
)}
@ -457,30 +464,32 @@ export function ChatSidebar({
{showContent && (
<>
{/* Header - minimal, expand and new chat buttons */}
<header className="flex h-12 shrink-0 items-center justify-end gap-1 px-2">
<header className="titlebar-drag-region flex h-10 shrink-0 items-center justify-end gap-1 px-2 bg-sidebar">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onNewChat} className="titlebar-no-drag h-8 w-8 text-muted-foreground hover:text-foreground">
<SquarePen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
{onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onOpenFullScreen} className="h-8 w-8 text-muted-foreground hover:text-foreground">
<Button variant="ghost" size="icon" onClick={onOpenFullScreen} className="titlebar-no-drag h-8 w-8 text-muted-foreground hover:text-foreground">
<Expand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Full screen chat</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onNewChat} className="h-8 w-8 text-muted-foreground hover:text-foreground">
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
</header>
{/* Conversation area */}
<FileCardProvider onOpenKnowledgeFile={onOpenKnowledgeFile ?? (() => {})}>
<div className="flex min-h-0 flex-1 flex-col relative">
<Conversation className="relative flex-1 overflow-y-auto">
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<ScrollPositionPreserver />
<ConversationContent className={hasConversation ? "px-4 pb-24" : "px-4 min-h-full items-center justify-center"}>
{!hasConversation ? (
<ConversationEmptyState className="h-auto">
@ -536,7 +545,7 @@ export function ChatSidebar({
{currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse>{currentAssistantMessage}</MessageResponse>
<MessageResponse components={streamdownComponents}>{currentAssistantMessage}</MessageResponse>
</MessageContent>
</Message>
)}
@ -565,7 +574,7 @@ export function ChatSidebar({
className="mb-3"
/>
)}
<div className="flex items-center gap-2 bg-background border border-border rounded-3xl shadow-xl px-4 py-2.5">
<div className="flex items-center gap-2 bg-background border border-border rounded-lg shadow-none px-4 py-2.5">
<div className="relative flex-1 min-w-0">
{mentionHighlights.hasHighlights && (
<div
@ -648,6 +657,7 @@ export function ChatSidebar({
)}
</div>
</div>
</FileCardProvider>
</>
)}
</div>

View file

@ -447,7 +447,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
<PopoverContent
side="right"
align="end"
sideOffset={8}
sideOffset={4}
className="w-80 p-0"
>
<div className="p-4 border-b">

View file

@ -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> | 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 (
<div className="editor-toolbar">
{/* Back / Forward Navigation */}
<Button
variant="ghost"
size="icon-sm"
onClick={onNavigateBack}
disabled={!canNavigateBack}
title="Go back"
>
<ChevronLeftIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onNavigateForward}
disabled={!canNavigateForward}
title="Go forward"
>
<ChevronRightIcon className="size-4" />
</Button>
<div className="separator" />
{/* Text formatting */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => editor.chain().focus().toggleBold().run()}
data-active={editor.isActive('bold') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Bold (Ctrl+B)"
>
<BoldIcon className="size-4" />
@ -155,7 +123,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
data-active={editor.isActive('italic') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Italic (Ctrl+I)"
>
<ItalicIcon className="size-4" />
@ -165,7 +133,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleStrike().run()}
data-active={editor.isActive('strike') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Strikethrough"
>
<StrikethroughIcon className="size-4" />
@ -175,7 +143,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleCode().run()}
data-active={editor.isActive('code') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Inline Code"
>
<CodeIcon className="size-4" />
@ -189,7 +157,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
data-active={editor.isActive('heading', { level: 1 }) || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Heading 1"
>
<Heading1Icon className="size-4" />
@ -199,7 +167,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
data-active={editor.isActive('heading', { level: 2 }) || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Heading 2"
>
<Heading2Icon className="size-4" />
@ -209,7 +177,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
data-active={editor.isActive('heading', { level: 3 }) || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Heading 3"
>
<Heading3Icon className="size-4" />
@ -223,7 +191,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
data-active={editor.isActive('bulletList') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Bullet List"
>
<ListIcon className="size-4" />
@ -233,7 +201,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
data-active={editor.isActive('orderedList') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Ordered List"
>
<ListOrderedIcon className="size-4" />
@ -243,7 +211,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleTaskList().run()}
data-active={editor.isActive('taskList') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Task List"
>
<ListTodoIcon className="size-4" />
@ -257,7 +225,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
data-active={editor.isActive('blockquote') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Blockquote"
>
<QuoteIcon className="size-4" />
@ -267,7 +235,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
data-active={editor.isActive('codeBlock') || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Code Block"
>
<CodeSquareIcon className="size-4" />
@ -296,7 +264,7 @@ export function EditorToolbar({
size="icon-sm"
onClick={openLinkPopover}
data-active={isLinkActive || undefined}
className="data-[active]:bg-accent"
className="data-active:bg-accent"
title="Link"
>
<LinkIcon className="size-4" />

View file

@ -11,6 +11,9 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
const GOOGLE_CLIENT_ID_SETUP_GUIDE_URL =
"https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md"
interface GoogleClientIdModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@ -53,6 +56,18 @@ export function GoogleClientIdModal({
<label className="text-xs font-medium text-muted-foreground" htmlFor="google-client-id">
Client ID
</label>
<div className="text-xs text-muted-foreground">
Need help setting this up?{" "}
<a
className="text-primary underline underline-offset-4 hover:text-primary/80"
href={GOOGLE_CLIENT_ID_SETUP_GUIDE_URL}
target="_blank"
rel="noreferrer"
>
Read the setup guide
</a>
.
</div>
<Input
id="google-client-id"
placeholder="xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"

View file

@ -49,7 +49,7 @@ export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
<PopoverContent
side="right"
align="end"
sideOffset={8}
sideOffset={4}
className="w-80 p-0"
>
<div className="p-4 border-b">

View file

@ -1,4 +1,4 @@
import { useEditor, EditorContent, Extension } from '@tiptap/react'
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
@ -10,6 +10,159 @@ import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
// Zero-width space used as invisible marker for blank lines
const BLANK_LINE_MARKER = '\u200B'
// Pre-process markdown to preserve blank lines before parsing
function preprocessMarkdown(markdown: string): string {
// Convert sequences of 3+ newlines to paragraphs with zero-width space
// - 2 newlines = normal paragraph break (0 empty paragraphs)
// - 3 newlines = 1 blank line = 1 empty paragraph
// - 4 newlines = 2 blank lines = 2 empty paragraphs
// Formula: emptyParagraphs = totalNewlines - 2
return markdown.replace(/\n{3,}/g, (match) => {
const totalNewlines = match.length
const emptyParagraphs = totalNewlines - 2
let result = '\n\n'
for (let i = 0; i < emptyParagraphs; i++) {
result += BLANK_LINE_MARKER + '\n\n'
}
return result
})
}
// Post-process to clean up any zero-width spaces in the output
function postprocessMarkdown(markdown: string): string {
// Remove lines that contain only the zero-width space marker
return markdown.split('\n').map(line => {
if (line === BLANK_LINE_MARKER || line.trim() === BLANK_LINE_MARKER) {
return ''
}
// Also remove zero-width spaces from other content
return line.replace(new RegExp(BLANK_LINE_MARKER, 'g'), '')
}).join('\n')
}
// Custom function to get markdown that preserves empty paragraphs as blank lines
function getMarkdownWithBlankLines(editor: Editor): string {
const json = editor.getJSON()
if (!json.content) return ''
const blocks: string[] = []
// Helper to convert a node to markdown text
const nodeToText = (node: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }): string => {
if (!node.content) return ''
return node.content.map(child => {
if (child.type === 'text') {
let text = child.text || ''
// Apply marks (bold, italic, etc.)
if (child.marks) {
for (const mark of child.marks) {
if (mark.type === 'bold') text = `**${text}**`
else if (mark.type === 'italic') text = `*${text}*`
else if (mark.type === 'code') text = `\`${text}\``
else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`
}
}
return text
} else if (child.type === 'hardBreak') {
return '\n'
}
return ''
}).join('')
}
for (const node of json.content) {
if (node.type === 'paragraph') {
const text = nodeToText(node)
// If the paragraph contains only the blank line marker or is empty, it's a blank line
if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) {
// Push empty string to represent blank line - will add extra newline when joining
blocks.push('')
} else {
blocks.push(text)
}
} else if (node.type === 'heading') {
const level = (node.attrs?.level as number) || 1
const text = nodeToText(node)
blocks.push('#'.repeat(level) + ' ' + text)
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
// Handle lists - all items are part of one block
const listLines: string[] = []
const listItems = (node.content || []) as Array<{ content?: Array<unknown>; attrs?: Record<string, unknown> }>
listItems.forEach((item, index) => {
const prefix = node.type === 'orderedList' ? `${index + 1}. ` : '- '
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, paraIndex: number) => {
const text = nodeToText(para)
if (paraIndex === 0) {
listLines.push(prefix + text)
} else {
listLines.push(' ' + text)
}
})
})
blocks.push(listLines.join('\n'))
} else if (node.type === 'taskList') {
const listLines: string[] = []
const listItems = (node.content || []) as Array<{ content?: Array<unknown>; attrs?: Record<string, unknown> }>
listItems.forEach(item => {
const checked = item.attrs?.checked ? 'x' : ' '
const itemContent = (item.content || []) as Array<{ type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }>
itemContent.forEach((para: { type?: string; content?: Array<{ type?: string; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> }>; attrs?: Record<string, unknown> }, paraIndex: number) => {
const text = nodeToText(para)
if (paraIndex === 0) {
listLines.push(`- [${checked}] ${text}`)
} else {
listLines.push(' ' + text)
}
})
})
blocks.push(listLines.join('\n'))
} else if (node.type === 'codeBlock') {
const lang = (node.attrs?.language as string) || ''
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
} else if (node.type === 'blockquote') {
const content = node.content || []
const quoteLines = content.map(para => '> ' + nodeToText(para))
blocks.push(quoteLines.join('\n'))
} else if (node.type === 'horizontalRule') {
blocks.push('---')
} else if (node.type === 'wikiLink') {
const path = (node.attrs?.path as string) || ''
blocks.push(`[[${path}]]`)
} else if (node.type === 'image') {
const src = (node.attrs?.src as string) || ''
const alt = (node.attrs?.alt as string) || ''
blocks.push(`![${alt}](${src})`)
}
}
// Custom join: content blocks get \n\n before them, empty blocks add \n each
// This produces: 1 empty paragraph = 3 newlines (1 blank line on disk)
if (blocks.length === 0) return ''
let result = ''
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
const isContent = block !== ''
if (i === 0) {
result = block
} else if (isContent) {
// Content block: add \n\n before it (standard paragraph break)
result += '\n\n' + block
} else {
// Empty block: just add \n (one extra newline for blank line)
result += '\n'
}
}
return result
}
import { EditorToolbar } from './editor-toolbar'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
@ -30,10 +183,6 @@ interface MarkdownEditorProps {
placeholder?: string
wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null>
onNavigateBack?: () => void
onNavigateForward?: () => void
canNavigateBack?: boolean
canNavigateForward?: boolean
}
type WikiLinkMatch = {
@ -76,16 +225,47 @@ const createSelectionHighlightExtension = (getRange: () => SelectionHighlightRan
})
}
const TabIndentExtension = Extension.create({
name: 'tabIndent',
addKeyboardShortcuts() {
const indentText = ' '
return {
Tab: () => {
// Always handle Tab so focus never leaves the editor.
// First try list indentation; otherwise insert spaces.
if (this.editor.can().sinkListItem('taskItem')) {
void this.editor.commands.sinkListItem('taskItem')
return true
}
if (this.editor.can().sinkListItem('listItem')) {
void this.editor.commands.sinkListItem('listItem')
return true
}
void this.editor.commands.insertContent(indentText)
return true
},
'Shift-Tab': () => {
// Always handle Shift+Tab so focus never leaves the editor.
if (this.editor.can().liftListItem('taskItem')) {
void this.editor.commands.liftListItem('taskItem')
return true
}
if (this.editor.can().liftListItem('listItem')) {
void this.editor.commands.liftListItem('listItem')
return true
}
return true
},
}
},
})
export function MarkdownEditor({
content,
onChange,
placeholder = 'Start writing...',
wikiLinks,
onImageUpload,
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
@ -93,6 +273,9 @@ export function MarkdownEditor({
const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
// Keep ref in sync with state for the plugin to access
selectionHighlightRef.current = selectionHighlight
@ -140,24 +323,62 @@ export function MarkdownEditor({
placeholder,
}),
Markdown.configure({
html: false,
html: true,
breaks: true,
tightLists: false,
transformCopiedText: true,
transformPastedText: true,
}),
selectionHighlightExtension,
TabIndentExtension,
],
content: '',
onUpdate: ({ editor }) => {
if (isInternalUpdate.current) return
const storage = editor.storage as unknown as Record<string, { getMarkdown?: () => string }>
const markdown = storage.markdown?.getMarkdown?.() ?? ''
let markdown = getMarkdownWithBlankLines(editor)
// Post-process to clean up any markers and ensure blank lines are preserved
markdown = postprocessMarkdown(markdown)
onChange(markdown)
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none',
},
handleKeyDown: (_view, event) => {
const state = wikiKeyStateRef.current
if (state.open) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
setActiveWikiLink(null)
setAnchorPosition(null)
setWikiCommandValue('')
return true
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (state.options.length === 0) return true
event.preventDefault()
event.stopPropagation()
const currentIndex = Math.max(0, state.options.indexOf(state.value))
const delta = event.key === 'ArrowDown' ? 1 : -1
const nextIndex = (currentIndex + delta + state.options.length) % state.options.length
setWikiCommandValue(state.options[nextIndex])
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
if (state.options.length === 0) return true
event.preventDefault()
event.stopPropagation()
const selected = state.options.includes(state.value) ? state.value : state.options[0]
handleSelectWikiLinkRef.current(selected)
return true
}
}
return false
},
handleClickOn: (_view, _pos, node, _nodePos, event) => {
if (node.type.name === 'wikiLink') {
event.preventDefault()
@ -249,11 +470,14 @@ export function MarkdownEditor({
// Update editor content when prop changes (e.g., file selection changes)
useEffect(() => {
if (editor && content !== undefined) {
const storage = editor.storage as unknown as Record<string, { getMarkdown?: () => string }>
const currentContent = storage.markdown?.getMarkdown?.() ?? ''
if (currentContent !== content) {
const currentContent = getMarkdownWithBlankLines(editor)
// Normalize for comparison (trim trailing whitespace from lines)
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
isInternalUpdate.current = true
editor.commands.setContent(content)
// Pre-process to preserve blank lines
const preprocessed = preprocessMarkdown(content)
editor.commands.setContent(preprocessed)
isInternalUpdate.current = false
}
}
@ -304,10 +528,40 @@ export function MarkdownEditor({
setAnchorPosition(null)
}, [editor, activeWikiLink, wikiLinks])
useEffect(() => {
handleSelectWikiLinkRef.current = handleSelectWikiLink
}, [handleSelectWikiLink])
const handleScroll = useCallback(() => {
updateWikiLinkState()
}, [updateWikiLinkState])
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
const wikiOptions = useMemo(() => {
if (!showWikiPopover) return []
const options: string[] = []
if (canCreate) options.push(createCandidate)
options.push(...visibleFiles)
return options
}, [showWikiPopover, canCreate, createCandidate, visibleFiles])
useEffect(() => {
wikiKeyStateRef.current = { open: showWikiPopover, options: wikiOptions, value: wikiCommandValue }
}, [showWikiPopover, wikiOptions, wikiCommandValue])
// Keep cmdk selection in sync with available options
useEffect(() => {
if (!showWikiPopover) {
setWikiCommandValue('')
return
}
if (wikiOptions.length === 0) {
setWikiCommandValue('')
return
}
setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0]))
}, [showWikiPopover, wikiOptions])
// Handle keyboard shortcuts
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
@ -316,8 +570,6 @@ export function MarkdownEditor({
}
}, [])
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
// Create image upload handler that shows placeholder
const handleImageUploadWithPlaceholder = useMemo(() => {
if (!editor || !onImageUpload) return undefined
@ -330,10 +582,6 @@ export function MarkdownEditor({
editor={editor}
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
onNavigateBack={onNavigateBack}
onNavigateForward={onNavigateForward}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
/>
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
@ -344,6 +592,7 @@ export function MarkdownEditor({
if (!open) {
setActiveWikiLink(null)
setAnchorPosition(null)
setWikiCommandValue('')
}
}}
>
@ -363,7 +612,7 @@ export function MarkdownEditor({
side="bottom"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false}>
<Command shouldFilter={false} value={wikiCommandValue} onValueChange={setWikiCommandValue}>
<CommandList>
{canCreate ? (
<CommandItem

View file

@ -2,7 +2,8 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react"
import { Loader2, Mic, Mail, CheckCircle2 } from "lucide-react"
// import { MessageSquare } from "lucide-react"
import {
Dialog,
@ -38,7 +39,7 @@ interface OnboardingModalProps {
onComplete: () => void
}
type Step = 0 | 1 | 2 | 3
type Step = 0 | 1 | 2
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
@ -68,8 +69,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
})
const [savingLlmConfig, setSavingLlmConfig] = useState(false)
// OAuth provider states
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@ -83,7 +82,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
// const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
const updateProviderConfig = useCallback(
@ -157,6 +156,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
loadModels()
}, [open])
// Preferred default models for each provider
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.2",
anthropic: "claude-opus-4-5-20251101",
}
// Initialize default models from catalog
useEffect(() => {
if (Object.keys(modelsCatalog).length === 0) return
@ -166,7 +171,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
for (const provider of cloudProviders) {
const models = modelsCatalog[provider]
if (models?.length && !next[provider].model) {
next[provider] = { ...next[provider], model: models[0]?.id || "" }
// Check if preferred default exists in the catalog
const preferredModel = preferredDefaults[provider]
const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
}
}
return next
@ -205,14 +213,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
try {
setSlackLoading(true)
// setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
} finally {
setSlackLoading(false)
// setSlackLoading(false)
}
}, [])
@ -234,6 +242,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}, [])
// Connect to Slack via Composio (checks if configured first)
/*
const handleConnectSlack = useCallback(async () => {
// Check if Composio is configured
const configResult = await window.ipc.invoke('composio:is-configured', null)
@ -243,6 +252,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
await startSlackConnect()
}, [startSlackConnect])
*/
// Handle Composio API key submission
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
@ -259,7 +269,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}, [startSlackConnect])
const handleNext = () => {
if (currentStep < 3) {
if (currentStep < 2) {
setCurrentStep((prev) => (prev + 1) as Step)
}
}
@ -268,24 +278,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
onComplete()
}
const handleTestConnection = useCallback(async () => {
const handleTestAndSaveLlmConfig = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim()
const result = await window.ipc.invoke("models:test", {
const providerConfig = {
provider: {
flavor: llmProvider,
apiKey,
baseURL,
},
model,
})
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
setTestState({ status: "success" })
toast.success("Connection successful")
// Save and continue
await window.ipc.invoke("models:saveConfig", providerConfig)
handleNext()
} else {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
@ -295,31 +308,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
const handleSaveLlmConfig = useCallback(async () => {
if (testState.status !== "success") return
setSavingLlmConfig(true)
try {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim()
await window.ipc.invoke("models:saveConfig", {
provider: {
flavor: llmProvider,
apiKey,
baseURL,
},
model,
})
setSavingLlmConfig(false)
handleNext()
} catch (error) {
console.error("Failed to save LLM config:", error)
toast.error("Failed to save LLM settings")
setSavingLlmConfig(false)
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, handleNext, llmProvider, testState.status])
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider, handleNext])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
@ -459,7 +448,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Step indicator component
const StepIndicator = () => (
<div className="flex gap-2 justify-center mb-6">
{[0, 1, 2, 3].map((step) => (
{[0, 1, 2].map((step) => (
<div
key={step}
className={cn(
@ -552,6 +541,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
// Render Slack row
/*
const renderSlackRow = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
@ -594,86 +584,76 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
</div>
)
*/
// Step 0: Welcome
const WelcomeStep = () => (
<div className="flex flex-col items-center text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-primary/10 mb-6">
<Sailboat className="size-10 text-primary" />
</div>
<DialogHeader className="space-y-3">
<DialogTitle className="text-2xl">Your AI coworker, with memory</DialogTitle>
<DialogDescription className="text-base max-w-md mx-auto">
Rowboat connects to your email, calendar, and meetings to help you stay on top of your work.
</DialogDescription>
</DialogHeader>
<div className="mt-8 space-y-3 text-left w-full max-w-sm">
<div className="flex gap-3">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">1</div>
<p className="text-sm text-muted-foreground">Syncs with your email, calendar, and meetings</p>
</div>
<div className="flex gap-3">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">2</div>
<p className="text-sm text-muted-foreground">Remembers the people and context from your conversations</p>
</div>
<div className="flex gap-3">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-medium">3</div>
<p className="text-sm text-muted-foreground">Helps you follow up and never miss what matters</p>
</div>
</div>
<Button onClick={handleNext} size="lg" className="mt-8 w-full max-w-xs">
Get Started
</Button>
</div>
)
// 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 }) => (
<button
key={provider.id}
onClick={() => {
setLlmProvider(provider.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-3 text-left transition-colors",
llmProvider === provider.id
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="text-sm font-medium">{provider.name}</div>
<div className="text-xs text-muted-foreground mt-1">{provider.description}</div>
</button>
)
return (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
<div className="flex items-center justify-center gap-3 mb-3">
<span className="text-lg font-medium text-muted-foreground">Your AI coworker, with memory</span>
</div>
<DialogHeader className="text-center mb-3">
<DialogTitle className="text-2xl">Choose your model</DialogTitle>
<DialogDescription className="text-base">
Select your provider and model to power Rowboats AI.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="space-y-3">
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 sm:grid-cols-2">
{providerOptions.map((provider) => (
<button
key={provider.id}
onClick={() => {
setLlmProvider(provider.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-3 text-left transition-colors",
llmProvider === provider.id
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="text-sm font-medium">{provider.name}</div>
<div className="text-xs text-muted-foreground mt-1">{provider.description}</div>
</button>
))}
{primaryProviders.map(renderProviderCard)}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 sm:grid-cols-2 mt-2">
{moreProviders.map(renderProviderCard)}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
<div className="space-y-2">
@ -741,48 +721,36 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)}
</div>
<div className="mt-6 flex items-center gap-3">
{testState.status === "error" && (
<div className="mt-4 text-sm text-destructive">
{testState.error || "Connection test failed"}
</div>
)}
<div className="flex flex-col gap-3 mt-4">
<Button
variant="default"
onClick={handleTestConnection}
onClick={handleTestAndSaveLlmConfig}
size="lg"
disabled={!canTest || testState.status === "testing"}
>
{testState.status === "testing" ? (
<Loader2 className="size-4 animate-spin" />
<><Loader2 className="size-4 animate-spin mr-2" />Testing connection...</>
) : (
"Test connection"
"Continue"
)}
</Button>
{testState.status === "success" && (
<span className="text-sm text-green-600">Connected</span>
)}
{testState.status === "error" && (
<span className="text-sm text-destructive">
{testState.error || "Test failed"}
</span>
)}
</div>
<div className="flex flex-col gap-3 mt-8">
<Button
onClick={handleSaveLlmConfig}
size="lg"
disabled={testState.status !== "success" || savingLlmConfig}
>
{savingLlmConfig ? <Loader2 className="size-4 animate-spin" /> : "Continue"}
</Button>
</div>
</div>
)
}
// Step 2: Connect Accounts
// Step 1: Connect Accounts
const AccountConnectionStep = () => (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
<DialogTitle className="text-2xl">Connect Your Accounts</DialogTitle>
<DialogDescription className="text-base">
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.
</DialogDescription>
</DialogHeader>
@ -812,13 +780,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
</div>
{/* Team Communication Section */}
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
</div>
{renderSlackRow()}
</div>
</>
)}
</div>
@ -834,7 +795,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
)
// 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) {
<DialogTitle className="text-2xl">You're All Set!</DialogTitle>
<DialogDescription className="text-base max-w-md mx-auto">
{hasConnections ? (
<>Your workspace will populate over the next ~30 minutes as we sync your data.</>
<>Give me 30 minutes to build your context graph.<br />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()}
>
<StepIndicator />
{currentStep === 0 && <WelcomeStep />}
{currentStep === 1 && <LlmSetupStep />}
{currentStep === 2 && <AccountConnectionStep />}
{currentStep === 3 && <CompletionStep />}
{currentStep === 0 && <LlmSetupStep />}
{currentStep === 1 && <AccountConnectionStep />}
{currentStep === 2 && <CompletionStep />}
</DialogContent>
</Dialog>
</>

View file

@ -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 (
<button
onClick={onClick}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className={cn("size-6", isSelected ? "text-primary" : "text-muted-foreground")} />
<span className={cn("text-sm font-medium", isSelected ? "text-primary" : "text-foreground")}>
{label}
</span>
</button>
)
}
function AppearanceSettings() {
const { theme, setTheme } = useTheme()
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium mb-3">Theme</h4>
<p className="text-xs text-muted-foreground mb-4">
Select your preferred color scheme
</p>
<div className="grid grid-cols-3 gap-3">
<ThemeOption
label="Light"
icon={Sun}
isSelected={theme === "light"}
onClick={() => setTheme("light")}
/>
<ThemeOption
label="Dark"
icon={Moon}
isSelected={theme === "dark"}
onClick={() => setTheme("dark")}
/>
<ThemeOption
label="System"
icon={Monitor}
isSelected={theme === "system"}
onClick={() => setTheme("system")}
/>
</div>
</div>
</div>
)
}
// --- 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<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.2",
anthropic: "claude-opus-4-5-20251101",
}
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
}
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
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<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(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<string, LlmModelOption[]> = {}
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 }) => (
<button
key={p.id}
onClick={() => {
setProvider(p.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-2.5 text-left transition-colors",
provider === p.id
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="text-sm font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
</button>
)
if (configLoading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-4">
{/* Provider selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 grid-cols-2">
{primaryProviders.map(renderProviderCard)}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 grid-cols-2 mt-2">
{moreProviders.map(renderProviderCard)}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* Model selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading models...
</div>
) : showModelInput ? (
<Input
value={activeConfig.model}
onChange={(e) => updateConfig(provider, { model: e.target.value })}
placeholder="Enter model ID"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateConfig(provider, { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
{/* API Key */}
{requiresApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API Key</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
placeholder="Paste your API key"
/>
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => 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"
}
/>
</div>
)}
{/* Test status */}
{testState.status === "error" && (
<div className="text-sm text-destructive">
{testState.error || "Connection test failed"}
</div>
)}
{testState.status === "success" && (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected and saved
</div>
)}
{/* Test & Save button */}
<Button
onClick={handleTestAndSave}
disabled={!canTest || testState.status === "testing"}
className="w-full"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing connection...</>
) : (
"Test & Save"
)}
</Button>
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
@ -60,9 +469,20 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const [error, setError] = useState<string | null>(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) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="!max-w-[900px] w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
>
<div className="flex h-full">
<div className="flex h-full overflow-hidden">
{/* Sidebar */}
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
<div className="px-2 py-3 mb-2">
@ -164,7 +576,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex flex-col min-w-0 min-h-0">
{/* Header */}
<div className="px-4 py-3 border-b">
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
@ -173,9 +585,13 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</p>
</div>
{/* Editor */}
<div className="flex-1 p-4 overflow-hidden">
{loading ? (
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}>
{activeTab === "models" ? (
<ModelSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...
</div>
@ -190,36 +606,38 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{error && (
<span className="text-xs text-destructive">{error}</span>
)}
{hasChanges && !error && (
<span className="text-xs text-muted-foreground">
Unsaved changes
</span>
)}
{/* Footer - only show for JSON config tabs */}
{isJsonTab && (
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{error && (
<span className="text-xs text-destructive">{error}</span>
)}
{hasChanges && !error && (
<span className="text-xs text-muted-foreground">
Unsaved changes
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={loading || saving}
>
Format
</Button>
<Button
size="sm"
onClick={saveConfig}
disabled={loading || saving || !hasChanges}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={loading || saving}
>
Format
</Button>
<Button
size="sm"
onClick={saveConfig}
disabled={loading || saving || !hasChanges}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
</div>
</div>
</DialogContent>

View file

@ -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<typeof ServiceEvent>
const MAX_SYNC_EVENTS = 1000
const RUN_STALE_MS = 2 * 60 * 60 * 1000
const SERVICE_LABELS: Record<string, string> = {
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<string>
tasksActions?: TasksActions
backgroundTasks?: BackgroundTaskItem[]
selectedBackgroundTask?: string | null
} & React.ComponentProps<typeof Sidebar>
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<Map<string, string>>(new Map())
const [popoverOpen, setPopoverOpen] = useState(false)
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
const [logLoading, setLogLoading] = useState(false)
const runTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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 && (
<div
className="fixed bottom-4 z-40 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background shadow-sm"
style={{ left: "0.5rem" }}
aria-label="Syncing"
>
<LoaderIcon className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
<SidebarFooter className="border-t border-sidebar-border px-2 py-2">
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent"
>
<span className="flex items-center gap-2 min-w-0">
{isSyncing ? (
<LoaderIcon className="h-3 w-3 shrink-0 animate-spin" />
) : (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/60" />
)}
<span className="truncate">{statusLabel}</span>
</span>
<ChevronRight className="h-3 w-3 shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="end"
sideOffset={4}
className="w-96 p-0"
>
<div className="p-3 border-b">
<h4 className="font-semibold text-sm">Sync Activity</h4>
<p className="text-xs text-muted-foreground mt-0.5">
{isSyncing ? statusLabel : "All services up to date"}
</p>
</div>
<div className="max-h-80 overflow-y-auto p-2">
{logLoading ? (
<div className="flex items-center justify-center py-4">
<LoaderIcon className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : logEvents.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">
No recent activity.
</div>
) : (
<div className="space-y-0.5">
{logEvents.map((event, idx) => (
<div
key={`${event.runId}-${event.ts}-${idx}`}
className="flex items-start gap-2 rounded px-2 py-1 text-xs hover:bg-accent"
>
<span className="shrink-0 text-[10px] leading-4 text-muted-foreground/70">
{formatEventTime(event.ts)}
</span>
<span className="shrink-0">
<span className={cn(
"inline-block rounded px-1 py-0.5 text-[10px] font-medium leading-none",
event.level === 'error' ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" :
event.level === 'warn' ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" :
"bg-muted text-muted-foreground"
)}>
{SERVICE_LABELS[event.service]?.split(" ").slice(-1)[0] || event.service}
</span>
</span>
<span className="leading-4 text-foreground/80">{event.message}</span>
</div>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
</SidebarFooter>
</>
)
}
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 (
<Sidebar className="border-r-0" {...props}>
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<span className="font-semibold text-lg">{sectionTitles[activeSection]}</span>
<SidebarHeader className="titlebar-drag-region">
{/* Top spacer to clear the traffic lights + fixed toggle row */}
<div className="h-8" />
{/* Tab switcher - centered below the traffic lights row */}
<div className="flex items-center px-2 py-1.5">
<div className="titlebar-no-drag flex w-full rounded-lg bg-sidebar-accent/50 p-0.5">
{sectionTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveSection(tab.id)}
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm font-medium transition-colors",
activeSection === tab.id
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70 hover:text-sidebar-foreground"
)}
>
{tab.label}
</button>
))}
</div>
</div>
</SidebarHeader>
<SidebarContent>
@ -137,10 +394,37 @@ export function SidebarContentPanel({
<TasksSection
runs={runs}
currentRunId={currentRunId}
processingRunIds={processingRunIds}
actions={tasksActions}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/>
)}
</SidebarContent>
{/* Bottom actions */}
<div className="border-t border-sidebar-border px-2 py-2">
<div className="flex flex-col gap-1">
<ConnectorsPopover>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Plug className="size-4" />
<span>Connectors</span>
</button>
</ConnectorsPopover>
<SettingsDialog>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Settings className="size-4" />
<span>Settings</span>
</button>
</SettingsDialog>
<HelpPopover>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<HelpCircle className="size-4" />
<span>Help</span>
</button>
</HelpPopover>
</div>
</div>
<SyncStatusBar />
<SidebarRail />
</Sidebar>
)
@ -179,12 +463,25 @@ async function transcribeWithDeepgram(audioBlob: Blob): Promise<string | null> {
// 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<MediaRecorder | null>(null)
const chunksRef = React.useRef<Blob[]>([])
const notePathRef = React.useRef<string | null>(null)
const timestampRef = React.useRef<string | null>(null)
const relativePathRef = React.useRef<string | null>(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 (
<Tooltip>
<TooltipTrigger asChild>
@ -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<string>
actions?: TasksActions
backgroundTasks?: BackgroundTaskItem[]
selectedBackgroundTask?: string | null
}) {
return (
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
@ -678,9 +1004,38 @@ function TasksSection({
</SidebarMenu>
</div>
<SidebarGroupContent className="flex-1 overflow-y-auto">
{runs.length > 0 && (
{/* Background Tasks Section */}
{backgroundTasks.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Background Tasks
</div>
<SidebarMenu>
{backgroundTasks.map((task) => (
<SidebarMenuItem key={task.name}>
<SidebarMenuButton
isActive={selectedBackgroundTask === task.name}
onClick={() => actions?.onSelectBackgroundTask?.(task.name)}
className="gap-2"
>
<div className="relative">
<Bot className="size-4 shrink-0" />
<span
className={`absolute -bottom-0.5 -right-0.5 size-2 rounded-full ${getStatusColor(task.status, task.enabled)} ${task.status === "running" && task.enabled ? "animate-pulse" : ""}`}
/>
</div>
<span className={`truncate text-sm ${!task.enabled ? "text-muted-foreground" : ""}`}>
{task.name}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</>
)}
{runs.length > 0 && (
<>
<div className="px-3 py-1.5 mt-4 text-xs font-medium text-muted-foreground">
Chat history
</div>
<SidebarMenu>
@ -689,10 +1044,13 @@ function TasksSection({
<SidebarMenuButton
isActive={currentRunId === run.id}
onClick={() => actions?.onSelectRun(run.id)}
className="gap-2"
>
<MessageSquare className="size-4 shrink-0" />
<span className="truncate text-sm">{run.title || '(Untitled chat)'}</span>
<div className="flex items-center gap-2">
{processingRunIds?.has(run.id) ? (
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
) : null}
<span className="truncate text-sm">{run.title || '(Untitled chat)'}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
))}
@ -703,4 +1061,3 @@ function TasksSection({
</SidebarGroup>
)
}

View file

@ -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 (
<div className="bg-sidebar border-r border-sidebar-border flex h-svh w-14 flex-col items-center py-2 fixed left-0 top-0 z-50 shrink-0">
{/* Main navigation */}
<nav className="flex flex-1 flex-col items-center gap-1">
{navItems.map((item) => (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
onClick={() => setActiveSection(item.id)}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
activeSection === item.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<item.icon className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{item.title}
</TooltipContent>
</Tooltip>
))}
</nav>
{/* Secondary navigation (bottom) */}
<nav className="flex flex-col items-center gap-1">
{/* Connectors */}
<ConnectorsPopover tooltip="Connectors">
<button
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Plug className="size-5" />
</button>
</ConnectorsPopover>
{/* Settings */}
<SettingsDialog>
<button
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Settings className="size-5" />
</button>
</SettingsDialog>
{/* Help */}
<HelpPopover tooltip="Help">
<button
className="flex h-10 w-10 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<HelpCircle className="size-5" />
</button>
</HelpPopover>
</nav>
</div>
)
}

View file

@ -31,7 +31,7 @@ function DropdownMenuTrigger({
function DropdownMenuContent({
className,
sideOffset = 4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
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({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
@ -126,7 +126,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -153,7 +153,7 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
"px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className
)}
{...props}
@ -209,7 +209,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none 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-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -228,7 +228,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"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 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}

View file

@ -20,7 +20,7 @@ function HoverCardTrigger({
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
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}

View file

@ -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.

View file

@ -18,7 +18,7 @@ function PopoverTrigger({
function PopoverContent({
className,
align = "center",
sideOffset = 4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
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}

View file

@ -0,0 +1,27 @@
import { createContext, useContext, type ReactNode } from 'react'
interface FileCardContextType {
onOpenKnowledgeFile: (path: string) => void
}
const FileCardContext = createContext<FileCardContextType | null>(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 (
<FileCardContext.Provider value={{ onOpenKnowledgeFile }}>
{children}
</FileCardContext.Provider>
)
}

View file

@ -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<ThemeContextProps | null>(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<Theme>(() => {
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<ThemeContextProps>(
() => ({
theme,
resolvedTheme,
setTheme,
}),
[theme, resolvedTheme, setTheme]
)
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
)
}

View file

@ -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(
<StrictMode>
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
<App />
<ThemeProvider defaultTheme="system">
<App />
</ThemeProvider>
</PostHogProvider>
</StrictMode>,
)

View file

@ -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"
}
}

View file

@ -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<typeof AgentScheduleConfig>["agents"] = {};
export interface IAgentScheduleRepo {
ensureConfig(): Promise<void>;
getConfig(): Promise<z.infer<typeof AgentScheduleConfig>>;
upsert(agentName: string, entry: z.infer<typeof AgentScheduleEntry>): Promise<void>;
delete(agentName: string): Promise<void>;
}
export class FSAgentScheduleRepo implements IAgentScheduleRepo {
private readonly configPath = path.join(WorkDir, "config", "agent-schedule.json");
async ensureConfig(): Promise<void> {
try {
await fs.access(this.configPath);
} catch {
await fs.writeFile(this.configPath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULES }, null, 2));
}
}
async getConfig(): Promise<z.infer<typeof AgentScheduleConfig>> {
const config = await fs.readFile(this.configPath, "utf8");
return AgentScheduleConfig.parse(JSON.parse(config));
}
async upsert(agentName: string, entry: z.infer<typeof AgentScheduleEntry>): Promise<void> {
const conf = await this.getConfig();
conf.agents[agentName] = entry;
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
}
async delete(agentName: string): Promise<void> {
const conf = await this.getConfig();
delete conf.agents[agentName];
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
}
}

View file

@ -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<void> {
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<typeof AgentScheduleEntry>["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<typeof AgentScheduleEntry>,
state: z.infer<typeof AgentScheduleStateEntry> | 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<typeof AgentScheduleEntry>,
stateRepo: IAgentScheduleStateRepo,
runsRepo: IRunsRepo,
agentRuntime: IAgentRuntime,
idGenerator: IMonotonicallyIncreasingIdGenerator
): Promise<void> {
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<typeof MessageEvent> = {
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<typeof AgentScheduleState>,
config: z.infer<typeof AgentScheduleConfig>,
stateRepo: IAgentScheduleStateRepo
): Promise<void> {
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<void> {
const scheduleRepo = container.resolve<IAgentScheduleRepo>("agentScheduleRepo");
const stateRepo = container.resolve<IAgentScheduleStateRepo>("agentScheduleStateRepo");
const runsRepo = container.resolve<IRunsRepo>("runsRepo");
const agentRuntime = container.resolve<IAgentRuntime>("agentRuntime");
const idGenerator = container.resolve<IMonotonicallyIncreasingIdGenerator>("idGenerator");
// Load config and state
let config: z.infer<typeof AgentScheduleConfig>;
let state: z.infer<typeof AgentScheduleState>;
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<void> {
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);
}
}

View file

@ -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<typeof AgentScheduleState>["agents"] = {};
export interface IAgentScheduleStateRepo {
ensureState(): Promise<void>;
getState(): Promise<z.infer<typeof AgentScheduleState>>;
getAgentState(agentName: string): Promise<z.infer<typeof AgentScheduleStateEntry> | null>;
updateAgentState(agentName: string, entry: Partial<z.infer<typeof AgentScheduleStateEntry>>): Promise<void>;
setAgentState(agentName: string, entry: z.infer<typeof AgentScheduleStateEntry>): Promise<void>;
deleteAgentState(agentName: string): Promise<void>;
}
export class FSAgentScheduleStateRepo implements IAgentScheduleStateRepo {
private readonly statePath = path.join(WorkDir, "config", "agent-schedule-state.json");
async ensureState(): Promise<void> {
try {
await fs.access(this.statePath);
} catch {
await fs.writeFile(this.statePath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULE_STATE }, null, 2));
}
}
async getState(): Promise<z.infer<typeof AgentScheduleState>> {
const state = await fs.readFile(this.statePath, "utf8");
return AgentScheduleState.parse(JSON.parse(state));
}
async getAgentState(agentName: string): Promise<z.infer<typeof AgentScheduleStateEntry> | null> {
const state = await this.getState();
return state.agents[agentName] ?? null;
}
async updateAgentState(agentName: string, entry: Partial<z.infer<typeof AgentScheduleStateEntry>>): Promise<void> {
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<typeof AgentScheduleStateEntry>): Promise<void> {
const state = await this.getState();
state.agents[agentName] = entry;
await fs.writeFile(this.statePath, JSON.stringify(state, null, 2));
}
async deleteAgentState(agentName: string): Promise<void> {
const state = await this.getState();
delete state.agents[agentName];
await fs.writeFile(this.statePath, JSON.stringify(state, null, 2));
}
}

View file

@ -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.`;

View file

@ -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 exactlydon'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 writingmalformed 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;

View file

@ -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://<WORKSPACE_ROOT>/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 <WORKSPACE_ROOT> with the actual absolute path returned by workspace-getRoot.
6. Run it: 'node <WORKSPACE_ROOT>/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

View file

@ -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:**

View file

@ -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) => ({

View file

@ -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:**

View file

@ -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 exactlydon'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 writingmalformed 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;

View file

@ -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 <agent-name>
\`\`\`
**With input**:
\`\`\`bash
rowboatx --agent <agent-name> --input "your input here"
\`\`\`
**Non-interactive** (for automation/cron):
\`\`\`bash
rowboatx --agent <agent-name> --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":"<agent-name>"' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r
Replace <agent-name> 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 <agent-name> --input "input" --no-interactive >> ~/.rowboat/logs/<agent-name>.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 '<agent-name>' | 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;

View file

@ -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<any>;
// 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<string, string> = {
'.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<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
@ -690,6 +719,182 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'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<string, string> = {};
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<IModelConfigRepo>('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({

View file

@ -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<void> {
// Resolve repos and explicitly call their ensureConfig methods
const modelConfigRepo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const mcpConfigRepo = container.resolve<IMcpConfigRepo>("mcpConfigRepo");
const agentScheduleRepo = container.resolve<IAgentScheduleRepo>("agentScheduleRepo");
const agentScheduleStateRepo = container.resolve<IAgentScheduleStateRepo>("agentScheduleStateRepo");
await Promise.all([
modelConfigRepo.ensureConfig(),
mcpConfigRepo.ensureConfig(),
agentScheduleRepo.ensureConfig(),
agentScheduleStateRepo.ensureState(),
ensureSecurityConfig(),
]);
}

View file

@ -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);
}
/**

View file

@ -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<IOAuthRepo>(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
});
export default container;

View file

@ -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/<date>/
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<void> {
/**
* 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<string> {
async function createNotesFromBatch(
files: { path: string; content: string }[],
batchNumber: number,
knowledgeIndex: string
): Promise<{ runId: string; notesCreated: Set<string>; notesModified: Set<string> }> {
// 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<string>();
const notesModified = new Set<string>();
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<string>;
notesModified: Set<string>;
hadError: boolean;
};
async function buildGraphWithFiles(
sourceDir: string,
filesToProcess: string[],
state: GraphState,
run?: ServiceRunContext
): Promise<BatchResult> {
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<string>();
const notesModified = new Set<string>();
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<void> {
console.log(`[buildGraph] Starting build for directory: ${sourceDir}`);
@ -210,62 +362,7 @@ export async function buildGraph(sourceDir: string): Promise<void> {
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<boolean> {
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<boolean> {
const BATCH_SIZE = 10;
const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE);
const notesCreated = new Set<string>();
const notesModified = new Set<string>();
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<boolean> {
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<boolean> {
// 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<boolean> {
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<void> {
console.error('[GraphBuilder] Error processing voice memos:', error);
}
const state = loadState();
const folderChanges: { folder: string; sourceDir: string; files: string[] }[] = [];
const countsByFolder: Record<string, number> = {};
const allFiles: string[] = [];
for (const folder of SOURCE_FOLDERS) {
const sourceDir = path.join(WorkDir, folder);
@ -362,14 +540,13 @@ async function processAllSources(): Promise<void> {
}
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<void> {
}
}
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<string>();
const notesModified = new Set<string>();
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 {

View file

@ -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<void> {
console.log('[Granola] Starting sync...');
// Check if enabled
const granolaRepo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
const config = await granolaRepo.getConfig();
if (!config.enabled) {
console.log('[Granola] Sync disabled in config');
return;
}
// Extract access token
const accessToken = extractAccessToken();
if (!accessToken) {
console.log('[Granola] No access token available');
return;
}
// Ensure sync directory exists
ensureDir(SYNC_DIR);
// Load state
const state = loadState();
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<IGranolaConfigRepo>('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<void> {
await interruptibleSleep(SYNC_INTERVAL_MS);
}
}

View file

@ -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 };
}

View file

@ -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<string>, syncDir: string) {
if (!fs.existsSync(syncDir)) return;
function cleanUpOldFiles(currentEventIds: Set<string>, 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<string>, 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<boolean> {
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<number> {
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);
}
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}
}

View file

@ -0,0 +1,24 @@
import type { ServiceEventType } from "@x/shared/dist/service-events.js";
type ServiceEventHandler = (event: ServiceEventType) => Promise<void> | void;
export class ServiceBus {
private subscribers: ServiceEventHandler[] = [];
async publish(event: ServiceEventType): Promise<void> {
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();

View file

@ -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, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
type ServiceEventInput = DistributiveOmit<ServiceEventType, "ts">;
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<void> = Promise.resolve();
private async ensureReady(): Promise<void> {
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<void> {
if (this.currentSize + nextBytes <= MAX_LOG_BYTES) return;
if (this.stream) {
const stream = this.stream;
this.stream = null;
await new Promise<void>((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<void> {
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<string, unknown>;
}): Promise<ServiceRunContext> {
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();

View file

@ -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),
});

View file

@ -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),
});

View file

@ -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 };

View file

@ -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;
// ============================================================================

View file

@ -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<typeof ServiceName>;
export type ServiceEventType = z.infer<typeof ServiceEvent>;

649
apps/x/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

138
google-setup.md Normal file
View file

@ -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)
---