Merge pull request #329 from rowboatlabs/dev

launch macos app
This commit is contained in:
Ramnique Singh 2026-01-21 22:30:58 +05:30 committed by GitHub
commit 9084c739de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 13762 additions and 897 deletions

122
.github/workflows/electron-build.yml vendored Normal file
View file

@ -0,0 +1,122 @@
name: Build Electron App
on:
release:
types: [published]
permissions:
contents: write # Required to upload release assets
jobs:
build:
runs-on: macos-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: Import Code Signing Certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# Create a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Decode and import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
# Allow codesign to access the keychain
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
# Verify certificate was imported
security find-identity -v "$KEYCHAIN_PATH"
# Clean up certificate file
rm -f $RUNNER_TEMP/certificate.p12
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: apps/x
- name: Build and publish to S3
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 }}
- name: Cleanup keychain
if: always()
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
if [ -f "$KEYCHAIN_PATH" ]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi

168
README.md
View file

@ -1,20 +1,20 @@
![ui](https://github.com/user-attachments/assets/fdf0679f-b107-48ce-b326-04db752838d9)
<img width="1409" height="605" alt="Work knowledge graph" src="https://github.com/user-attachments/assets/707d0452-459d-4710-be85-b5322b433151" />
<h5 align="center">
<p align="center" style="display: flex; justify-content: center; gap: 20px; align-items: center;">
<a href="https://trendshift.io/repositories/13609" target="blank">
<img src="https://trendshift.io/api/badge/repositories/13609" alt="rowboatlabs%2Frowboat | Trendshift" width="250" height="55"/>
<img src="https://trendshift.io/api/badge/repositories/13609" alt="rowboatlabs/rowboat | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="https://discord.gg/rxB8pzHxaS" target="_blank" rel="noopener">
<img alt="Discord" src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white&labelColor=5865F2">
</a>
<a href="https://www.rowboatx.com/" target="_blank" rel="noopener">
<a href="https://www.rowboatlabs.com/" target="_blank" rel="noopener">
<img alt="Website" src="https://img.shields.io/badge/Website-10b981?labelColor=10b981&logo=window&logoColor=white">
</a>
<a href="https://discord.com/invite/htdKpBZF" target="_blank" rel="noopener">
<img alt="Discord" src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white&labelColor=5865F2">
</a>
<a href="https://x.com/intent/user?screen_name=rowboatlabshq" target="_blank" rel="noopener">
<img alt="Twitter" src="https://img.shields.io/twitter/follow/rowboatlabshq?style=social">
</a>
@ -23,134 +23,80 @@
</a>
</p>
# RowboatX - Claude Code for Everyday Automations
# Rowboat
**An open-source, local-first AI coworker with memory for everyday work**
</h5>
RowboatX is a local-first CLI for creating background AI agents with full shell access.
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.
**Example agents you can create:**
- Research every person before your meetings (Exa search MCP + Google Calendar MCP)
- Daily podcast summarizing your saved articles (ElevenLabs MCP + ffmpeg)
- Auto-triage Slack DMs and draft responses while you sleep (Slack MCP)
## Quick start
```bash
npx @rowboatlabs/rowboatx@latest
```
---
## Demo
[![Screenshot](https://github.com/user-attachments/assets/ab46ff8b-44bd-400e-beb0-801c6431033f)](https://www.youtube.com/watch?v=cyPBinQzicY&t)
## Examples
### Add and Manage MCP servers
`$ rowboatx`
- Add MCP: 'Add this MCP server config: \<config\> '
- Explore tools: 'What tools are there in \<server-name\> '
[![Demo video](https://github.com/user-attachments/assets/f378285b-4ef3-4a4b-aa20-7dbb664e496c)](https://www.youtube.com/watch?v=T2Bmiy05FrI)
### Create background agents
`$ rowboatx`
- 'Create agent to do X.'
- '... Attach the correct tools from \<mcp-server-name\> to the agent'
- '... Allow the agent to run shell commands including ffmpeg'
---
### Schedule and monitor agents
`$ rowboatx`
- 'Make agent \<background-agent-name\> run every day at 10 AM'
- 'What agents do I have scheduled to run and at what times'
- 'When was \<background-agent-name\> last run'
- 'Are any agents waiting for my input or confirmation'
## Quick start
### Run background agents manually
``` bash
rowboatx --agent=<agent-name> --input="xyz" --no-interactive=true
```
```bash
rowboatx --agent=<agent-name> --run_id=<run_id> # resume from a previous run
```
## Models support
You can configure your models using:
```bash
rowboatx model-config
```
**Download for Mac:**
Alternatively, you can directly edit `~/.rowboat/config/models.json`
```json
{
"providers": {
"openai": {
"flavor": "openai"
},
"lm-studio": {
"flavor": "openai-compatible",
"baseURL": "http://localhost:2000/...",
"apiKey": "...",
"headers": {
"foo": "bar"
}
},
"anthropic": {
"flavor": "anthropic"
},
"google": {
"flavor": "google"
},
"ollama": {
"flavor": "ollama"
}
},
"defaults": {
"provider": "lm-studio",
"model": "gpt-5"
}
}
```
## Contributing
https://github.com/rowboatlabs/rowboat/releases/latest
We want help with:
## What it does
- **Agent templates** - Pre-built agents others can use (podcast generator, meeting prep, etc.)
- **MCP server integrations** - Add support for new tools
- **Platform support** - Windows improvements, Linux edge cases
Rowboat ingests your:
- **Email** (Gmail)
- **Meeting notes** (Granola, Fireflies)
```bash
git clone git@github.com:rowboatlabs/rowboat.git
cd rowboat
npm install
npm run build
npm link
rowboatx
```
and organizes them into a local, Obsidian-compatible vault of plain Markdown files with backlinks.
Ping us on [Discord](https://discord.com/invite/rxB8pzHxaS) if you want to discuss before building.
This vault is not just for browsing or search. It becomes a working memory that Rowboats AI uses to take actions on your behalf.
---
## Prefer a Web UI: Rowboat Studio
As new emails and meetings come in, the relevant notes update automatically, building persistent context across people, projects, organizations, and topics.
*Cursor for Multi-agent Workflows*
---
⚡ Build AI agents instantly with natural language | 🔌 Connect tools with one-click integrations | 📂 Power with knowledge by adding documents for RAG | 🔄 Automate workflows by setting up triggers and actions | 🚀 Deploy anywhere via API or SDK<br><br>
## How its different
### Quick start
1. Set your OpenAI key
```bash
export OPENAI_API_KEY=your-openai-api-key
```
2. Clone the repository and start Rowboat (requires Docker)
```bash
./start.sh
```
Most AI tools reconstruct context on demand by searching transcripts or documents.
3. Access the app at [http://localhost:3000](http://localhost:3000).
Rowboat maintains **long-lived knowledge** instead:
- context accumulates over time
- relationships are explicit and inspectable
- notes are editable by you, not hidden inside a model
- everything lives on your machine as plain Markdown
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:
- 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
Actions are explicit and grounded in the current state of your knowledge.
---
## 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
#### Create a multi-agent assistant with MCP tools by chatting with Rowboat
[![meeting-prep](https://github.com/user-attachments/assets/c8a41622-8e0e-459f-becb-767503489866)](https://youtu.be/KZTP4xZM2DY)
See [Docs](https://docs.rowboatlabs.com/) for more details.
---
<div align="center">
Made with ❤️ by the Rowboat team
[Discord](https://discord.gg/rxB8pzHxaS) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
[Discord](https://discord.com/invite/htdKpBZF) · [Twitter](https://x.com/intent/user?screen_name=rowboatlabshq)
</div>

View file

@ -1,2 +1,5 @@
node_modules/
dist/
dist/
# Staging directory for Electron Forge packaging (contains bundled main process, copied preload/renderer)
.package/
out/

View file

@ -0,0 +1,37 @@
/**
* Bundles the compiled main process into a single JavaScript file.
*
* Why we bundle:
* - pnpm uses symlinks for workspace packages (@x/core, @x/shared)
* - Electron Forge's dependency walker (flora-colossus) cannot follow these symlinks
* - Bundling inlines all dependencies into a single file, eliminating node_modules
*
* This script is called by the generateAssets hook in forge.config.js before packaging.
*/
import * as esbuild from 'esbuild';
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
// The banner defines __import_meta_url at the top of the bundle,
// and we use define to replace all import.meta.url references with it.
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
await esbuild.build({
entryPoints: ['./dist/main.js'],
bundle: true,
platform: 'node',
target: 'node20',
outfile: './.package/dist/main.cjs',
external: ['electron'], // Provided by Electron runtime
// Use CommonJS format - many dependencies use require() which doesn't work
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
format: 'cjs',
// Inject the polyfill variable at the top
banner: { js: cjsBanner },
// Replace import.meta.url directly with our polyfill variable
define: {
'import.meta.url': '__import_meta_url',
},
});
console.log('✅ Main process bundled to .package/dist-bundle/main.js');

View file

@ -0,0 +1,144 @@
// Electron Forge config file
// NOTE: Must be .cjs (CommonJS) because package.json has "type": "module"
// Forge loads configs with require(), which fails on ESM files
const path = require('path');
module.exports = {
packagerConfig: {
name: 'Rowboat',
executableName: 'rowboat',
icon: './icons/icon', // .icns extension added automatically
appBundleId: 'com.rowboat.app',
appCategoryType: 'public.app-category.productivity',
osxSign: {
batchCodesignCalls: true,
},
osxNotarize: {
appleId: process.env.APPLE_ID,
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.
prune: false,
ignore: [
/src\//,
/node_modules\//,
/.gitignore/,
/bundle\.mjs/,
/tsconfig.json/,
],
},
makers: [
{
name: '@electron-forge/maker-dmg',
config: (arch) => ({
format: 'ULFO',
name: `Rowboat-${arch}`, // Architecture-specific name to avoid conflicts
})
},
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
// ZIP is used by Squirrel.Mac for auto-updates
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}`
})
}
],
publishers: [
{
name: '@electron-forge/publisher-s3',
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)
}
}
],
hooks: {
// Hook signature: (forgeConfig, platform, arch)
// Note: Console output only shows if DEBUG or CI env vars are set
generateAssets: async (forgeConfig, platform, arch) => {
const { execSync } = require('child_process');
const fs = require('fs');
const packageDir = path.join(__dirname, '.package');
// Clean staging directory (ensures fresh build every time)
console.log('Cleaning staging directory...');
if (fs.existsSync(packageDir)) {
fs.rmSync(packageDir, { recursive: true });
}
fs.mkdirSync(packageDir, { recursive: true });
// Build order matters! Dependencies must be built before dependents:
// shared → core → (renderer, preload, main)
// Build shared (TypeScript compilation) - no dependencies
console.log('Building shared...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../../packages/shared'),
stdio: 'inherit'
});
// Build core (TypeScript compilation) - depends on shared
console.log('Building core...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../../packages/core'),
stdio: 'inherit'
});
// Build renderer (Vite build) - depends on shared
console.log('Building renderer...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../renderer'),
stdio: 'inherit'
});
// Build preload (TypeScript compilation) - depends on shared
console.log('Building preload...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../preload'),
stdio: 'inherit'
});
// Build main (TypeScript compilation) - depends on core, shared
console.log('Building main (tsc)...');
execSync('pnpm run build', {
cwd: __dirname,
stdio: 'inherit'
});
// Bundle main process with esbuild (inlines all dependencies)
console.log('Bundling main process...');
execSync('node bundle.mjs', {
cwd: __dirname,
stdio: 'inherit'
});
// Copy preload dist into staging directory
console.log('Copying preload...');
const preloadSrc = path.join(__dirname, '../preload/dist');
const preloadDest = path.join(packageDir, 'preload/dist');
fs.mkdirSync(preloadDest, { recursive: true });
fs.cpSync(preloadSrc, preloadDest, { recursive: true });
// Copy renderer dist into staging directory
console.log('Copying renderer...');
const rendererSrc = path.join(__dirname, '../renderer/dist');
const rendererDest = path.join(packageDir, 'renderer/dist');
fs.mkdirSync(rendererDest, { recursive: true });
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
console.log('✅ All assets staged in .package/');
},
}
};

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,19 +1,31 @@
{
"name": "@x/main",
"name": "Rowboat",
"type": "module",
"main": "dist/main.js",
"version": "0.1.0",
"main": ".package/dist/main.cjs",
"scripts": {
"start": "electron .",
"build": "rm -rf dist && tsc"
"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"
},
"dependencies": {
"@x/core": "workspace:*",
"@x/shared": "workspace:*",
"chokidar": "^4.0.3",
"update-electron-app": "^3.1.2",
"zod": "^4.2.1"
},
"devDependencies": {
"@types/node": "^25.0.3",
"electron": "^39.2.7"
"electron": "^39.2.7",
"esbuild": "^0.24.2",
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-dmg": "^7.10.2",
"@electron-forge/maker-squirrel": "^7.10.2",
"@electron-forge/maker-zip": "^7.10.2",
"@electron-forge/publisher-s3": "^7.10.2"
}
}

View file

@ -18,6 +18,8 @@ import z from 'zod';
import { RunEvent } from 'packages/shared/dist/runs.js';
import container from '@x/core/dist/di/container.js';
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';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -209,6 +211,15 @@ function emitRunEvent(event: z.infer<typeof RunEvent>): void {
}
}
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('oauth:didConnect', event);
}
}
}
let runsWatcher: (() => void) | null = null;
export async function startRunsWatcher(): Promise<void> {
if (runsWatcher) {
@ -316,6 +327,21 @@ export function setupIpcHandlers() {
'granola:setConfig': async (_event, args) => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled });
// Trigger sync immediately when enabled
if (args.enabled) {
triggerGranolaSync();
}
return { success: true };
},
'onboarding:getStatus': async () => {
// Show onboarding if it hasn't been completed yet
const complete = isOnboardingComplete();
return { showOnboarding: !complete };
},
'onboarding:markComplete': async () => {
markOnboardingComplete();
return { success: true };
},
});

View file

@ -1,8 +1,9 @@
import { app, BrowserWindow } from "electron";
import { app, BrowserWindow, protocol, net, shell } from "electron";
import path from "node:path";
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname } from "node:path";
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
@ -13,13 +14,55 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const preloadPath = path.join(__dirname, "../../preload/dist/preload.js");
// Path resolution differs between development and production:
const preloadPath = app.isPackaged
? path.join(__dirname, "../preload/dist/preload.js")
: path.join(__dirname, "../../../preload/dist/preload.js");
console.log("preloadPath", preloadPath);
const rendererPath = app.isPackaged
? path.join(__dirname, "../renderer/dist") // Production
: path.join(__dirname, "../../../renderer/dist"); // Development
console.log("rendererPath", rendererPath);
// Register custom protocol for serving built renderer files in production.
// This keeps SPA routes working when users deep link into the packaged app.
function registerAppProtocol() {
protocol.handle("app", (request) => {
const url = new URL(request.url);
// url.pathname starts with "/"
let urlPath = url.pathname;
// If it's "/" or a SPA route (no extension), serve index.html
if (urlPath === "/" || !path.extname(urlPath)) {
urlPath = "/index.html";
}
const filePath = path.join(rendererPath, urlPath);
return net.fetch(pathToFileURL(filePath).toString());
});
}
protocol.registerSchemesAsPrivileged([
{
scheme: "app",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
allowServiceWorkers: true,
// optional but often helpful:
// stream: true,
},
},
]);
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
width: 1280,
height: 800,
webPreferences: {
// IMPORTANT: keep Node out of renderer
nodeIntegration: false,
@ -29,10 +72,47 @@ function createWindow() {
},
});
win.loadURL("http://localhost:5173"); // load the dev server
// Open external links in system browser (not sandboxed Electron window)
// This handles window.open() and target="_blank" links
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
win.webContents.on("will-navigate", (event, url) => {
const isInternal =
url.startsWith("app://") || url.startsWith("http://localhost:5173");
if (!isInternal) {
event.preventDefault();
shell.openExternal(url);
}
});
if (app.isPackaged) {
win.loadURL("app://-/index.html");
} else {
win.loadURL("http://localhost:5173");
}
}
app.whenReady().then(() => {
// Register custom protocol before creating window (for production builds)
if (app.isPackaged) {
registerAppProtocol();
}
// Initialize auto-updater (only in production)
if (app.isPackaged) {
updateElectronApp({
updateSource: {
type: UpdateSourceType.StaticStorage,
baseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/${process.platform}/${process.arch}`,
},
notifyUser: true, // Shows native dialog when update is available
});
}
setupIpcHandlers();
createWindow();
@ -44,7 +124,6 @@ app.whenReady().then(() => {
// Only starts once (guarded in startWorkspaceWatcher)
startWorkspaceWatcher();
// start runs watcher
startRunsWatcher();
@ -66,7 +145,7 @@ app.whenReady().then(() => {
// start pre-built agent runner
initPreBuiltRunner();
app.on('activate', () => {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}

View file

@ -1,4 +1,4 @@
import { BrowserWindow } from 'electron';
import { shell } from 'electron';
import { createAuthServer } from './auth-server.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
@ -6,6 +6,10 @@ import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/prov
import container from '@x/core/dist/di/container.js';
import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js';
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
import { emitOAuthEvent } from './ipc.js';
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
@ -110,6 +114,17 @@ export async function connectProvider(provider: string): Promise<{ success: bool
// Store flow state
activeFlows.set(state, { codeVerifier, provider, config });
// Build authorization URL
const authUrl = oauthClient.buildAuthorizationUrl(config, {
redirectUri: REDIRECT_URI,
scope: scopes.join(' '),
codeChallenge,
state,
});
// Declare timeout variable (will be set after server is created)
let cleanupTimeout: NodeJS.Timeout;
// Create callback server
const { server } = await createAuthServer(8080, async (code, receivedState) => {
// Validate state
@ -138,42 +153,43 @@ export async function connectProvider(provider: string): Promise<{ success: bool
// Save tokens
console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.saveTokens(provider, tokens);
// Trigger immediate sync for relevant providers
if (provider === 'google') {
triggerGmailSync();
triggerCalendarSync();
} else if (provider === 'fireflies-ai') {
triggerFirefliesSync();
}
// Emit success event to renderer
emitOAuthEvent({ provider, success: true });
} catch (error) {
console.error('OAuth token exchange failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
emitOAuthEvent({ provider, success: false, error: errorMessage });
throw error;
} finally {
// Clean up
activeFlows.delete(state);
server.close();
clearTimeout(cleanupTimeout);
}
});
// Build authorization URL
const authUrl = oauthClient.buildAuthorizationUrl(config, {
redirectUri: REDIRECT_URI,
scope: scopes.join(' '),
codeChallenge,
state,
});
// Set timeout to clean up abandoned flows (5 minutes)
// This prevents memory leaks if user never completes the OAuth flow
cleanupTimeout = setTimeout(() => {
if (activeFlows.has(state)) {
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
activeFlows.delete(state);
server.close();
emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' });
}
}, 5 * 60 * 1000); // 5 minutes
// Open browser window
const authWindow = new BrowserWindow({
width: 600,
height: 700,
show: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
authWindow.loadURL(authUrl.toString());
// Clean up on window close
authWindow.on('closed', () => {
activeFlows.delete(state);
server.close();
});
// Open in system browser (shares cookies/sessions with user's regular browser)
shell.openExternal(authUrl.toString());
// Wait for callback (server will handle it)
return { success: true };

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RowboatX</title>
<title>Rowboat</title>
</head>
<body>
<div id="root"></div>

View file

@ -25,6 +25,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-image": "^3.16.0",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/extension-task-item": "^3.15.3",
@ -41,8 +42,10 @@
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"nanoid": "^5.1.6",
"posthog-js": "^1.332.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
"streamdown": "^1.6.10",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",

View file

@ -1,6 +1,9 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Required for Streamdown markdown rendering (bullet points, lists, etc.) */
@source "../node_modules/streamdown/dist/*.js";
@custom-variant dark (&:is(.dark *));
#root {
@ -165,6 +168,66 @@
}
}
/* Markdown content base styles for Streamdown/MessageResponse */
@layer components {
/* Target elements inside MessageResponse wrapper */
[data-slot="message-content"] ul,
[data-slot="message-content"] ol {
@apply my-2 pl-5;
}
[data-slot="message-content"] ul {
@apply list-disc;
}
[data-slot="message-content"] ol {
@apply list-decimal;
}
[data-slot="message-content"] li {
@apply my-1;
}
[data-slot="message-content"] p {
@apply my-2 first:mt-0 last:mb-0;
}
[data-slot="message-content"] h1 {
@apply my-4 text-2xl font-bold first:mt-0;
}
[data-slot="message-content"] h2 {
@apply my-3 text-xl font-semibold first:mt-0;
}
[data-slot="message-content"] h3 {
@apply my-3 text-lg font-semibold first:mt-0;
}
[data-slot="message-content"] h4,
[data-slot="message-content"] h5,
[data-slot="message-content"] h6 {
@apply my-2 font-semibold first:mt-0;
}
[data-slot="message-content"] code:not(pre code) {
@apply rounded bg-muted px-1.5 py-0.5 font-mono text-sm;
}
[data-slot="message-content"] pre {
@apply my-3 overflow-x-auto rounded-lg;
}
[data-slot="message-content"] blockquote {
@apply my-3 border-l-4 border-border pl-4 italic text-muted-foreground;
}
[data-slot="message-content"] hr {
@apply my-4 border-border;
}
[data-slot="message-content"] a {
@apply text-primary underline underline-offset-2 hover:text-primary/80;
}
[data-slot="message-content"] table {
@apply my-3 w-full border-collapse;
}
[data-slot="message-content"] th,
[data-slot="message-content"] td {
@apply border border-border px-3 py-2 text-left;
}
[data-slot="message-content"] th {
@apply bg-muted font-semibold;
}
}
.graph-view {
background-color: var(--background);
user-select: none;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,97 @@
"use client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { MessageCircleIcon, ArrowUpIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useState, useRef, useEffect } from "react";
export type AskHumanRequestProps = ComponentProps<"div"> & {
query: string;
onResponse: (response: string) => void;
isProcessing?: boolean;
};
export const AskHumanRequest = ({
className,
query,
onResponse,
isProcessing = false,
...props
}: AskHumanRequestProps) => {
const [response, setResponse] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
// Auto-focus the textarea when component mounts
textareaRef.current?.focus();
}, []);
const handleSubmit = () => {
const trimmed = response.trim();
if (trimmed && !isProcessing) {
onResponse(trimmed);
setResponse("");
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const canSubmit = Boolean(response.trim()) && !isProcessing;
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
className
)}
{...props}
>
<div className="p-4 space-y-4">
<div className="flex items-start gap-3">
<MessageCircleIcon className="size-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<div>
<h3 className="font-semibold text-sm text-foreground mb-1">
Question from Agent
</h3>
<p className="text-sm text-foreground whitespace-pre-wrap">
{query}
</p>
</div>
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={response}
onChange={(e) => setResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response..."
disabled={isProcessing}
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -46,6 +46,7 @@ export const MessageContent = ({
...props
}: MessageContentProps) => (
<div
data-slot="message-content"
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",

View file

@ -0,0 +1,145 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & {
toolCall: z.infer<typeof ToolCallPart>;
onApprove?: () => void;
onDeny?: () => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
};
export const PermissionRequest = ({
className,
toolCall,
onApprove,
onDeny,
isProcessing = false,
response = null,
...props
}: PermissionRequestProps) => {
// Extract command from arguments if it's executeCommand
const command = toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
: null;
const isResponded = response !== null;
const isApproved = response === 'approve';
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
isResponded
? isApproved
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
className
)}
{...props}
>
<div className="p-4 space-y-4">
<div className="flex items-start gap-3">
{isResponded ? (
isApproved ? (
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
) : (
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
)
) : (
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1">
<h3 className="font-semibold text-sm text-foreground">
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span>
</p>
</div>
{isResponded && (
<Badge
variant="secondary"
className={cn(
"shrink-0",
isApproved
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
)}
>
{isApproved ? (
<>
<CheckIcon className="size-3 mr-1" />
Approved
</>
) : (
<>
<XIcon className="size-3 mr-1" />
Denied
</>
)}
</Badge>
)}
</div>
{command && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Command
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{command}
</pre>
</div>
)}
{!command && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{JSON.stringify(toolCall.arguments, null, 2)}
</pre>
</div>
)}
</div>
</div>
{!isResponded && (
<div className="flex items-center gap-2 pt-2">
<Button
variant="default"
size="sm"
onClick={onApprove}
disabled={isProcessing}
className="flex-1"
>
<CheckIcon className="size-4" />
Approve
</Button>
<Button
variant="destructive"
size="sm"
onClick={onDeny}
disabled={isProcessing}
className="flex-1"
>
<XIcon className="size-4" />
Deny
</Button>
</div>
)}
</div>
</div>
);
};

View file

@ -47,6 +47,10 @@ import {
XIcon,
} from "lucide-react";
import { nanoid } from "nanoid";
import { useMentionDetection } from "@/hooks/use-mention-detection";
import { MentionPopover } from "@/components/mention-popover";
import { toKnowledgePath, wikiLabel } from "@/lib/wiki-links";
import { getMentionHighlightSegments } from "@/lib/mention-highlights";
import {
type ChangeEvent,
type ChangeEventHandler,
@ -83,6 +87,19 @@ export type AttachmentsContext = {
fileInputRef: RefObject<HTMLInputElement | null>;
};
export type FileMention = {
id: string;
path: string; // "knowledge/notes.md"
displayName: string; // "notes"
};
export type MentionsContext = {
mentions: FileMention[];
addMention: (path: string, displayName: string) => void;
removeMention: (id: string) => void;
clearMentions: () => void;
};
export type TextInputContext = {
value: string;
setInput: (v: string) => void;
@ -92,6 +109,7 @@ export type TextInputContext = {
export type PromptInputControllerProps = {
textInput: TextInputContext;
attachments: AttachmentsContext;
mentions: MentionsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (
ref: RefObject<HTMLInputElement | null>,
@ -105,6 +123,7 @@ const PromptInputController = createContext<PromptInputControllerProps | null>(
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
);
const ProviderMentionsContext = createContext<MentionsContext | null>(null);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
@ -133,8 +152,35 @@ export const useProviderAttachments = () => {
const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext);
export const useProviderMentions = () => {
const ctx = useContext(ProviderMentionsContext);
if (!ctx) {
throw new Error(
"Wrap your component inside <PromptInputProvider> to use useProviderMentions()."
);
}
return ctx;
};
const useOptionalProviderMentions = () => useContext(ProviderMentionsContext);
export type KnowledgeFilesContext = {
files: string[];
recentFiles: string[];
visibleFiles: string[];
};
const ProviderKnowledgeFilesContext = createContext<KnowledgeFilesContext | null>(null);
export const useProviderKnowledgeFiles = () => {
return useContext(ProviderKnowledgeFilesContext);
};
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
knowledgeFiles?: string[];
recentFiles?: string[];
visibleFiles?: string[];
}>;
/**
@ -143,6 +189,9 @@ export type PromptInputProviderProps = PropsWithChildren<{
*/
export function PromptInputProvider({
initialInput: initialTextInput = "",
knowledgeFiles = [],
recentFiles = [],
visibleFiles = [],
children,
}: PromptInputProviderProps) {
// ----- textInput state
@ -227,6 +276,37 @@ export function PromptInputProvider({
[attachmentFiles, add, remove, clear, openFileDialog]
);
// ----- mentions state (for @ file mentions)
const [mentionsList, setMentionsList] = useState<FileMention[]>([]);
const addMention = useCallback((path: string, displayName: string) => {
setMentionsList((prev) => {
// Avoid duplicates
if (prev.some((m) => m.path === path)) {
return prev;
}
return [...prev, { id: nanoid(), path, displayName }];
});
}, []);
const removeMention = useCallback((id: string) => {
setMentionsList((prev) => prev.filter((m) => m.id !== id));
}, []);
const clearMentions = useCallback(() => {
setMentionsList([]);
}, []);
const mentions = useMemo<MentionsContext>(
() => ({
mentions: mentionsList,
addMention,
removeMention,
clearMentions,
}),
[mentionsList, addMention, removeMention, clearMentions]
);
const __registerFileInput = useCallback(
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
fileInputRef.current = ref.current;
@ -243,15 +323,25 @@ export function PromptInputProvider({
clear: clearInput,
},
attachments,
mentions,
__registerFileInput,
}),
[textInput, clearInput, attachments, __registerFileInput]
[textInput, clearInput, attachments, mentions, __registerFileInput]
);
const knowledgeFilesContext = useMemo<KnowledgeFilesContext>(
() => ({ files: knowledgeFiles, recentFiles, visibleFiles }),
[knowledgeFiles, recentFiles, visibleFiles]
);
return (
<PromptInputController.Provider value={controller}>
<ProviderAttachmentsContext.Provider value={attachments}>
{children}
<ProviderMentionsContext.Provider value={mentions}>
<ProviderKnowledgeFilesContext.Provider value={knowledgeFilesContext}>
{children}
</ProviderKnowledgeFilesContext.Provider>
</ProviderMentionsContext.Provider>
</ProviderAttachmentsContext.Provider>
</PromptInputController.Provider>
);
@ -820,14 +910,103 @@ export const PromptInputTextarea = ({
onChange,
className,
placeholder = "What would you like to know?",
onKeyDown: externalOnKeyDown,
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const mentionsCtx = useOptionalProviderMentions();
const knowledgeFilesCtx = useProviderKnowledgeFiles();
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const currentValue = controller?.textInput.value ?? "";
const knowledgeFiles = knowledgeFilesCtx?.files ?? [];
const recentFiles = knowledgeFilesCtx?.recentFiles ?? [];
const visibleFiles = knowledgeFilesCtx?.visibleFiles ?? [];
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
const mentionLabels = useMemo(() => {
if (knowledgeFiles.length === 0) return [];
const labels = knowledgeFiles
.map((path) => wikiLabel(path))
.map((label) => label.trim())
.filter(Boolean);
return Array.from(new Set(labels));
}, [knowledgeFiles]);
const { activeMention, cursorCoords } = useMentionDetection(
textareaRef,
currentValue,
knowledgeFiles.length > 0
);
// Use proper regex-based highlight segmentation that handles multi-word names
const mentionHighlights = useMemo(
() => getMentionHighlightSegments(currentValue, activeMention, mentionLabels),
[currentValue, activeMention, mentionLabels]
);
// Sync highlight overlay scroll with textarea
const syncHighlightScroll = useCallback(() => {
const textarea = textareaRef.current;
const highlight = highlightRef.current;
if (!textarea || !highlight) return;
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
}, []);
useEffect(() => {
syncHighlightScroll();
}, [currentValue, mentionHighlights.hasHighlights, syncHighlightScroll]);
const handleMentionSelect = useCallback(
(path: string, displayName: string) => {
if (!controller || !activeMention) return;
// Calculate the text before and after the @query
const currentText = controller.textInput.value;
const beforeAt = currentText.substring(0, activeMention.triggerIndex);
const afterQuery = currentText.substring(
activeMention.triggerIndex + 1 + activeMention.query.length
);
// Replace @query with @displayName followed by a space
const newText = `${beforeAt}@${displayName} ${afterQuery}`;
controller.textInput.setInput(newText);
// Convert to knowledge path and add mention
const fullPath = toKnowledgePath(path);
if (fullPath && mentionsCtx) {
mentionsCtx.addMention(fullPath, displayName);
}
// Focus back on textarea
textareaRef.current?.focus();
},
[controller, activeMention, mentionsCtx]
);
const handleMentionClose = useCallback(() => {
// The popover handles its own closing
}, []);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
// If mention popover is open, let it handle navigation keys
if (activeMention && ["ArrowDown", "ArrowUp", "Tab"].includes(e.key)) {
// Don't prevent default here - the popover handles this via document listener
return;
}
if (e.key === "Enter") {
// If mention popover is open, Enter should select the item
if (activeMention) {
return;
}
if (isComposing || e.nativeEvent.isComposing) {
return;
}
@ -848,6 +1027,53 @@ export const PromptInputTextarea = ({
form?.requestSubmit();
}
// Handle backspace to delete entire mention at once
if (e.key === "Backspace") {
const textarea = e.currentTarget;
const cursorPos = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;
const textValue = controller?.textInput.value ?? textarea.value;
// Only handle if no text is selected (cursor is at a single position)
if (cursorPos === selectionEnd) {
// Check if cursor is right after a mention
for (const label of mentionLabels) {
const mentionText = `@${label}`;
const startPos = cursorPos - mentionText.length;
if (startPos >= 0) {
const textBefore = textValue.substring(startPos, cursorPos);
if (textBefore === mentionText) {
// Check if it's at word boundary (start of string or preceded by whitespace)
if (startPos === 0 || /\s/.test(textValue[startPos - 1])) {
e.preventDefault();
const newText = textValue.substring(0, startPos) + textValue.substring(cursorPos);
if (controller) {
controller.textInput.setInput(newText);
} else {
// Fallback: directly set textarea value and trigger change
textarea.value = newText;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
// Remove the mention from state
if (mentionsCtx) {
const mentionToRemove = mentionsCtx.mentions.find(m => m.displayName === label);
if (mentionToRemove) {
mentionsCtx.removeMention(mentionToRemove.id);
}
}
// Set cursor position after React updates
setTimeout(() => {
textarea.selectionStart = startPos;
textarea.selectionEnd = startPos;
}, 0);
return;
}
}
}
}
}
}
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === "Backspace" &&
@ -860,6 +1086,15 @@ export const PromptInputTextarea = ({
attachments.remove(lastAttachment.id);
}
}
// Close mention popover on Escape
if (e.key === "Escape" && activeMention) {
// Let the popover handle this
return;
}
// Call external handler if provided
externalOnKeyDown?.(e);
};
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
@ -899,17 +1134,54 @@ export const PromptInputTextarea = ({
};
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
/>
<div ref={containerRef} className="relative flex flex-1 min-w-0">
{mentionHighlights.hasHighlights && (
<div
ref={highlightRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-sm text-transparent"
>
{mentionHighlights.segments.map((segment, index) =>
segment.highlighted ? (
<span
key={`mention-${index}`}
className="rounded bg-primary/20 text-transparent [box-decoration-break:clone] shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.15),-3px_0_0_hsl(var(--primary)/0.2),3px_0_0_hsl(var(--primary)/0.2),0_-2px_0_hsl(var(--primary)/0.2),0_2px_0_hsl(var(--primary)/0.2)]"
>
{segment.text}
</span>
) : (
<span key={`text-${index}`}>{segment.text}</span>
)
)}
</div>
)}
<InputGroupTextarea
ref={textareaRef}
className={cn("relative z-10 !p-0 field-sizing-content max-h-48 min-h-10", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onScroll={syncHighlightScroll}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
/>
{knowledgeFiles.length > 0 && (
<MentionPopover
files={knowledgeFiles}
recentFiles={recentFiles}
visibleFiles={visibleFiles}
query={activeMention?.query ?? ""}
position={cursorCoords}
containerRef={containerRef}
onSelect={handleMentionSelect}
onClose={handleMentionClose}
open={Boolean(activeMention)}
/>
)}
</div>
);
};

View file

@ -0,0 +1,76 @@
import { Mail, Calendar, FolderOpen, FileText } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface Suggestion {
id: string
label: string
prompt: string
icon: React.ReactNode
}
const defaultSuggestions: Suggestion[] = [
{
id: 'email-draft',
label: 'Draft an email',
prompt: "Let's draft an email response to [name]",
icon: <Mail className="h-4 w-4" />,
},
{
id: 'meeting-prep',
label: 'Prep for a meeting',
prompt: 'Help me prep for my next meeting with [name]',
icon: <Calendar className="h-4 w-4" />,
},
{
id: 'doc-collab',
label: 'Work on a document',
prompt: "Let's work on [document name]",
icon: <FileText className="h-4 w-4" />,
},
{
id: 'organize-files',
label: 'Organize files',
prompt: 'Help me organize [folder or files]',
icon: <FolderOpen className="h-4 w-4" />,
},
]
interface SuggestionsProps {
suggestions?: Suggestion[]
onSelect: (prompt: string) => void
className?: string
vertical?: boolean
}
export function Suggestions({
suggestions = defaultSuggestions,
onSelect,
className,
vertical = false,
}: SuggestionsProps) {
return (
<div className={cn(
'flex gap-2',
vertical ? 'flex-col items-start' : 'flex-wrap justify-center',
className
)}>
{suggestions.map((suggestion) => (
<button
key={suggestion.id}
onClick={() => onSelect(suggestion.prompt)}
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-full',
'text-sm text-muted-foreground',
'border border-border bg-background',
'hover:bg-muted hover:text-foreground hover:border-muted-foreground/30',
'transition-colors duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
>
{suggestion.icon}
<span>{suggestion.label}</span>
</button>
))}
</div>
)
}

View file

@ -1,6 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import { ArrowUp, PanelRightClose, Plus } from 'lucide-react'
import type { LanguageModelUsage, ToolUIPart } from 'ai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUp, Expand, Plus } from 'lucide-react'
import type { ToolUIPart } from 'ai'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
@ -12,7 +12,6 @@ import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import {
Message,
@ -22,6 +21,17 @@ import {
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions'
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
import { useMentionDetection } from '@/hooks/use-mention-detection'
import { MentionPopover } from '@/components/mention-popover'
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { getMentionHighlightSegments } from '@/lib/mention-highlights'
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
import z from 'zod'
import React from 'react'
interface ChatMessage {
id: string
@ -98,24 +108,33 @@ const DEFAULT_WIDTH = 400
interface ChatSidebarProps {
defaultWidth?: number
onClose: () => void
isOpen?: boolean
onNewChat: () => void
onOpenFullScreen?: () => void
conversation: ConversationItem[]
currentAssistantMessage: string
currentReasoning: string
isProcessing: boolean
message: string
onMessageChange: (message: string) => void
onSubmit: (message: { text: string }) => void
contextUsage: LanguageModelUsage
maxTokens: number
usedTokens: number
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
selectedPath?: string | null
pendingPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
pendingAskHumanRequests?: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses?: Map<string, 'approve' | 'deny'>
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
}
export function ChatSidebar({
defaultWidth = DEFAULT_WIDTH,
onClose,
isOpen = true,
onNewChat,
onOpenFullScreen,
conversation,
currentAssistantMessage,
currentReasoning,
@ -123,12 +142,101 @@ export function ChatSidebar({
message,
onMessageChange,
onSubmit,
knowledgeFiles = [],
recentFiles = [],
visibleFiles = [],
selectedPath,
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
permissionResponses = new Map(),
onPermissionResponse,
onAskHumanResponse,
}: ChatSidebarProps) {
const [width, setWidth] = useState(defaultWidth)
const [isResizing, setIsResizing] = useState(false)
const [showContent, setShowContent] = useState(isOpen)
// Delay showing content when opening, hide immediately when closing
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => setShowContent(true), 150)
return () => clearTimeout(timer)
} else {
setShowContent(false)
}
}, [isOpen])
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const highlightRef = useRef<HTMLDivElement>(null)
const [mentions, setMentions] = useState<FileMention[]>([])
const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
const lastSelectedPathRef = useRef<string | null>(null)
// Build mention labels for highlighting (handles multi-word names like "AI Agents")
const mentionLabels = useMemo(() => {
if (knowledgeFiles.length === 0) return []
const labels = knowledgeFiles
.map((path) => wikiLabel(path))
.map((label) => label.trim())
.filter(Boolean)
return Array.from(new Set(labels))
}, [knowledgeFiles])
const { activeMention, cursorCoords } = useMentionDetection(
textareaRef,
message,
knowledgeFiles.length > 0
)
// Use proper regex-based highlight segmentation that handles multi-word names
const mentionHighlights = useMemo(
() => getMentionHighlightSegments(message, activeMention, mentionLabels),
[message, activeMention, mentionLabels]
)
// Sync highlight overlay scroll with textarea
const syncHighlightScroll = useCallback(() => {
const textarea = textareaRef.current
const highlight = highlightRef.current
if (!textarea || !highlight) return
highlight.scrollTop = textarea.scrollTop
highlight.scrollLeft = textarea.scrollLeft
}, [])
useEffect(() => {
syncHighlightScroll()
}, [message, mentionHighlights.hasHighlights, syncHighlightScroll])
const handleMentionSelect = useCallback(
(path: string, displayName: string) => {
if (!activeMention) return
const beforeAt = message.substring(0, activeMention.triggerIndex)
const afterQuery = message.substring(
activeMention.triggerIndex + 1 + activeMention.query.length
)
const newText = `${beforeAt}@${displayName} ${afterQuery}`
onMessageChange(newText)
const fullPath = toKnowledgePath(path)
if (fullPath) {
setMentions(prev => {
if (prev.some(m => m.path === fullPath)) return prev
return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }]
})
}
textareaRef.current?.focus()
},
[activeMention, message, onMessageChange]
)
const handleMentionClose = useCallback(() => {
// The popover handles its own closing
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
@ -152,20 +260,117 @@ export function ChatSidebar({
document.addEventListener('mouseup', handleMouseUp)
}, [width])
// Auto-focus textarea when sidebar opens
useEffect(() => {
textareaRef.current?.focus()
}, [])
// Auto-populate with @currentfile when switching knowledge files
useEffect(() => {
if (selectedPath === lastSelectedPathRef.current) return
lastSelectedPathRef.current = selectedPath ?? null
if (!selectedPath || !selectedPath.startsWith('knowledge/') || !selectedPath.endsWith('.md')) {
return
}
const displayName = wikiLabel(selectedPath)
const previousAuto = autoMentionRef.current
const trimmed = message.trim()
const previousToken = previousAuto ? `@${previousAuto.displayName}` : null
const shouldReplace = !trimmed || (previousToken && trimmed === previousToken)
if (!shouldReplace) {
return
}
const nextText = `@${displayName} `
if (message !== nextText) {
onMessageChange(nextText)
}
setMentions((prev) => {
const withoutPrevious = previousAuto
? prev.filter((mention) => mention.path !== previousAuto.path)
: prev
if (withoutPrevious.some((mention) => mention.path === selectedPath)) {
return withoutPrevious
}
return [
...withoutPrevious,
{
id: `mention-auto-${Date.now()}`,
path: selectedPath,
displayName,
},
]
})
autoMentionRef.current = { path: selectedPath, displayName }
}, [selectedPath, message, onMessageChange])
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
const canSubmit = Boolean(message.trim()) && !isProcessing
const handleSubmit = () => {
const trimmed = message.trim()
if (trimmed && !isProcessing) {
onSubmit({ text: trimmed })
onSubmit({ text: trimmed, files: [] }, mentions)
setMentions([])
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// If mention popover is open, let it handle navigation keys
if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) {
return
}
if (e.key === 'Enter') {
// If mention popover is open, Enter should select the item
if (activeMention) {
return
}
if (!e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
// Handle backspace to delete entire mention at once
if (e.key === 'Backspace') {
const textarea = e.currentTarget
const cursorPos = textarea.selectionStart
const selectionEnd = textarea.selectionEnd
// Only handle if no text is selected (cursor is at a single position)
if (cursorPos !== selectionEnd) return
// Check if cursor is right after a mention
for (const label of mentionLabels) {
const mentionText = `@${label}`
const startPos = cursorPos - mentionText.length
if (startPos >= 0) {
const textBefore = message.substring(startPos, cursorPos)
if (textBefore === mentionText) {
// Check if it's at word boundary (start of string or preceded by whitespace)
if (startPos === 0 || /\s/.test(message[startPos - 1])) {
e.preventDefault()
const newText = message.substring(0, startPos) + message.substring(cursorPos)
onMessageChange(newText)
// Remove the mention from state
setMentions(prev => prev.filter(m => m.displayName !== label))
// Set cursor position after React updates
setTimeout(() => {
textarea.selectionStart = startPos
textarea.selectionEnd = startPos
}, 0)
return
}
}
}
}
}
}
@ -217,10 +422,15 @@ export function ChatSidebar({
return null
}
const displayWidth = isOpen ? width : 0
return (
<div
className="relative flex flex-col border-l border-border bg-background shrink-0"
style={{ width }}
className={cn(
"relative flex flex-col border-l border-border bg-background shrink-0 overflow-hidden",
!isResizing && "transition-[width] duration-200 ease-linear"
)}
style={{ width: displayWidth }}
>
{/* Resize handle */}
<div
@ -233,25 +443,30 @@ export function ChatSidebar({
)}
/>
{/* Header - minimal, no border */}
<header className="flex h-12 shrink-0 items-center justify-between px-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8 text-muted-foreground hover:text-foreground">
<PanelRightClose className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close</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>
{/* Content - delayed on open, hidden immediately on close to avoid layout issues during animation */}
{showContent && (
<>
{/* Header - minimal, expand and new chat buttons */}
<header className="flex h-12 shrink-0 items-center justify-end gap-1 px-2">
{onOpenFullScreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onOpenFullScreen} className="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 */}
<div className="flex min-h-0 flex-1 flex-col relative">
@ -267,7 +482,39 @@ export function ChatSidebar({
</ConversationEmptyState>
) : (
<>
{conversation.map(item => renderConversationItem(item))}
{conversation.map(item => {
const rendered = renderConversationItem(item)
// If this is a tool call, check for permission request (pending or responded)
if (isToolCall(item) && onPermissionResponse) {
const permRequest = allPermissionRequests.get(item.id)
if (permRequest) {
const response = permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing}
response={response}
/>
</React.Fragment>
)
}
}
return rendered
})}
{/* Render pending ask-human requests */}
{onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isProcessing}
/>
))}
{currentReasoning && (
<Reasoning isStreaming>
@ -294,22 +541,55 @@ export function ChatSidebar({
</>
)}
</ConversationContent>
<ConversationScrollButton className="bottom-24" />
</Conversation>
{/* Input area - responsive to sidebar width, matches floating bar position exactly */}
<div className="absolute bottom-6 left-14 right-6 z-10">
<div className="flex items-center gap-2 bg-background border border-border rounded-full shadow-xl px-4 py-2.5">
<input
ref={inputRef}
type="text"
value={message}
onChange={(e) => onMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything..."
disabled={isProcessing}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50"
<div className="absolute bottom-6 left-14 right-6 z-10" ref={containerRef}>
{!hasConversation && (
<Suggestions
onSelect={(prompt) => {
onMessageChange(prompt)
setTimeout(() => textareaRef.current?.focus(), 0)
}}
vertical
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="relative flex-1 min-w-0">
{mentionHighlights.hasHighlights && (
<div
ref={highlightRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap wrap-break-word text-sm text-transparent"
>
{mentionHighlights.segments.map((segment, index) =>
segment.highlighted ? (
<span
key={`mention-${index}`}
className="rounded bg-primary/20 text-transparent [box-decoration-break:clone] shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.15),-3px_0_0_hsl(var(--primary)/0.2),3px_0_0_hsl(var(--primary)/0.2),0_-2px_0_hsl(var(--primary)/0.2),0_2px_0_hsl(var(--primary)/0.2)]"
>
{segment.text}
</span>
) : (
<span key={`text-${index}`}>{segment.text}</span>
)
)}
</div>
)}
<textarea
ref={textareaRef}
value={message}
onChange={(e) => onMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
onScroll={syncHighlightScroll}
placeholder="Ask anything..."
disabled={isProcessing}
rows={1}
className="relative z-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-6"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
</div>
<Button
size="icon"
onClick={handleSubmit}
@ -324,8 +604,23 @@ export function ChatSidebar({
<ArrowUp className="h-4 w-4" />
</Button>
</div>
{knowledgeFiles.length > 0 && (
<MentionPopover
files={knowledgeFiles}
recentFiles={recentFiles}
visibleFiles={visibleFiles}
query={activeMention?.query ?? ''}
position={cursorCoords}
containerRef={containerRef}
onSelect={handleMentionSelect}
onClose={handleMentionClose}
open={Boolean(activeMention)}
/>
)}
</div>
</div>
</>
)}
</div>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Database, Loader2, Plug } from "lucide-react"
import { Loader2, Mic, Mail } from "lucide-react"
import {
Popover,
@ -15,10 +15,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { toast } from "@/lib/toast"
import { toast } from "sonner"
interface ProviderState {
isConnected: boolean
@ -78,10 +77,10 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
setGranolaLoading(true)
await window.ipc.invoke('granola:setConfig', { enabled })
setGranolaEnabled(enabled)
toast(enabled ? 'Granola sync enabled' : 'Granola sync disabled', 'success')
toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
} catch (error) {
console.error('Failed to update Granola config:', error)
toast('Failed to update Granola sync settings', 'error')
toast.error('Failed to update Granola sync settings')
} finally {
setGranolaLoading(false)
}
@ -127,6 +126,41 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}, [open, providers, refreshAllStatuses])
// Listen for OAuth completion events
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
const { provider, success, error } = event
setProviderStates(prev => ({
...prev,
[provider]: {
isConnected: success,
isLoading: false,
isConnecting: false,
}
}))
if (success) {
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
// Show detailed message for Google and Fireflies (includes sync info)
if (provider === 'google' || provider === 'fireflies-ai') {
toast.success(`Connected to ${displayName}`, {
description: 'Syncing your data in the background. This may take a few minutes before changes appear.',
duration: 8000,
})
} else {
toast.success(`Connected to ${displayName}`)
}
// Refresh status to ensure consistency
refreshAllStatuses()
} else {
toast.error(error || `Failed to connect to ${provider}`)
}
})
return cleanup
}, [refreshAllStatuses])
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
setProviderStates(prev => ({
@ -138,19 +172,11 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
const result = await window.ipc.invoke('oauth:connect', { provider })
if (result.success) {
toast(`Successfully connected to ${provider}`, 'success')
// Refresh the status after successful connection
const checkResult = await window.ipc.invoke('oauth:is-connected', { provider })
setProviderStates(prev => ({
...prev,
[provider]: {
isConnected: checkResult.isConnected,
isLoading: false,
isConnecting: false,
}
}))
// OAuth flow started - keep isConnecting state, wait for event
// Event listener will handle the actual completion
} else {
toast(result.error || `Failed to connect to ${provider}`, 'error')
// Immediate failure (e.g., couldn't start flow)
toast.error(result.error || `Failed to connect to ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: false }
@ -158,7 +184,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
} catch (error) {
console.error('Failed to connect:', error)
toast(`Failed to connect to ${provider}`, 'error')
toast.error(`Failed to connect to ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: false }
@ -177,7 +203,8 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
const result = await window.ipc.invoke('oauth:disconnect', { provider })
if (result.success) {
toast(`Disconnected from ${provider}`, 'success')
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
toast.success(`Disconnected from ${displayName}`)
setProviderStates(prev => ({
...prev,
[provider]: {
@ -187,7 +214,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}))
} else {
toast(`Failed to disconnect from ${provider}`, 'error')
toast.error(`Failed to disconnect from ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isLoading: false }
@ -195,7 +222,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
} catch (error) {
console.error('Failed to disconnect:', error)
toast(`Failed to disconnect from ${provider}`, 'error')
toast.error(`Failed to disconnect from ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isLoading: false }
@ -203,6 +230,64 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}, [])
// Helper to render an OAuth provider row
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = providerStates[provider] || {
isConnected: false,
isLoading: true,
isConnecting: false,
}
return (
<div
key={provider}
className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
{icon}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{displayName}</span>
{state.isLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">{description}</span>
)}
</div>
</div>
<div className="shrink-0">
{state.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : state.isConnected ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDisconnect(provider)}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={() => handleConnect(provider)}
disabled={state.isConnecting}
className="h-7 px-2 text-xs"
>
{state.isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
@ -234,123 +319,56 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
</p>
</div>
<div className="p-2">
{/* Data Sources Section */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Data Sources</span>
</div>
{/* Granola */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Database className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Granola</span>
<span className="text-xs text-muted-foreground truncate">
Sync meeting notes
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{granolaLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={granolaEnabled}
onCheckedChange={handleGranolaToggle}
disabled={granolaLoading}
/>
</div>
</div>
<Separator className="my-2" />
{/* OAuth Connectors Section */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Accounts</span>
</div>
{providersLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : providers.length === 0 ? (
<div className="text-center py-4 text-xs text-muted-foreground">
No account connectors available
</div>
) : (
<div className="flex flex-col gap-1">
{providers.map((provider) => {
const state = providerStates[provider] || {
isConnected: false,
isLoading: true,
isConnecting: false,
}
const displayName = provider.charAt(0).toUpperCase() + provider.slice(1)
return (
<div
key={provider}
className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Plug className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">
{displayName}
</span>
{state.isLoading ? (
<span className="text-xs text-muted-foreground">
Checking...
</span>
) : (
<Badge
variant={state.isConnected ? "default" : "outline"}
className="w-fit text-xs mt-0.5"
>
{state.isConnected ? "Connected" : "Not Connected"}
</Badge>
)}
</div>
</div>
<div className="shrink-0">
{state.isConnected ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDisconnect(provider)}
disabled={state.isLoading}
className="h-7 px-2 text-xs"
>
{state.isLoading ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Disconnect"
)}
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={() => handleConnect(provider)}
disabled={state.isConnecting || state.isLoading}
className="h-7 px-2 text-xs"
>
{state.isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
<>
{/* Email & Calendar Section - Google */}
{providers.includes('google') && (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Email & Calendar</span>
</div>
)
})}
</div>
{renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')}
<Separator className="my-2" />
</>
)}
{/* Meeting Notes Section - Granola & Fireflies */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Meeting Notes</span>
</div>
{/* Granola */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mic className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Granola</span>
<span className="text-xs text-muted-foreground truncate">
Local meeting notes
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{granolaLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={granolaEnabled}
onCheckedChange={handleGranolaToggle}
disabled={granolaLoading}
/>
</div>
</div>
{/* Fireflies */}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
</>
)}
</div>
</PopoverContent>

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef } from 'react'
import type { Editor } from '@tiptap/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -26,16 +26,19 @@ import {
Redo2Icon,
ExternalLinkIcon,
Trash2Icon,
ImageIcon,
} from 'lucide-react'
interface EditorToolbarProps {
editor: Editor | null
onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void
}
export function EditorToolbar({ editor, onSelectionHighlight }: EditorToolbarProps) {
export function EditorToolbar({ editor, onSelectionHighlight, onImageUpload }: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const openLinkPopover = useCallback(() => {
if (!editor) return
@ -79,6 +82,23 @@ export function EditorToolbar({ editor, onSelectionHighlight }: EditorToolbarPro
closeLinkPopover()
}, [editor, closeLinkPopover])
const handleImageUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !onImageUpload) return
// Reset file input immediately
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
// Call the upload handler (which handles placeholder insertion)
try {
await onImageUpload(file)
} catch (error) {
console.error('Failed to upload image:', error)
}
}, [onImageUpload])
if (!editor) return null
const isLinkActive = editor.isActive('link')
@ -320,6 +340,27 @@ export function EditorToolbar({ editor, onSelectionHighlight }: EditorToolbarPro
</div>
</PopoverContent>
</Popover>
{/* Image upload */}
{onImageUpload && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => fileInputRef.current?.click()}
title="Insert Image"
>
<ImageIcon className="size-4" />
</Button>
</>
)}
</div>
)
}

View file

@ -68,7 +68,7 @@ export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: Grap
const hasCenteredRef = useRef(false)
const [viewport, setViewport] = useState({ width: 1, height: 1 })
const [pan, setPan] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [zoom, setZoom] = useState(0.6)
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
@ -501,7 +501,7 @@ export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: Grap
style={{ backgroundColor: item.color, boxShadow: `0 0 0 1px ${item.stroke}` }}
/>
<span className="truncate">{item.label}</span>
{isSelected && <X className="ml-auto size-3 text-muted-foreground" />}
<X className={`ml-auto size-3 ${isSelected ? 'text-muted-foreground' : 'invisible'}`} />
</button>
)
})}

View file

@ -25,7 +25,7 @@ export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
const [open, setOpen] = useState(false)
const handleDiscordClick = () => {
window.open("https://discord.com/invite/rxB8pzHxaS", "_blank")
window.open("https://discord.gg/htdKpBZF", "_blank")
}
return (
@ -75,6 +75,25 @@ export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
</div>
</Button>
</div>
<div className="px-4 py-3 border-t flex justify-center gap-3 text-xs text-muted-foreground">
<a
href="https://www.rowboatlabs.com/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Terms of Service
</a>
<span>·</span>
<a
href="https://www.rowboatlabs.com/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</a>
</div>
</PopoverContent>
</Popover>
)

View file

@ -3,9 +3,11 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list'
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'
import { EditorToolbar } from './editor-toolbar'
@ -27,6 +29,7 @@ interface MarkdownEditorProps {
onChange: (markdown: string) => void
placeholder?: string
wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null>
}
type WikiLinkMatch = {
@ -74,6 +77,7 @@ export function MarkdownEditor({
onChange,
placeholder = 'Start writing...',
wikiLinks,
onImageUpload,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
@ -105,6 +109,14 @@ export function MarkdownEditor({
target: '_blank',
},
}),
Image.configure({
inline: false,
allowBase64: true,
HTMLAttributes: {
class: 'editor-image',
},
}),
ImageUploadPlaceholderExtension,
WikiLink.configure({
onCreate: wikiLinks?.onCreate
? (path) => {
@ -298,9 +310,15 @@ export function MarkdownEditor({
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
// Create image upload handler that shows placeholder
const handleImageUploadWithPlaceholder = useMemo(() => {
if (!editor || !onImageUpload) return undefined
return createImageUploadHandler(editor, onImageUpload)
}, [editor, onImageUpload])
return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar editor={editor} onSelectionHighlight={setSelectionHighlight} />
<EditorToolbar editor={editor} onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} />
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} />
{wikiLinks ? (

View file

@ -0,0 +1,185 @@
import { useMemo, useEffect, useState, useCallback } from 'react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { wikiLabel, stripKnowledgePrefix } from '@/lib/wiki-links'
import { FileTextIcon } from 'lucide-react'
import type { CaretCoordinates } from '@/lib/textarea-caret'
interface MentionPopoverProps {
files: string[]
recentFiles?: string[]
visibleFiles?: string[]
query: string
position: CaretCoordinates | null
containerRef: React.RefObject<HTMLElement | null>
onSelect: (path: string, displayName: string) => void
onClose: () => void
open: boolean
}
const MAX_VISIBLE_FILES = 8
export function MentionPopover({
files,
recentFiles = [],
visibleFiles = [],
query,
position,
containerRef: _containerRef,
onSelect,
onClose,
open,
}: MentionPopoverProps) {
void _containerRef // Reserved for future positioning logic
const [selectedIndex, setSelectedIndex] = useState(0)
// Order files: visible > recent > rest, then filter by query
const orderedAndFilteredFiles = useMemo(() => {
const lowerQuery = query.toLowerCase()
// Create sets for quick lookup
const visibleSet = new Set(visibleFiles)
const recentSet = new Set(recentFiles)
const allFiles = new Set(files)
// Categorize files
const visible: string[] = []
const recent: string[] = []
const rest: string[] = []
for (const file of files) {
if (visibleSet.has(file)) {
visible.push(file)
} else if (recentSet.has(file)) {
recent.push(file)
} else {
rest.push(file)
}
}
// Maintain recent order for recent files
const orderedRecent = recentFiles.filter(f => allFiles.has(f) && !visibleSet.has(f))
// Combine in order: visible > recent > rest
const ordered = [...visible, ...orderedRecent, ...rest]
// Filter by query if present
if (!query) return ordered.slice(0, MAX_VISIBLE_FILES)
return ordered
.filter((path) => {
const label = wikiLabel(path).toLowerCase()
const normalized = stripKnowledgePrefix(path).toLowerCase()
return label.includes(lowerQuery) || normalized.includes(lowerQuery)
})
.slice(0, MAX_VISIBLE_FILES)
}, [files, recentFiles, visibleFiles, query])
// Reset selection when filtered list changes
useEffect(() => {
setSelectedIndex(0)
}, [orderedAndFilteredFiles.length, query])
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!open) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => (prev + 1) % orderedAndFilteredFiles.length)
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => (prev - 1 + orderedAndFilteredFiles.length) % orderedAndFilteredFiles.length)
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (orderedAndFilteredFiles[selectedIndex]) {
const path = orderedAndFilteredFiles[selectedIndex]
onSelect(path, wikiLabel(path))
}
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
onClose()
break
case 'Tab':
e.preventDefault()
e.stopPropagation()
if (orderedAndFilteredFiles[selectedIndex]) {
const path = orderedAndFilteredFiles[selectedIndex]
onSelect(path, wikiLabel(path))
}
break
}
},
[open, orderedAndFilteredFiles, selectedIndex, onSelect, onClose]
)
// Attach keyboard listener
useEffect(() => {
if (!open) return
// Use capture phase to intercept before textarea handles it
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [open, handleKeyDown])
if (!open || !position || orderedAndFilteredFiles.length === 0) {
return null
}
return (
<Popover open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<PopoverAnchor asChild>
<span
className="mention-popover-anchor"
style={{
position: 'absolute',
left: position.left,
top: position.top + position.height + 4,
width: 0,
height: 0,
pointerEvents: 'none',
}}
/>
</PopoverAnchor>
<PopoverContent
className="w-64 p-1"
align="start"
side="bottom"
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
{orderedAndFilteredFiles.length === 0 ? (
<CommandEmpty>No files found</CommandEmpty>
) : (
orderedAndFilteredFiles.map((path, index) => (
<CommandItem
key={path}
value={path}
onSelect={() => onSelect(path, wikiLabel(path))}
className={index === selectedIndex ? 'bg-accent' : ''}
onMouseEnter={() => setSelectedIndex(index)}
>
<FileTextIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{wikiLabel(path)}</span>
</CommandItem>
))
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,446 @@
"use client"
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
interface ProviderState {
isConnected: boolean
isLoading: boolean
isConnecting: boolean
}
interface OnboardingModalProps {
open: boolean
onComplete: () => void
}
type Step = 0 | 1 | 2
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState<Step>(0)
// OAuth provider states
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
// Granola state
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Track connected providers for the completion step
const connectedProviders = Object.entries(providerStates)
.filter(([, state]) => state.isConnected)
.map(([provider]) => provider)
// Load available providers on mount
useEffect(() => {
if (!open) return
async function loadProviders() {
try {
setProvidersLoading(true)
const result = await window.ipc.invoke('oauth:list-providers', null)
setProviders(result.providers || [])
} catch (error) {
console.error('Failed to get available providers:', error)
setProviders([])
} finally {
setProvidersLoading(false)
}
}
loadProviders()
}, [open])
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
try {
setGranolaLoading(true)
const result = await window.ipc.invoke('granola:getConfig', null)
setGranolaEnabled(result.enabled)
} catch (error) {
console.error('Failed to load Granola config:', error)
setGranolaEnabled(false)
} finally {
setGranolaLoading(false)
}
}, [])
// Update Granola config
const handleGranolaToggle = useCallback(async (enabled: boolean) => {
try {
setGranolaLoading(true)
await window.ipc.invoke('granola:setConfig', { enabled })
setGranolaEnabled(enabled)
toast.success(enabled ? 'Granola sync enabled' : 'Granola sync disabled')
} catch (error) {
console.error('Failed to update Granola config:', error)
toast.error('Failed to update Granola sync settings')
} finally {
setGranolaLoading(false)
}
}, [])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
// Refresh Granola
refreshGranolaConfig()
// Refresh OAuth providers
if (providers.length === 0) return
const newStates: Record<string, ProviderState> = {}
await Promise.all(
providers.map(async (provider) => {
try {
const result = await window.ipc.invoke('oauth:is-connected', { provider })
newStates[provider] = {
isConnected: result.isConnected,
isLoading: false,
isConnecting: false,
}
} catch (error) {
console.error(`Failed to check connection status for ${provider}:`, error)
newStates[provider] = {
isConnected: false,
isLoading: false,
isConnecting: false,
}
}
})
)
setProviderStates(newStates)
}, [providers, refreshGranolaConfig])
// Refresh statuses when modal opens or providers list changes
useEffect(() => {
if (open && providers.length > 0) {
refreshAllStatuses()
}
}, [open, providers, refreshAllStatuses])
// Listen for OAuth completion events
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
const { provider, success, error } = event
setProviderStates(prev => ({
...prev,
[provider]: {
isConnected: success,
isLoading: false,
isConnecting: false,
}
}))
if (success) {
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
toast.success(`Connected to ${displayName}`)
} else {
toast.error(error || `Failed to connect to ${provider}`)
}
})
return cleanup
}, [])
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider })
if (!result.success) {
toast.error(result.error || `Failed to connect to ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: false }
}))
}
} catch (error) {
console.error('Failed to connect:', error)
toast.error(`Failed to connect to ${provider}`)
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: false }
}))
}
}, [])
const handleNext = () => {
if (currentStep < 2) {
setCurrentStep((prev) => (prev + 1) as Step)
}
}
const handleComplete = () => {
onComplete()
}
// Step indicator component
const StepIndicator = () => (
<div className="flex gap-2 justify-center mb-6">
{[0, 1, 2].map((step) => (
<div
key={step}
className={cn(
"w-2 h-2 rounded-full transition-colors",
currentStep >= step ? "bg-primary" : "bg-muted"
)}
/>
))}
</div>
)
// Helper to render an OAuth provider row
const renderOAuthProvider = (provider: string, displayName: string, icon: React.ReactNode, description: string) => {
const state = providerStates[provider] || {
isConnected: false,
isLoading: true,
isConnecting: false,
}
return (
<div
key={provider}
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">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
{icon}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{displayName}</span>
{state.isLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">{description}</span>
)}
</div>
</div>
<div className="shrink-0">
{state.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : state.isConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
<span>Connected</span>
</div>
) : (
<Button
variant="default"
size="sm"
onClick={() => handleConnect(provider)}
disabled={state.isConnecting}
>
{state.isConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
}
// Render Granola row
const renderGranolaRow = () => (
<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">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<Mic className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Granola</span>
<span className="text-xs text-muted-foreground truncate">
Local meeting notes
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{granolaLoading && (
<Loader2 className="size-3 animate-spin" />
)}
<Switch
checked={granolaEnabled}
onCheckedChange={handleGranolaToggle}
disabled={granolaLoading}
/>
</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: 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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{providersLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Email & Calendar Section */}
{providers.includes('google') && (
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email & Calendar</span>
</div>
{renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')}
</div>
)}
{/* Meeting Notes Section */}
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting Notes</span>
</div>
{renderGranolaRow()}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
</div>
</>
)}
</div>
<div className="flex flex-col gap-3 mt-8">
<Button onClick={handleNext} size="lg">
Continue
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
</div>
</div>
)
// Step 2: Completion
const CompletionStep = () => {
const hasConnections = connectedProviders.length > 0 || granolaEnabled
return (
<div className="flex flex-col items-center text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-green-100 mb-6">
<CheckCircle2 className="size-10 text-green-600" />
</div>
<DialogHeader className="space-y-3">
<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.</>
) : (
<>You can connect your accounts anytime from the sidebar to start syncing data.</>
)}
</DialogDescription>
</DialogHeader>
{hasConnections && (
<div className="mt-6 w-full max-w-sm">
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm font-medium mb-2">Connected accounts:</p>
<div className="space-y-1">
{connectedProviders.includes('google') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Google (Email & Calendar)</span>
</div>
)}
{connectedProviders.includes('fireflies-ai') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Fireflies (Meeting transcripts)</span>
</div>
)}
{granolaEnabled && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Granola (Local meeting notes)</span>
</div>
)}
</div>
</div>
</div>
)}
<Button onClick={handleComplete} size="lg" className="mt-8 w-full max-w-xs">
Start Using Rowboat
</Button>
</div>
)
}
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
showCloseButton={false}
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<StepIndicator />
{currentStep === 0 && <WelcomeStep />}
{currentStep === 1 && <AccountConnectionStep />}
{currentStep === 2 && <CompletionStep />}
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,229 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { Server, Key, Shield } from "lucide-react"
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type ConfigTab = "models" | "mcp" | "security"
interface TabConfig {
id: ConfigTab
label: string
icon: React.ElementType
path: string
description: string
}
const tabs: TabConfig[] = [
{
id: "models",
label: "Models",
icon: Key,
path: "config/models.json",
description: "Configure LLM providers and API keys",
},
{
id: "mcp",
label: "MCP Servers",
icon: Server,
path: "config/mcp.json",
description: "Configure MCP server connections",
},
{
id: "security",
label: "Security",
icon: Shield,
path: "config/security.json",
description: "Configure allowed shell commands",
},
]
interface SettingsDialogProps {
children: React.ReactNode
}
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const activeTabConfig = tabs.find((t) => t.id === activeTab)!
const loadConfig = async (tab: ConfigTab) => {
const tabConfig = tabs.find((t) => t.id === tab)!
setLoading(true)
setError(null)
try {
const result = await window.ipc.invoke("workspace:readFile", {
path: tabConfig.path,
})
const formattedContent = formatJson(result.data)
setContent(formattedContent)
setOriginalContent(formattedContent)
} catch (err) {
setError(`Failed to load ${tabConfig.label} config`)
setContent("")
setOriginalContent("")
} finally {
setLoading(false)
}
}
const saveConfig = async () => {
setSaving(true)
setError(null)
try {
// Validate JSON before saving
JSON.parse(content)
await window.ipc.invoke("workspace:writeFile", {
path: activeTabConfig.path,
data: content,
})
setOriginalContent(content)
} catch (err) {
if (err instanceof SyntaxError) {
setError("Invalid JSON syntax")
} else {
setError(`Failed to save ${activeTabConfig.label} config`)
}
} finally {
setSaving(false)
}
}
const formatJson = (jsonString: string): string => {
try {
return JSON.stringify(JSON.parse(jsonString), null, 2)
} catch {
return jsonString
}
}
const handleFormat = () => {
setContent(formatJson(content))
}
const hasChanges = content !== originalContent
useEffect(() => {
if (open) {
loadConfig(activeTab)
}
}, [open, activeTab])
const handleTabChange = (tab: ConfigTab) => {
if (hasChanges) {
if (!confirm("You have unsaved changes. Discard them?")) {
return
}
}
setActiveTab(tab)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="!max-w-[900px] w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
showCloseButton={false}
>
<div className="flex h-full">
{/* Sidebar */}
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
<div className="px-2 py-3 mb-2">
<h2 className="font-semibold text-sm">Settings</h2>
</div>
<nav className="flex flex-col gap-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={cn(
"flex items-center gap-2 px-2 py-2 rounded-md text-sm transition-colors text-left",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
<tab.icon className="size-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<div className="px-4 py-3 border-b">
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{activeTabConfig.description}
</p>
</div>
{/* Editor */}
<div className="flex-1 p-4 overflow-hidden">
{loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...
</div>
) : (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-full resize-none bg-muted/50 rounded-md p-3 font-mono text-sm border-0 focus:outline-none focus:ring-1 focus:ring-ring"
spellCheck={false}
placeholder="Loading configuration..."
/>
)}
</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>
)}
</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>
</Dialog>
)
}

View file

@ -3,7 +3,6 @@
import * as React from "react"
import { useState } from "react"
import {
CalendarDays,
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
@ -12,11 +11,10 @@ import {
FilePlus,
Folder,
FolderPlus,
Mail,
Microscope,
MessageSquare,
Network,
Pencil,
Plus,
SquarePen,
Trash2,
} from "lucide-react"
@ -30,7 +28,6 @@ import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
@ -73,44 +70,43 @@ type KnowledgeActions = {
copyPath: (path: string) => void
}
type RunListItem = {
id: string
title?: string
createdAt: string
agentId: string
}
type TasksActions = {
onNewChat: () => void
onSelectRun: (runId: string) => void
}
type SidebarContentPanelProps = {
tree: TreeNode[]
selectedPath: string | null
expandedPaths: Set<string>
onSelectFile: (path: string, kind: "file" | "dir") => void
knowledgeActions: KnowledgeActions
runs?: RunListItem[]
currentRunId?: string | null
tasksActions?: TasksActions
} & React.ComponentProps<typeof Sidebar>
const sectionTitles = {
knowledge: "Knowledge",
agents: "Agents",
tasks: "Tasks",
}
const agentPresets = [
{
name: "Email Assistant",
description: "Draft replies, summarize threads.",
icon: Mail,
},
{
name: "Meeting Prep",
description: "Build briefs and talking points.",
icon: CalendarDays,
},
{
name: "Research",
description: "Gather sources, outline findings.",
icon: Microscope,
},
]
export function SidebarContentPanel({
tree,
selectedPath,
expandedPaths,
onSelectFile,
knowledgeActions,
runs = [],
currentRunId,
tasksActions,
...props
}: SidebarContentPanelProps) {
const { activeSection } = useSidebarSection()
@ -132,8 +128,12 @@ export function SidebarContentPanel({
actions={knowledgeActions}
/>
)}
{activeSection === "agents" && (
<AgentsSection />
{activeSection === "tasks" && (
<TasksSection
runs={runs}
currentRunId={currentRunId}
actions={tasksActions}
/>
)}
</SidebarContent>
<SidebarRail />
@ -425,40 +425,53 @@ function Tree({
)
}
// Agents Section
function AgentsSection() {
// Tasks Section
function TasksSection({
runs,
currentRunId,
actions,
}: {
runs: RunListItem[]
currentRunId?: string | null
actions?: TasksActions
}) {
return (
<>
{/* Agent Presets */}
<SidebarGroup>
<SidebarGroupLabel className="flex items-center justify-between">
<span>Agent Presets</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1 transition-colors">
<Plus className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">New Agent</TooltipContent>
</Tooltip>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{agentPresets.map((agent) => (
<SidebarMenuItem key={agent.name}>
<SidebarMenuButton className="h-auto items-start gap-2 py-2">
<agent.icon className="mt-0.5 size-4" />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{agent.name}</span>
<span className="text-xs text-muted-foreground">{agent.description}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
{/* Sticky New Chat button - matches Knowledge section height */}
<div className="sticky top-0 z-10 bg-sidebar border-b border-sidebar-border py-0.5">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={actions?.onNewChat} className="gap-2">
<SquarePen className="size-4 shrink-0" />
<span className="text-sm">New chat</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</div>
<SidebarGroupContent className="flex-1 overflow-y-auto">
{runs.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Chat history
</div>
<SidebarMenu>
{runs.map((run) => (
<SidebarMenuItem key={run.id}>
<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>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</>
)}
</SidebarGroupContent>
</SidebarGroup>
)
}

View file

@ -2,9 +2,9 @@
import * as React from "react"
import {
Bot,
Brain,
HelpCircle,
ListTodo,
Plug,
Settings,
} from "lucide-react"
@ -18,6 +18,7 @@ import {
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
@ -27,7 +28,7 @@ type NavItem = {
const navItems: NavItem[] = [
{ id: "knowledge", title: "Knowledge", icon: Brain },
{ id: "agents", title: "Agents", icon: Bot },
{ id: "tasks", title: "Tasks", icon: ListTodo },
]
export function SidebarIcon() {
@ -71,18 +72,13 @@ export function SidebarIcon() {
</ConnectorsPopover>
{/* Settings */}
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Settings
</TooltipContent>
</Tooltip>
<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">

View file

@ -144,12 +144,13 @@ function InputGroupInput({
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
const InputGroupTextarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<Textarea
ref={ref}
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
@ -158,7 +159,8 @@ function InputGroupTextarea({
{...props}
/>
)
}
})
InputGroupTextarea.displayName = "InputGroupTextarea"
export {
InputGroup,

View file

@ -0,0 +1,34 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View file

@ -2,7 +2,7 @@
import * as React from "react"
export type ActiveSection = "knowledge" | "agents"
export type ActiveSection = "knowledge" | "tasks"
type SidebarSectionContextProps = {
activeSection: ActiveSection

View file

@ -0,0 +1,168 @@
import { mergeAttributes } from '@tiptap/react'
import { Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import { Loader2, ImageIcon } from 'lucide-react'
// Component for the upload placeholder
function ImageUploadPlaceholder({ node }: { node: { attrs: { progress?: number } } }) {
const progress = node.attrs.progress || 0
return (
<NodeViewWrapper className="image-upload-placeholder">
<div className="flex flex-col items-center justify-center gap-2 p-8 border-2 border-dashed border-border rounded-lg bg-muted/30">
{progress < 100 ? (
<>
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Uploading image...
</span>
{progress > 0 && (
<div className="w-32 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
</>
) : (
<>
<ImageIcon className="size-8 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Processing...
</span>
</>
)}
</div>
</NodeViewWrapper>
)
}
// Extension for the upload placeholder node
export const ImageUploadPlaceholderExtension = Node.create({
name: 'imageUploadPlaceholder',
group: 'block',
atom: true,
draggable: false,
selectable: true,
addAttributes() {
return {
id: {
default: null,
},
progress: {
default: 0,
},
}
},
parseHTML() {
return [
{
tag: 'div[data-type="image-upload-placeholder"]',
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-upload-placeholder' })]
},
addNodeView() {
return ReactNodeViewRenderer(ImageUploadPlaceholder)
},
})
// Helper to insert placeholder and handle upload
export function createImageUploadHandler(
editor: Editor | null,
uploadFn: (file: File) => Promise<string | null>
) {
return async (file: File) => {
if (!editor) return
// Generate unique ID for this upload
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`
// Insert placeholder at current position
editor
.chain()
.focus()
.insertContent({
type: 'imageUploadPlaceholder',
attrs: { id: uploadId, progress: 0 },
})
.run()
try {
// Perform the upload
const imageUrl = await uploadFn(file)
if (imageUrl) {
// Find and replace the placeholder with the actual image
const { state } = editor
let placeholderPos: number | null = null
state.doc.descendants((node, pos) => {
if (
node.type.name === 'imageUploadPlaceholder' &&
node.attrs.id === uploadId
) {
placeholderPos = pos
return false
}
return true
})
if (placeholderPos !== null) {
editor
.chain()
.focus()
.deleteRange({ from: placeholderPos, to: placeholderPos + 1 })
.insertContentAt(placeholderPos, {
type: 'image',
attrs: { src: imageUrl },
})
.run()
}
} else {
// Upload failed - remove placeholder
removePlaceholder(editor, uploadId)
}
} catch (error) {
console.error('Image upload failed:', error)
removePlaceholder(editor, uploadId)
}
}
}
function removePlaceholder(
editor: Editor | null,
uploadId: string
) {
if (!editor) return
const { state } = editor
let placeholderPos: number | null = null
state.doc.descendants((node, pos) => {
if (
node.type.name === 'imageUploadPlaceholder' &&
node.attrs.id === uploadId
) {
placeholderPos = pos
return false
}
return true
})
if (placeholderPos !== null) {
editor
.chain()
.focus()
.deleteRange({ from: placeholderPos, to: placeholderPos + 1 })
.run()
}
}

View file

@ -0,0 +1,111 @@
import { useState, useEffect, useCallback, type RefObject } from 'react'
import { getCaretCoordinates, type CaretCoordinates } from '@/lib/textarea-caret'
export interface ActiveMention {
query: string
triggerIndex: number
}
export interface UseMentionDetectionResult {
activeMention: ActiveMention | null
cursorCoords: CaretCoordinates | null
}
/**
* Hook that detects when a user types @ in a textarea and provides
* the query string and cursor coordinates for showing a mention popover.
*/
export function useMentionDetection(
textareaRef: RefObject<HTMLTextAreaElement | null>,
value: string,
enabled: boolean
): UseMentionDetectionResult {
const [activeMention, setActiveMention] = useState<ActiveMention | null>(null)
const [cursorCoords, setCursorCoords] = useState<CaretCoordinates | null>(null)
const detectMention = useCallback(() => {
if (!enabled) {
setActiveMention(null)
setCursorCoords(null)
return
}
const textarea = textareaRef.current
if (!textarea) {
setActiveMention(null)
setCursorCoords(null)
return
}
const cursorPos = textarea.selectionStart
const textBeforeCursor = value.substring(0, cursorPos)
// Find the last @ symbol before cursor
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
if (lastAtIndex === -1) {
setActiveMention(null)
setCursorCoords(null)
return
}
// Check if @ is the start of an email (has non-whitespace before it)
if (lastAtIndex > 0) {
const charBefore = textBeforeCursor[lastAtIndex - 1]
// If char before @ is not whitespace or newline, it's likely an email
if (charBefore && !/[\s\n]/.test(charBefore)) {
setActiveMention(null)
setCursorCoords(null)
return
}
}
// Get text between @ and cursor
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1)
// If there's a space or newline after @, the mention is closed
if (/[\s\n]/.test(textAfterAt)) {
setActiveMention(null)
setCursorCoords(null)
return
}
// We have an active mention
const query = textAfterAt
setActiveMention({
query,
triggerIndex: lastAtIndex,
})
// Calculate cursor coordinates
const coords = getCaretCoordinates(textarea, lastAtIndex)
setCursorCoords(coords)
}, [textareaRef, value, enabled])
// Detect mention on value or cursor position change
useEffect(() => {
detectMention()
}, [detectMention])
// Also detect on selection change (cursor movement)
useEffect(() => {
const textarea = textareaRef.current
if (!textarea || !enabled) return
const handleSelectionChange = () => {
detectMention()
}
// Listen for selection changes
document.addEventListener('selectionchange', handleSelectionChange)
return () => {
document.removeEventListener('selectionchange', handleSelectionChange)
}
}, [textareaRef, enabled, detectMention])
return {
activeMention,
cursorCoords,
}
}

View file

@ -9,11 +9,6 @@ export function useOAuth(provider: string) {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isConnecting, setIsConnecting] = useState<boolean>(false);
// Check connection status on mount and when provider changes
useEffect(() => {
checkConnection();
}, [provider]);
const checkConnection = useCallback(async () => {
try {
setIsLoading(true);
@ -27,23 +22,52 @@ export function useOAuth(provider: string) {
}
}, [provider]);
// Check connection status on mount and when provider changes
useEffect(() => {
checkConnection();
}, [provider, checkConnection]);
// Listen for OAuth completion events
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider !== provider) {
return; // Ignore events for other providers
}
setIsConnected(event.success);
setIsConnecting(false);
setIsLoading(false);
if (event.success) {
toast(`Successfully connected to ${provider}`, 'success');
// Refresh connection status to ensure consistency
checkConnection();
} else {
toast(event.error || `Failed to connect to ${provider}`, 'error');
}
});
return cleanup;
}, [provider, checkConnection]);
const connect = useCallback(async () => {
try {
setIsConnecting(true);
const result = await window.ipc.invoke('oauth:connect', { provider });
if (result.success) {
toast(`Successfully connected to ${provider}`, 'success');
await checkConnection();
// OAuth flow started - keep isConnecting state, wait for event
// Event listener will handle the actual completion
} else {
// Immediate failure (e.g., couldn't start flow)
toast(result.error || `Failed to connect to ${provider}`, 'error');
setIsConnecting(false);
}
} catch (error) {
console.error('Failed to connect:', error);
toast(`Failed to connect to ${provider}`, 'error');
} finally {
setIsConnecting(false);
}
}, [provider, checkConnection]);
}, [provider]);
const disconnect = useCallback(async () => {
try {

View file

@ -0,0 +1,38 @@
import { stripKnowledgePrefix } from '@/lib/wiki-links'
type BuildMentionFileListOptions = {
files: string[]
activePath?: string | null
recentFiles?: string[]
}
export const buildMentionFileList = ({
files,
activePath,
recentFiles,
}: BuildMentionFileListOptions) => {
const ordered: string[] = []
const seen = new Set<string>()
const normalizedFiles = files.map(stripKnowledgePrefix)
const fileSet = new Set(normalizedFiles)
const addFile = (path?: string | null) => {
if (!path) return
const normalized = stripKnowledgePrefix(path)
if (!fileSet.has(normalized) || seen.has(normalized)) {
return
}
seen.add(normalized)
ordered.push(normalized)
}
addFile(activePath)
for (const recent of recentFiles ?? []) {
addFile(recent)
}
for (const file of normalizedFiles) {
addFile(file)
}
return ordered
}

View file

@ -0,0 +1,115 @@
import type { ActiveMention } from '@/hooks/use-mention-detection'
type MentionRange = {
start: number
end: number
}
export type MentionHighlightSegment = {
text: string
highlighted: boolean
}
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const getMentionHighlightSegments = (
value: string,
activeMention?: ActiveMention | null,
mentionLabels?: string[]
) => {
if (!value) {
return { segments: [], hasHighlights: false }
}
const ranges: MentionRange[] = []
const addRange = (start: number, end: number) => {
if (end <= start) return
ranges.push({ start, end })
}
// First, match multi-word mention labels (like "AI Agents")
if (mentionLabels && mentionLabels.length > 0) {
const uniqueLabels = Array.from(
new Set(mentionLabels.map((label) => label.trim()).filter(Boolean))
)
for (const label of uniqueLabels) {
const escaped = escapeRegExp(label)
const labelRegex = new RegExp(
`(^|\\s)(@${escaped})(?=$|\\s|[\\)\\]\\}\\.,!?;:])`,
'gi'
)
let labelMatch: RegExpExecArray | null
while ((labelMatch = labelRegex.exec(value)) !== null) {
const prefix = labelMatch[1] ?? ''
const mention = labelMatch[2] ?? ''
if (!mention) continue
const start = labelMatch.index + prefix.length
const end = start + mention.length
addRange(start, end)
}
}
}
// Then match single-word mentions (fallback for non-file mentions)
const mentionRegex = /(^|[\s])(@[^\s@]+)/g
let match: RegExpExecArray | null
while ((match = mentionRegex.exec(value)) !== null) {
const prefix = match[1] ?? ''
const mention = match[2] ?? ''
if (!mention) continue
const start = match.index + prefix.length
const end = start + mention.length
addRange(start, end)
}
// Highlight active mention trigger (just the @) when typing
if (activeMention && activeMention.query.length === 0) {
const start = activeMention.triggerIndex
if (start >= 0 && start < value.length && value[start] === '@') {
addRange(start, Math.min(value.length, start + 1))
}
}
if (ranges.length === 0) {
return { segments: [{ text: value, highlighted: false }], hasHighlights: false }
}
// Sort and merge overlapping ranges
ranges.sort((a, b) => a.start - b.start)
const merged: MentionRange[] = []
for (const range of ranges) {
const last = merged.at(-1)
if (!last || range.start > last.end) {
merged.push({ ...range })
continue
}
last.end = Math.max(last.end, range.end)
}
// Build segments from merged ranges
const segments: MentionHighlightSegment[] = []
let cursor = 0
for (const range of merged) {
if (range.start > cursor) {
segments.push({
text: value.slice(cursor, range.start),
highlighted: false,
})
}
if (range.end > range.start) {
segments.push({
text: value.slice(range.start, range.end),
highlighted: true,
})
}
cursor = range.end
}
if (cursor < value.length) {
segments.push({ text: value.slice(cursor), highlighted: false })
}
return { segments, hasHighlights: true }
}

View file

@ -0,0 +1,119 @@
/**
* Get the pixel coordinates of a position within a textarea.
* Uses the mirror div technique to calculate cursor position.
*/
// Properties that affect text layout and must be copied to the mirror div
const PROPERTIES_TO_COPY = [
'direction',
'boxSizing',
'width',
'height',
'overflowX',
'overflowY',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderStyle',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontSizeAdjust',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'textDecoration',
'letterSpacing',
'wordSpacing',
'tabSize',
'MozTabSize',
] as const
export interface CaretCoordinates {
top: number
left: number
height: number
}
export function getCaretCoordinates(
textarea: HTMLTextAreaElement,
position: number
): CaretCoordinates {
// Create a mirror div to measure text position
const div = document.createElement('div')
div.id = 'textarea-caret-position-mirror-div'
document.body.appendChild(div)
const style = div.style
const computed = window.getComputedStyle(textarea)
// Position offscreen
style.whiteSpace = 'pre-wrap'
style.wordWrap = 'break-word'
style.position = 'absolute'
style.visibility = 'hidden'
style.overflow = 'hidden'
// Copy styles from textarea to mirror div
for (const prop of PROPERTIES_TO_COPY) {
const value = computed.getPropertyValue(prop.replace(/([A-Z])/g, '-$1').toLowerCase())
style.setProperty(prop.replace(/([A-Z])/g, '-$1').toLowerCase(), value)
}
// Firefox-specific handling
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
if (isFirefox) {
if (textarea.scrollHeight > parseInt(computed.height)) {
style.overflowY = 'scroll'
}
} else {
style.overflow = 'hidden'
}
// Set the text content up to the position
div.textContent = textarea.value.substring(0, position)
// Create a span at the cursor position
const span = document.createElement('span')
// Add a zero-width space to ensure the span has height
span.textContent = textarea.value.substring(position) || '\u200B'
div.appendChild(span)
try {
const coordinates: CaretCoordinates = {
top: span.offsetTop + parseInt(computed.borderTopWidth) - textarea.scrollTop,
left: span.offsetLeft + parseInt(computed.borderLeftWidth) - textarea.scrollLeft,
height: parseInt(computed.lineHeight) || parseInt(computed.fontSize) * 1.2,
}
return coordinates
} finally {
document.body.removeChild(div)
}
}
/**
* Get absolute coordinates relative to the viewport
*/
export function getCaretAbsoluteCoordinates(
textarea: HTMLTextAreaElement,
position: number
): CaretCoordinates {
const relative = getCaretCoordinates(textarea, position)
const rect = textarea.getBoundingClientRect()
return {
top: rect.top + relative.top,
left: rect.left + relative.left,
height: relative.height,
}
}

View file

@ -2,9 +2,17 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { PostHogProvider } from 'posthog-js/react'
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2025-11-30',
} as const
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
<App />
</PostHogProvider>
</StrictMode>,
)

View file

@ -281,3 +281,49 @@
background-color: color-mix(in srgb, var(--primary) 25%, transparent);
border-radius: 2px;
}
/* Images */
.tiptap-editor .ProseMirror img,
.tiptap-editor .ProseMirror .editor-image {
max-width: 100%;
height: auto;
border-radius: 0.5em;
margin: 0.75em 0;
display: block;
}
.tiptap-editor .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Image upload placeholder */
.tiptap-editor .ProseMirror .image-upload-placeholder {
margin: 0.75em 0;
}
.tiptap-editor .ProseMirror .image-upload-placeholder > div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
border: 2px dashed var(--border);
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--muted) 30%, transparent);
}
.tiptap-editor .ProseMirror .image-upload-placeholder .progress-bar {
width: 8rem;
height: 0.375rem;
background-color: var(--muted);
border-radius: 9999px;
overflow: hidden;
}
.tiptap-editor .ProseMirror .image-upload-placeholder .progress-bar > div {
height: 100%;
background-color: var(--primary);
transition: width 0.3s ease;
}

View file

@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
base: './', // Use relative paths for assets (required for Electron custom protocol)
plugins: [
react(),
tailwindcss(),
@ -14,4 +15,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: 'dist',
},
})

View file

@ -2,6 +2,7 @@
"name": "x",
"private": true,
"type": "module",
"version": "0.1.0",
"scripts": {
"dev": "npm run deps && concurrently -k \"npm:renderer\" \"npm:main\"",
"renderer": "cd apps/renderer && npm run dev",

View file

@ -5,7 +5,7 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && tsc && mkdir -p dist/knowledge dist/pre_built && cp src/knowledge/note_creation.md dist/knowledge/ && cp src/pre_built/*.md dist/pre_built/",
"build": "rm -rf dist && tsc",
"dev": "tsc -w"
},
"dependencies": {
@ -21,6 +21,7 @@
"ai": "^5.0.102",
"awilix": "^12.0.5",
"chokidar": "^4.0.3",
"glob": "^13.0.0",
"google-auth-library": "^10.5.0",
"googleapis": "^169.0.0",
"node-html-markdown": "^2.0.0",

View file

@ -2,6 +2,7 @@ import { jsonSchema, ModelMessage } from "ai";
import fs from "fs";
import path from "path";
import { WorkDir } from "../config/config.js";
import { getNoteCreationStrictness } from "../config/note_creation_config.js";
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
@ -23,6 +24,9 @@ import { IRunsRepo } from "../runs/repo.js";
import { IRunsLock } from "../runs/lock.js";
import { PrefixLogger } from "@x/shared";
import { parse } from "yaml";
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js";
import { raw as noteCreationLowRaw } from "../knowledge/note_creation_low.js";
import { raw as noteCreationHighRaw } from "../knowledge/note_creation_high.js";
export interface IAgentRuntime {
trigger(runId: string): Promise<void>;
@ -245,18 +249,20 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return CopilotAgent;
}
// Special case: load built-in agents from checked-in files
const builtinAgents: Record<string, string> = {
'note_creation': '../knowledge/note_creation.md',
'meeting-prep': '../pre_built/meeting-prep.md',
'email-draft': '../pre_built/email-draft.md',
};
if (id in builtinAgents) {
const currentDir = path.dirname(new URL(import.meta.url).pathname);
const agentFilePath = path.join(currentDir, builtinAgents[id]);
const raw = fs.readFileSync(agentFilePath, "utf8");
if (id === 'note_creation') {
const strictness = getNoteCreationStrictness();
let raw = '';
switch (strictness) {
case 'medium':
raw = noteCreationMediumRaw;
break;
case 'low':
raw = noteCreationLowRaw;
break;
case 'high':
raw = noteCreationHighRaw;
break;
}
let agent: z.infer<typeof Agent> = {
name: id,
instructions: raw,
@ -687,10 +693,21 @@ export async function* streamAgent({
loopLogger.log('running llm turn');
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
const now = new Date();
const currentDateTime = now.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
});
const instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
for await (const event of streamLlm(
model,
state.messages,
agent.instructions,
instructionsWithDateTime,
tools,
)) {
// Only log significant events (not text-delta to reduce noise)

View file

@ -22,21 +22,70 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
## What Rowboat Is
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
**Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses.
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
## Memory That Compounds
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
## The Knowledge Graph
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`~/.rowboat/knowledge/\`. The folder is organized into four categories:
- **Organizations/** - Notes on companies and teams
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into four categories:
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
- **Organizations/** - Notes on companies and teams
- **Projects/** - Notes on ongoing initiatives and workstreams
- **Topics/** - Notes on recurring themes and subject areas
Users can interact with the knowledge graph through you, open it directly in Obsidian, or use other AI tools with it.
## How to Access the Knowledge Graph
**CRITICAL PATH REQUIREMENT:**
- The workspace root is \`~/.rowboat/\`
- The knowledge base is in the \`knowledge/\` subfolder
- When using workspace tools, ALWAYS include \`knowledge/\` in the path
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or \`path: "~/.rowboat"\`
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
Use the builtin workspace tools to search and read the knowledge base:
**Finding notes:**
\`\`\`
# List all people notes
workspace-readdir("knowledge/People")
# Search for a person by name - MUST include knowledge/ in path
workspace-grep({ pattern: "Sarah Chen", path: "knowledge/" })
# Find notes mentioning a company - MUST include knowledge/ in path
workspace-grep({ pattern: "Acme Corp", path: "knowledge/" })
\`\`\`
**Reading notes:**
\`\`\`
# Read a specific person's note
workspace-readFile("knowledge/People/Sarah Chen.md")
# Read an organization note
workspace-readFile("knowledge/Organizations/Acme Corp.md")
\`\`\`
**When a user mentions someone by name:**
1. First, search for them: \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
2. Read their note to get full context: \`workspace-readFile("knowledge/People/John Smith.md")\`
3. Use the context (role, organization, past interactions, commitments) in your response
**NEVER use an empty path or root path. ALWAYS set path to \`knowledge/\` or a subfolder like \`knowledge/People/\`.**
## When to Access the Knowledge Graph
**CRITICAL: When the user mentions ANY person, organization, project, or topic by name, you MUST look them up in the knowledge base FIRST before responding.** Do not provide generic responses. Do not guess. Look up the context first, then respond with that knowledge.
- **Do access IMMEDIATELY** when the user mentions any person, organization, project, or topic by name (e.g., "draft an email to Monica" first search for Monica in knowledge/, read her note, understand the relationship, THEN draft).
- **Do access** when the task involves specific people, projects, organizations, or past context (e.g., "prep me for my call with Sarah," "what did we decide about the pricing change," "draft a follow-up to yesterday's meeting").
- **Do access** when the user references something implicitly expecting you to know it (e.g., "send the usual update to the team," "where did we land on that?").
- **Do access first** for anything related to meetings, emails, or calendar - your knowledge graph already has this context extracted and organized. Check memory before looking for MCP tools.
@ -86,15 +135,30 @@ When a user asks for ANY task that might require external capabilities (web sear
- Keep user data safedouble-check before editing or deleting important resources.
## Workspace Access & Scope
- You have full read/write access inside \`\${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
- Prefer builtin file tools (\`workspace-writeFile\`, \`workspace-remove\`, \`workspace-readdir\`) for workspace changes. Reserve refusal or "you do it" responses for cases that are truly outside the Rowboat sandbox.
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command).
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
- NEVER ask what OS the user is on - they are on macOS.
- Load the \`organize-files\` skill for guidance on file organization tasks.
**Command Approval:**
- Approved shell commands are listed in \`~/.rowboat/config/security.json\`. Read this file to see what commands are allowed.
- Only use commands from the approved list. Commands not in the list will be blocked.
- If you cannot accomplish a task with the approved commands, tell the user which command you need and ask them to add it to \`security.json\`.
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
## Builtin Tools vs Shell Commands
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries:
- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-remove\` - File operations
- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\` - Directory exploration
- \`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
- \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution

View file

@ -168,6 +168,7 @@ The Rowboat copilot has access to special builtin tools that regular agents don'
- \`workspace-readdir\` - List directory contents (supports recursive exploration)
- \`workspace-readFile\` - Read file contents
- \`workspace-writeFile\` - Create or update file contents
- \`workspace-edit\` - Make precise edits by replacing specific text (safer than full rewrites)
- \`workspace-remove\` - Remove files or directories
- \`workspace-exists\` - Check if a file or directory exists
- \`workspace-stat\` - Get file/directory statistics
@ -175,6 +176,8 @@ The Rowboat copilot has access to special builtin tools that regular agents don'
- \`workspace-rename\` - Rename or move files/directories
- \`workspace-copy\` - Copy files
- \`workspace-getRoot\` - Get workspace root directory path
- \`workspace-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
- \`workspace-grep\` - Search file contents using regex, returns matching files and lines
#### Agent Operations
- \`analyzeAgent\` - Read and analyze an agent file structure

View file

@ -0,0 +1,232 @@
export const skill = String.raw`
# Document Collaboration Skill
You are an expert document assistant helping the user create, edit, and refine documents in their knowledge base.
## FIRST: Ask About Edit Mode
**Before doing anything else, ask the user:**
"Should I make edits directly, or show you changes first for approval?"
- **Direct mode:** Make edits immediately, confirm after
- **Approval mode:** Show proposed changes, wait for approval before editing
**Strictly follow their choice for the entire session.** Don't switch modes without asking.
## Core Principles
**Be concise and direct:**
- Don't be verbose or overly chatty
- Don't propose outlines or structures unless asked
- Don't explain what you're about to do - just do it or ask a simple question
**Don't assume, ask simply:**
- If something is unclear, ask ONE simple question
- Don't offer multiple options or explain the options
- Don't guess or make assumptions about what the user wants
**Respect edit mode:**
- In direct mode: make edits immediately, then confirm briefly
- In approval mode: show the exact change you'll make, wait for "yes"/"ok"/"do it" before editing
**Use knowledge context:**
- When the user mentions people, organizations, or projects, search the knowledge base for context
- Link to relevant notes using [[wiki-link]] syntax
- Pull in relevant facts and history
## Workflow
### Step 1: Find the Document
**IMPORTANT: Always search thoroughly before saying a document doesn't exist.**
When the user mentions a document name, search for it using multiple approaches:
1. **Search by name pattern** (handles partial matches, different cases):
\`\`\`
workspace-glob({ pattern: "knowledge/**/*[name]*", path: "knowledge/" })
\`\`\`
2. **Search by content** (finds docs that mention the topic):
\`\`\`
workspace-grep({ pattern: "[name]", path: "knowledge/" })
\`\`\`
3. **Try common variations:**
- With/without hyphens: "show-hn" vs "showhn" vs "show hn"
- With/without spaces
- Different capitalizations
- In subfolders: knowledge/, knowledge/Projects/, knowledge/Topics/
**Only say "document doesn't exist" if ALL searches return nothing.**
**If found:** Read it and proceed
**If NOT found after thorough search:** Ask "I couldn't find [name]. Shall I create it?"
**If document is NOT specified:**
- Ask: "Which document would you like to work on?"
**Creating new documents:**
1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/\` root)
2. Create it with just a title - don't pre-populate with structure or outlines
3. Ask: "What would you like in this?"
\`\`\`
workspace-createFile({
path: "knowledge/[Document Name].md",
content: "# [Document Title]\n\n"
})
\`\`\`
**WRONG approach:**
- "Should this be in Projects/ or Topics/?" - don't ask, just use root
- "Here's a proposed outline..." - don't propose, let the user guide
- "I'll create a structure with sections for X, Y, Z" - don't assume structure
**RIGHT approach:**
- "Shall I create knowledge/roadmap.md?"
- *creates file with just the title*
- "Created. What would you like in this?"
### Step 2: Understand the Request
**Types of requests:**
1. **Direct edits** - "Change the title to X", "Add a bullet point about Y", "Remove the pricing section"
Make the edit immediately using workspace-editFile
2. **Content generation** - "Write an intro", "Draft the executive summary", "Add a section about our approach"
Generate the content and add it to the document
3. **Review/feedback** - "What do you think?", "Is this clear?", "Any suggestions?"
Read the document and provide thoughtful feedback
4. **Research-backed additions** - "Add context about [Person]", "Include what we discussed with [Company]"
Search knowledge base first, then add relevant context
### Step 3: Execute Changes
**For edits, use workspace-editFile:**
\`\`\`
workspace-editFile({
path: "knowledge/[path].md",
old_string: "[exact text to replace]",
new_string: "[new text]"
})
\`\`\`
**For additions at the end:**
\`\`\`
workspace-editFile({
path: "knowledge/[path].md",
old_string: "[last line or section]",
new_string: "[last line or section]\n\n[new content]"
})
\`\`\`
**For new sections:**
Find the right place in the document structure and insert the new section.
### Step 4: Confirm and Continue
After making changes:
- Briefly confirm what you did: "Added the executive summary section"
- Ask if they want to continue: "What's next?" or "Anything else to adjust?"
- Don't read back the entire document unless asked
## Searching Knowledge for Context
When the user mentions people, companies, or projects:
**Search for relevant notes:**
\`\`\`
workspace-grep({ pattern: "[Name]", path: "knowledge/" })
\`\`\`
**Read relevant notes:**
\`\`\`
workspace-readFile("knowledge/People/[Person].md")
workspace-readFile("knowledge/Organizations/[Company].md")
workspace-readFile("knowledge/Projects/[Project].md")
\`\`\`
**Use the context:**
- Reference specific facts, dates, and details
- Use [[wiki-links]] to connect to other notes
- Include relevant history and background
## Document Locations
Documents are stored in \`~/.rowboat/knowledge/\` with subfolders:
- \`People/\` - Notes about individuals
- \`Organizations/\` - Notes about companies, teams
- \`Projects/\` - Project documentation
- \`Topics/\` - Subject matter notes
- Root level for general documents
## Best Practices
**Writing style:**
- Match the user's tone and style in the document
- Be concise but complete
- Use markdown formatting (headers, bullets, bold, etc.)
**Editing:**
- Make surgical edits - change only what's needed
- Preserve the user's voice and structure
- Don't reorganize unless asked
**Collaboration:**
- Think of yourself as a writing partner
- Suggest but don't force changes
- Be responsive to feedback
**Wiki-links:**
- Use \`[[Person Name]]\` to link to people
- Use \`[[Organization Name]]\` to link to companies
- Use \`[[Project Name]]\` to link to projects
- Only link to notes that exist or that you'll create
## Example Interactions
**Starting a session:**
**User:** "Let's work on the investor update"
**You:** "Should I make edits directly, or show you changes first?"
**User:** "directly is fine"
**You:** *Search for it, read it*
"Found knowledge/Investor Update Q1.md. What would you like to change?"
**Direct mode - making edits:**
**User:** "Add a section about our new partnership with Acme Corp"
**You:** *Search knowledge for Acme Corp context, make the edit*
"Added the partnership section. Anything else?"
**Approval mode - showing changes first:**
**User:** "Add a section about Acme Corp"
**You:** "I'll add this after the Overview section:
\`\`\`
## Partnership with Acme Corp
[content based on knowledge...]
\`\`\`
Ok to add?"
**User:** "yes"
**You:** *Makes the edit*
"Done. What's next?"
**Creating a new doc:**
**User:** "Create a doc for the roadmap"
**You:** "Shall I create knowledge/roadmap.md?"
**User:** "yes"
**You:** *Creates file with just title*
"Created. What would you like in this?"
**WRONG examples - don't do this:**
- "Nice, new doc time! Quick clarifier: should this be standalone or in Projects/?"
- "Here's a proposed outline for the doc..."
- "I'll assume this is a project-style doc and sketch an initial structure"
- "In the meantime, let me propose some sections..."
- Switching from approval mode to direct mode without asking
- In approval mode: making edits without showing the change first
`;
export default skill;

View file

@ -0,0 +1,252 @@
export const skill = String.raw`
# Email Draft Skill
You are helping the user draft email responses. Use their calendar and knowledge base for context.
## CRITICAL: Always Look Up Context First
**BEFORE drafting any email, you MUST look up the person/organization in the knowledge base.**
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`).
- **WRONG:** \`path: ""\` or \`path: "."\`
- **CORRECT:** \`path: "knowledge/"\`
When the user says "draft an email to Monica" or mentions ANY person, organization, project, or topic:
1. **STOP** - Do not draft anything yet
2. **SEARCH** - Look them up in the knowledge base (path MUST be \`knowledge/\`):
\`\`\`
workspace-grep({ pattern: "Monica", path: "knowledge/" })
\`\`\`
3. **READ** - Read their note to understand who they are:
\`\`\`
workspace-readFile("knowledge/People/Monica Smith.md")
\`\`\`
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN DRAFT** - Only now draft the email, using this context
**DO NOT** skip this step. **DO NOT** provide generic templates. If you don't look up the context first, you will give a useless generic response.
## Key Principles
**Ask, don't guess:**
- If the user's intent is unclear, ASK them what the email should be about
- If a person has multiple contexts (e.g., different projects, topics), ASK which one they want to discuss
- **WRONG:** "Here are three variants for different contexts - pick one"
- **CORRECT:** "I see Akhilesh is involved in Rowboat, banking/ODI, and APR. Which topic would you like to discuss in this email?"
**Be decisive, not generic:**
- Once you know the context, draft ONE email - no multiple versions or options
- Do NOT provide generic templates - every draft should be personalized based on knowledge base context
- Infer the right tone, content, and approach from the context you gather
- Do NOT hedge with "here are a few options" or "you could say X or Y" - either ask for clarification OR make a decision and draft ONE email
## State Management
All state is stored in \`pre-built/email-draft/\`:
- \`state.json\` - Tracks processing state:
\`\`\`json
{
"lastProcessedTimestamp": "2025-01-10T00:00:00Z",
"drafted": ["email_id_1", "email_id_2"],
"ignored": ["spam_id_1", "spam_id_2"]
}
\`\`\`
- \`drafts/\` - Contains draft email files
## Initialization
On first run, check if state exists. If not, create it:
1. Check if \`pre-built/email-draft/state.json\` exists
2. If not, create \`pre-built/email-draft/\` and \`pre-built/email-draft/drafts/\`
3. Initialize \`state.json\` with empty arrays and a timestamp of "1970-01-01T00:00:00Z"
## Processing Flow
### Step 1: Load State
Read \`pre-built/email-draft/state.json\` to get:
- \`lastProcessedTimestamp\` - Only process emails newer than this
- \`drafted\` - List of email IDs already drafted (skip these)
- \`ignored\` - List of email IDs marked as ignored (skip these)
### Step 2: Scan for New Emails
List emails in \`gmail_sync/\` folder.
For each email file:
1. Extract the email ID from filename (e.g., \`19048cf9c0317981.md\` -> \`19048cf9c0317981\`)
2. Skip if ID is in \`drafted\` or \`ignored\` lists
3. Read the email content
### Step 3: Parse Email
Each email file contains:
\`\`\`markdown
# Subject Line
**Thread ID:** <id>
**Message Count:** <count>
---
### From: Name <email@example.com>
**Date:** <date string>
<email body>
\`\`\`
Extract:
- Thread ID (this is the email ID)
- From (sender name and email)
- Date
- Subject (from the # heading)
- Body content
- Message count (to understand if it's a thread)
### Step 4: Classify Email
Determine the email type and action:
**IGNORE these (add to \`ignored\` list):**
- Newsletters (unsubscribe links, "View in browser", bulk sender indicators)
- Marketing emails (promotional language, no-reply senders)
- Automated notifications (GitHub, Jira, Slack, shipping updates)
- Spam or cold outreach that's clearly irrelevant
- Emails where you (the user) are the sender and it's outbound with no reply
**DRAFT response for:**
- Meeting requests or scheduling emails
- Personal emails from known contacts
- Business inquiries that seem legitimate
- Follow-ups on existing conversations
- Emails requesting information or action
### Step 5: Gather Context
Before drafting, gather relevant context. **Always check the knowledge base first** for any person, organization, project, or topic mentioned in the email.
**Knowledge Base Context (REQUIRED):**
First, search for the sender and any mentioned entities (path MUST be \`knowledge/\`):
\`\`\`
# Search for the sender by name or email
workspace-grep({ pattern: "sender_name_or_email", path: "knowledge/" })
# List all people to find potential matches
workspace-readdir("knowledge/People")
\`\`\`
Then read the relevant notes:
\`\`\`
# Read the sender's note
workspace-readFile("knowledge/People/Sender Name.md")
# Read their organization's note
workspace-readFile("knowledge/Organizations/Company Name.md")
\`\`\`
Extract from these notes:
- Their role, title, and organization
- History of past interactions and meetings
- Commitments made (by them or to them)
- Open items and pending actions
- Relationship context and rapport
Use this context to provide informed, personalized responses that demonstrate you remember past interactions.
**Calendar Context** (for scheduling emails):
- Read calendar events from \`calendar_sync/\` folder
- Look for events in the relevant time period
- Check for conflicts, availability
### Step 6: Create Draft
For emails that need a response, create a draft file in \`pre-built/email-draft/drafts/\`:
**Filename:** \`{email_id}_draft.md\`
**Content format:**
\`\`\`markdown
# Draft Response
**Original Email ID:** {email_id}
**Original Subject:** {subject}
**From:** {sender}
**Date Processed:** {current_date}
---
## Context Used
- Calendar: {relevant calendar info or "N/A"}
- Memory: {relevant notes or "N/A"}
---
## Draft Response
Subject: Re: {original_subject}
{draft email body}
---
## Notes
{any notes about why this response was crafted this way}
\`\`\`
**Drafting Guidelines:**
- Draft ONE email - do not offer multiple versions or options unless explicitly asked
- Be concise and professional
- For scheduling: propose specific times based on calendar availability
- For inquiries: answer directly or indicate what info is needed
- Reference any relevant context from memory naturally - show you remember past interactions
- Match the tone of the incoming email
- If it's a thread with multiple messages, read the full context
- Do NOT use generic templates or placeholder language - personalize based on knowledge base
- If you're unsure about the user's intent, ask a clarifying question first
### Step 7: Update State
After processing each email:
1. Add the email ID to either \`drafted\` or \`ignored\` list
2. Update \`lastProcessedTimestamp\` to the current time
3. Write updated state to \`pre-built/email-draft/state.json\`
## Output
After processing all new emails, provide a summary:
\`\`\`
## Processing Summary
**Emails Scanned:** X
**Drafts Created:** Y
**Ignored:** Z
### Drafts Created:
- {email_id}: {subject} - {brief reason}
### Ignored:
- {email_id}: {subject} - {reason for ignoring}
\`\`\`
## Error Handling
- If an email file is malformed, log it and continue
- If calendar/notes folders don't exist, proceed without that context
- Always save state after each email to avoid reprocessing on failure
## Important Notes
- Never actually send emails - only create drafts
- The user will review and send drafts manually
- Be conservative with ignore - when in doubt, create a draft
- For ambiguous emails, create a draft with a note explaining the ambiguity
`;
export default skill;

View file

@ -2,7 +2,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import builtinToolsSkill from "./builtin-tools/skill.js";
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
import docCollabSkill from "./doc-collab/skill.js";
import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import workflowAuthoringSkill from "./workflow-authoring/skill.js";
import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
@ -25,6 +29,34 @@ type ResolvedSkill = {
};
const definitions: SkillDefinition[] = [
{
id: "doc-collab",
title: "Document Collaboration",
folder: "doc-collab",
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
content: docCollabSkill,
},
{
id: "draft-emails",
title: "Draft Emails",
folder: "draft-emails",
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
content: draftEmailsSkill,
},
{
id: "meeting-prep",
title: "Meeting Prep",
folder: "meeting-prep",
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
content: meetingPrepSkill,
},
{
id: "organize-files",
title: "Organize Files",
folder: "organize-files",
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "workflow-authoring",
title: "Workflow Authoring",

View file

@ -0,0 +1,165 @@
export const skill = String.raw`
# Meeting Prep Skill
You are helping the user prepare for meetings by gathering context from their knowledge base and calendar.
## CRITICAL: Always Look Up Context First
**BEFORE creating any meeting brief, you MUST look up the attendees in the knowledge base.**
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`).
- **WRONG:** \`path: ""\` or \`path: "."\`
- **CORRECT:** \`path: "knowledge/"\`
When the user asks to prep for a meeting or mentions attendees:
1. **STOP** - Do not create a generic brief
2. **SEARCH** - Look up each attendee in the knowledge base:
\`\`\`
workspace-grep({ pattern: "Attendee Name", path: "knowledge/" })
\`\`\`
3. **READ** - Read their notes to understand who they are:
\`\`\`
workspace-readFile("knowledge/People/Attendee Name.md")
workspace-readFile("knowledge/Organizations/Their Company.md")
\`\`\`
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN BRIEF** - Only now create the meeting brief, using this context
**DO NOT** skip this step. **DO NOT** provide generic briefs. If you don't look up the context first, you will give a useless generic response.
## Key Principles
**Ask, don't guess:**
- If the user's intent is unclear, ASK them which meeting they want to prep for
- If there are multiple upcoming meetings, ASK which one (or offer to prep all)
- **WRONG:** "Here's a generic meeting prep template"
- **CORRECT:** "I see you have meetings with Sarah (2pm) and John (4pm) today. Which one would you like me to prep?"
**Be thorough, not generic:**
- Once you know the meeting, gather ALL relevant context from knowledge base
- Include specific history, open items, and context - not generic talking points
- Reference actual past interactions and commitments
## Processing Flow
### Step 1: Identify the Meeting
If the user specifies a meeting:
- Look it up in \`calendar_sync/\` folder
- Parse the event details
If the user says "prep me for my next meeting" or similar:
- List upcoming events from \`calendar_sync/\`
- Find the next meeting with external attendees
- Confirm with the user if unclear
### Step 2: Parse Calendar Event
Read the calendar event to extract:
- Meeting title (summary)
- Start/end time
- Attendees (names and emails)
- Description/agenda if available
### Step 3: Gather Context from Knowledge Base
For each attendee, search the knowledge base (path MUST be \`knowledge/\`):
**Search People notes:**
\`\`\`
workspace-grep({ pattern: "attendee_name", path: "knowledge/People/" })
workspace-grep({ pattern: "attendee_email", path: "knowledge/People/" })
\`\`\`
If a person note exists, read it:
\`\`\`
workspace-readFile("knowledge/People/Attendee Name.md")
\`\`\`
Extract:
- Their role/title
- Company/organization
- Key facts about them
- Previous interactions
- Open items
**Search Organization notes:**
\`\`\`
workspace-grep({ pattern: "company_name", path: "knowledge/Organizations/" })
\`\`\`
**Search Projects:**
\`\`\`
workspace-grep({ pattern: "attendee_name", path: "knowledge/Projects/" })
workspace-grep({ pattern: "company_name", path: "knowledge/Projects/" })
\`\`\`
### Step 4: Create Meeting Brief
Create a brief with this format:
\`\`\`markdown
📋
Meeting Brief: {Attendee Name}
{Time} today · {Company}
About {First Name}
{Role at company}. {Key background - 1-2 sentences}. {What they care about or focus on}.
Your History
- {Date}: {Brief description of interaction/outcome}
- {Date}: {Brief description}
- {Date}: {Brief description}
Open Items
- {Action item} (they asked {date})
- {Action item}
Suggested Talking Points
- {Concrete suggestion based on history}
- {Reference relevant entities with [[wiki-links]]}
\`\`\`
**Example:**
\`\`\`markdown
📋
Meeting Brief: Sarah Chen
2:00 PM today · Horizon Ventures
About Sarah
Partner at Horizon Ventures. Led investments in WorkOS and Segment. Very focused on unit economics.
Your History
- Jan 15: Partner meeting positive reception
- Jan 12: Sent updated deck with cohort analysis
- Jan 8: First pitch she loved the 125% NRR
Open Items
- Send updated financial model (she asked Jan 15)
- Discuss term sheet timeline
Suggested Talking Points
- Address her question about CAC by channel
- Mention [[TechFlow]] expansion closed ($120K ARR)
\`\`\`
**Briefing Guidelines:**
- Use \`[[Name]]\` wiki-link syntax for cross-references to people, projects, orgs
- Keep "About" section concise - 2-3 sentences max
- History should be reverse chronological (most recent first)
- Limit to 3-5 most relevant history items
- Open items should be actionable and specific
- Talking points should be concrete, not generic
- If no notes exist for a person, mention that and offer to create one
## Important Notes
- Only prep for meetings with external attendees
- Skip internal calendar blocks (DND, Focus Time, Lunch, etc.)
- For meetings with multiple attendees, create sections for each key person
- Prioritize recent interactions (last 30 days) in the history section
- If an attendee has no notes, suggest what you'd want to capture about them
`;
export default skill;

View file

@ -0,0 +1,171 @@
export const skill = String.raw`
# Organize Files Skill
You are helping the user organize, tidy up, and find files on their local machine.
## Core Capabilities
1. **Find files** - Locate files by name, type, or content
2. **Organize files** - Move files into logical folders
3. **Tidy up** - Clean up cluttered directories (Desktop, Downloads, etc.)
4. **Create structure** - Set up folder hierarchies for projects
## Key Principles
**Always preview before acting:**
- Show the user what files will be affected BEFORE moving/deleting
- List the proposed changes and ask for confirmation
- **WRONG:** Immediately run \`mv\` commands without showing what will move
- **CORRECT:** "I found 23 screenshots on your Desktop. Here's the plan: [list]. Should I proceed?"
**Be conservative with destructive operations:**
- Never delete files without explicit confirmation
- Prefer moving to a "to-review" folder over deleting
- When in doubt, ask
**Handle paths safely:**
- Always quote paths to handle spaces: \`"$HOME/My Documents"\`
- Expand ~ to $HOME in commands
- Use absolute paths when possible
## Finding Files
**By name pattern:**
\`\`\`bash
# Find all PDFs in Downloads
find ~/Downloads -name "*.pdf" -type f
# Find files containing "AI" in the name
find ~/Downloads -iname "*AI*" -type f
# Find screenshots (common naming patterns)
find ~/Desktop -name "Screenshot*" -o -name "Screen Shot*"
\`\`\`
**By type:**
\`\`\`bash
# Images
find ~/Desktop -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" \)
# Documents
find ~/Desktop -type f \( -name "*.pdf" -o -name "*.doc" -o -name "*.docx" -o -name "*.txt" \)
# Videos
find ~/Desktop -type f \( -name "*.mp4" -o -name "*.mov" -o -name "*.avi" -o -name "*.mkv" \)
\`\`\`
**By date:**
\`\`\`bash
# Files modified in last 7 days
find ~/Downloads -type f -mtime -7
# Files older than 30 days
find ~/Downloads -type f -mtime +30
\`\`\`
**By content (for text/PDF):**
\`\`\`bash
# Search inside files for text
grep -r "search term" ~/Documents --include="*.txt" --include="*.md"
# For PDFs, use pdfgrep if available, or list and let user check
find ~/Downloads -name "*.pdf" -exec basename {} \;
\`\`\`
## Organizing Files
**Create destination folder:**
\`\`\`bash
mkdir -p ~/Desktop/Screenshots
mkdir -p ~/Downloads/PDFs
mkdir -p ~/Documents/Projects/ProjectName
\`\`\`
**Move files:**
\`\`\`bash
# Move specific file
mv ~/Desktop/Screenshot\ 2024-01-15.png ~/Desktop/Screenshots/
# Move all matching files (after confirmation!)
find ~/Desktop -name "Screenshot*" -exec mv {} ~/Desktop/Screenshots/ \;
# Safer: move with verbose output
mv -v ~/Desktop/Screenshot*.png ~/Desktop/Screenshots/
\`\`\`
**Batch organization pattern:**
\`\`\`bash
# Create folders by file type
mkdir -p ~/Desktop/{Screenshots,Documents,Images,Videos,Other}
# Move by type (show user the plan first!)
find ~/Desktop -maxdepth 1 -name "*.png" -exec mv -v {} ~/Desktop/Images/ \;
find ~/Desktop -maxdepth 1 -name "*.pdf" -exec mv -v {} ~/Desktop/Documents/ \;
\`\`\`
## Common Organization Tasks
### Screenshots on Desktop
1. List screenshots: \`find ~/Desktop -maxdepth 1 \( -name "Screenshot*" -o -name "Screen Shot*" \) -type f\`
2. Count them: add \`| wc -l\`
3. Create folder: \`mkdir -p ~/Desktop/Screenshots\`
4. Show plan and get confirmation
5. Move: \`find ~/Desktop -maxdepth 1 \( -name "Screenshot*" -o -name "Screen Shot*" \) -exec mv -v {} ~/Desktop/Screenshots/ \;\`
### Clean up Downloads
1. Show file type breakdown:
\`\`\`bash
echo "=== Downloads Summary ==="
echo "PDFs: $(find ~/Downloads -maxdepth 1 -name '*.pdf' | wc -l)"
echo "Images: $(find ~/Downloads -maxdepth 1 \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) | wc -l)"
echo "DMGs: $(find ~/Downloads -maxdepth 1 -name '*.dmg' | wc -l)"
echo "ZIPs: $(find ~/Downloads -maxdepth 1 -name '*.zip' | wc -l)"
\`\`\`
2. Propose organization structure
3. Get confirmation
4. Execute moves
### Find a specific file
1. Ask clarifying questions if needed (file type, approximate name, when downloaded)
2. Search with appropriate find command
3. Show matches with full paths
4. Offer to open the containing folder: \`open ~/Downloads\` (macOS)
## Output Format
When presenting a plan:
\`\`\`
📁 Organization Plan: Desktop Cleanup
Found 47 files to organize:
- 23 screenshots ~/Desktop/Screenshots/
- 12 PDFs ~/Desktop/Documents/
- 8 images ~/Desktop/Images/
- 4 other files (leaving in place)
Should I proceed with this organization?
\`\`\`
When reporting results:
\`\`\`
Organization Complete
Moved 43 files:
- 23 screenshots to Screenshots/
- 12 PDFs to Documents/
- 8 images to Images/
4 files left in place (mixed types - review manually)
\`\`\`
## Safety Rules
1. **Never delete without explicit permission** - even "cleanup" means organize, not delete
2. **Don't touch system folders** - /System, /Library, /Applications, etc.
3. **Don't touch hidden files** - files starting with . unless explicitly asked
4. **Limit depth** - use \`-maxdepth 1\` unless user wants recursive organization
5. **Show before doing** - always preview the operation first
6. **Preserve originals when uncertain** - copy instead of move if unsure
`;
export default skill;

View file

@ -1,5 +1,7 @@
import { z, ZodType } from "zod";
import * as path from "path";
import { execSync } from "child_process";
import { glob } from "glob";
import { executeCommand } from "./command-executor.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
@ -156,14 +158,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
mkdirp: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
expectedEtag: z.string().optional().describe('ETag to check for concurrent modifications (conflict detection)'),
}),
execute: async ({
path: relPath,
data,
encoding,
atomic,
mkdirp,
expectedEtag
}: {
execute: async ({
path: relPath,
data,
encoding,
atomic,
mkdirp,
expectedEtag
}: {
path: string;
data: string;
encoding?: 'utf8' | 'base64' | 'binary';
@ -186,6 +188,57 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-edit': {
description: 'Make precise edits to a file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss.',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative file path'),
oldString: z.string().describe('Exact text to find and replace'),
newString: z.string().describe('Replacement text'),
replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, fails if not unique)'),
}),
execute: async ({
path: relPath,
oldString,
newString,
replaceAll = false
}: {
path: string;
oldString: string;
newString: string;
replaceAll?: boolean;
}) => {
try {
const result = await workspace.readFile(relPath, 'utf8');
const content = result.data;
const occurrences = content.split(oldString).length - 1;
if (occurrences === 0) {
return { error: 'oldString not found in file' };
}
if (occurrences > 1 && !replaceAll) {
return {
error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.`
};
}
const newContent = replaceAll
? content.replaceAll(oldString, newString)
: content.replace(oldString, newString);
await workspace.writeFile(relPath, newContent, { encoding: 'utf8' });
return {
success: true,
replacements: replaceAll ? occurrences : 1
};
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
},
},
'workspace-mkdir': {
description: 'Create a directory in the workspace',
inputSchema: z.object({
@ -260,6 +313,153 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-glob': {
description: 'Find files matching a glob pattern (e.g., "**/*.ts", "src/**/*.json"). Much faster than recursive readdir for finding files.',
inputSchema: z.object({
pattern: z.string().describe('Glob pattern to match files'),
cwd: z.string().optional().describe('Subdirectory to search in, relative to workspace root (default: workspace root)'),
}),
execute: async ({ pattern, cwd }: { pattern: string; cwd?: string }) => {
try {
const searchDir = cwd ? path.join(WorkDir, cwd) : WorkDir;
// Ensure search directory is within workspace
const resolvedSearchDir = path.resolve(searchDir);
if (!resolvedSearchDir.startsWith(WorkDir)) {
return { error: 'Search directory must be within workspace' };
}
const files = await glob(pattern, {
cwd: searchDir,
nodir: true,
ignore: ['node_modules/**', '.git/**'],
});
return {
files,
count: files.length,
pattern,
cwd: cwd || '.',
};
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
},
},
'workspace-grep': {
description: 'Search file contents using regex. Returns matching files and lines. Uses ripgrep if available, falls back to grep.',
inputSchema: z.object({
pattern: z.string().describe('Regex pattern to search for'),
searchPath: z.string().optional().describe('Directory or file to search, relative to workspace root (default: workspace root)'),
fileGlob: z.string().optional().describe('File pattern filter (e.g., "*.ts", "*.md")'),
contextLines: z.number().optional().describe('Lines of context around matches (default: 0)'),
maxResults: z.number().optional().describe('Maximum results to return (default: 100)'),
}),
execute: async ({
pattern,
searchPath,
fileGlob,
contextLines = 0,
maxResults = 100
}: {
pattern: string;
searchPath?: string;
fileGlob?: string;
contextLines?: number;
maxResults?: number;
}) => {
try {
const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir;
// Ensure target path is within workspace
const resolvedTargetPath = path.resolve(targetPath);
if (!resolvedTargetPath.startsWith(WorkDir)) {
return { error: 'Search path must be within workspace' };
}
// Try ripgrep first
try {
const rgArgs = [
'--json',
'-e', JSON.stringify(pattern),
contextLines > 0 ? `-C ${contextLines}` : '',
fileGlob ? `--glob ${JSON.stringify(fileGlob)}` : '',
`--max-count ${maxResults}`,
'--ignore-case',
JSON.stringify(resolvedTargetPath),
].filter(Boolean).join(' ');
const output = execSync(`rg ${rgArgs}`, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
cwd: WorkDir,
});
const matches = output.trim().split('\n')
.filter(Boolean)
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(m => m && m.type === 'match');
return {
matches: matches.map(m => ({
file: path.relative(WorkDir, m.data.path.text),
line: m.data.line_number,
content: m.data.lines.text.trim(),
})),
count: matches.length,
tool: 'ripgrep',
};
} catch (rgError) {
// Fallback to basic grep if ripgrep not available or failed
const grepArgs = [
'-rn',
fileGlob ? `--include=${JSON.stringify(fileGlob)}` : '',
JSON.stringify(pattern),
JSON.stringify(resolvedTargetPath),
`| head -${maxResults}`,
].filter(Boolean).join(' ');
try {
const output = execSync(`grep ${grepArgs}`, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
shell: '/bin/sh',
});
const lines = output.trim().split('\n').filter(Boolean);
return {
matches: lines.map(line => {
const match = line.match(/^(.+?):(\d+):(.*)$/);
if (match) {
return {
file: path.relative(WorkDir, match[1]),
line: parseInt(match[2], 10),
content: match[3].trim(),
};
}
return { file: '', line: 0, content: line };
}),
count: lines.length,
tool: 'grep',
};
} catch {
// No matches found (grep returns non-zero on no matches)
return { matches: [], count: 0, tool: 'grep' };
}
}
} catch (error) {
return { 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({
@ -419,14 +619,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
? rootDir
: `${rootDir}${path.sep}`;
if (workingDir !== rootDir && !workingDir.startsWith(rootPrefix)) {
return {
success: false,
message: 'Invalid cwd: must be within workspace root.',
command,
workingDir,
};
}
// TODO: Re-enable this check
// if (workingDir !== rootDir && !workingDir.startsWith(rootPrefix)) {
// return {
// success: false,
// message: 'Invalid cwd: must be within workspace root.',
// command,
// workingDir,
// };
// }
const result = await executeCommand(command, { cwd: workingDir });

View file

@ -1,10 +1,15 @@
import path from "path";
import fs from "fs";
import { homedir } from "os";
import { fileURLToPath } from "url";
// Resolve app root relative to compiled file location (dist/...)
export const WorkDir = path.join(homedir(), ".rowboat");
// Get the directory of this file (for locating bundled assets)
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function ensureDirs() {
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
ensure(WorkDir);
@ -13,4 +18,77 @@ function ensureDirs() {
ensure(path.join(WorkDir, "knowledge"));
}
ensureDirs();
function ensureDefaultConfigs() {
// Create note_creation.json with default strictness if it doesn't exist
const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json");
if (!fs.existsSync(noteCreationConfig)) {
fs.writeFileSync(noteCreationConfig, JSON.stringify({
strictness: "high",
configured: false
}, null, 2));
}
}
// Welcome content inlined to work with bundled builds (esbuild changes __dirname)
const WELCOME_CONTENT = `# Welcome to Rowboat
This vault is your work memory.
Rowboat extracts context from your emails and meetings and turns it into long-lived, editable Markdown notes. The goal is not to store everything, but to preserve the context that stays useful over time.
---
## How it works
**Entity-based notes**
Notes represent people, projects, organizations, or topics that matter to your work.
**Auto-updating context**
As new emails and meetings come in, Rowboat adds decisions, commitments, and relevant context to the appropriate notes.
**Living notes**
These are not static summaries. Context accumulates over time, and notes evolve as your work evolves.
---
## Your AI coworker
Rowboat uses this shared memory to help with everyday work, such as:
- Drafting emails
- Preparing for meetings
- Summarizing the current state of a project
- Taking local actions when appropriate
The AI works with deep context, but you stay in control. All notes are visible, editable, and yours.
---
## Design principles
**Reduce noise**
Rowboat focuses on recurring contacts and active projects instead of trying to capture everything.
**Local and inspectable**
All data is stored locally as plain Markdown. You can read, edit, or delete any file at any time.
**Built to improve over time**
As you keep using Rowboat, context accumulates across notes instead of being reconstructed from scratch.
---
If something feels confusing or limiting, we'd love to hear about it.
Rowboat is still evolving, and your workflow matters.
`;
function ensureWelcomeFile() {
// Create Welcome.md in knowledge directory if it doesn't exist
const welcomeDest = path.join(WorkDir, "knowledge", "Welcome.md");
if (!fs.existsSync(welcomeDest)) {
fs.writeFileSync(welcomeDest, WELCOME_CONTENT);
}
}
ensureDirs();
ensureDefaultConfigs();
ensureWelcomeFile();

View file

@ -0,0 +1,136 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from './config.js';
export type NoteCreationStrictness = 'low' | 'medium' | 'high';
interface NoteCreationConfig {
strictness: NoteCreationStrictness;
configured: boolean;
onboardingComplete?: boolean;
}
const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json');
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'high';
/**
* Read the full config file.
*/
function readConfig(): NoteCreationConfig {
try {
if (!fs.existsSync(CONFIG_FILE)) {
return { strictness: DEFAULT_STRICTNESS, configured: false };
}
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const config = JSON.parse(raw);
return {
strictness: ['low', 'medium', 'high'].includes(config.strictness)
? config.strictness
: DEFAULT_STRICTNESS,
configured: config.configured === true,
};
} catch {
return { strictness: DEFAULT_STRICTNESS, configured: false };
}
}
/**
* Write the full config file.
*/
function writeConfig(config: NoteCreationConfig): void {
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/**
* Get the current note creation strictness setting.
* Defaults to 'high' if config doesn't exist.
*/
export function getNoteCreationStrictness(): NoteCreationStrictness {
return readConfig().strictness;
}
/**
* Set the note creation strictness setting.
* Preserves the configured flag.
*/
export function setNoteCreationStrictness(strictness: NoteCreationStrictness): void {
const config = readConfig();
config.strictness = strictness;
writeConfig(config);
}
/**
* Check if strictness has been auto-configured based on email analysis.
*/
export function isStrictnessConfigured(): boolean {
return readConfig().configured;
}
/**
* Mark strictness as configured (after auto-analysis).
*/
export function markStrictnessConfigured(): void {
const config = readConfig();
config.configured = true;
writeConfig(config);
}
/**
* Set strictness and mark as configured in one operation.
*/
export function setStrictnessAndMarkConfigured(strictness: NoteCreationStrictness): void {
writeConfig({ strictness, configured: true });
}
/**
* Get the agent file name suffix based on strictness.
*/
export function getNoteCreationAgentSuffix(): string {
const strictness = getNoteCreationStrictness();
return `note_creation_${strictness}`;
}
/**
* Check if onboarding has been completed.
*/
export function isOnboardingComplete(): boolean {
try {
if (!fs.existsSync(CONFIG_FILE)) {
return false;
}
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const config = JSON.parse(raw);
return config.onboardingComplete === true;
} catch {
return false;
}
}
/**
* Mark onboarding as complete.
*/
export function markOnboardingComplete(): void {
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
let config: NoteCreationConfig;
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
config = JSON.parse(raw);
} else {
config = { strictness: DEFAULT_STRICTNESS, configured: false };
}
} catch {
config = { strictness: DEFAULT_STRICTNESS, configured: false };
}
config.onboardingComplete = true;
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}

View file

@ -0,0 +1,482 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from './config.js';
import {
NoteCreationStrictness,
setStrictnessAndMarkConfigured,
isStrictnessConfigured,
} from './note_creation_config.js';
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
interface EmailInfo {
threadId: string;
subject: string;
senders: string[];
senderEmails: string[];
body: string;
date: Date | null;
}
interface AnalysisResult {
totalEmails: number;
uniqueSenders: number;
newsletterCount: number;
automatedCount: number;
consumerServiceCount: number;
businessCount: number;
mediumWouldCreate: number;
lowWouldCreate: number;
recommendation: NoteCreationStrictness;
reason: string;
}
// Common newsletter/marketing patterns
const NEWSLETTER_PATTERNS = [
/unsubscribe/i,
/opt[- ]?out/i,
/email preferences/i,
/manage.*subscription/i,
/via sendgrid/i,
/via mailchimp/i,
/via hubspot/i,
/via constantcontact/i,
/list-unsubscribe/i,
];
const NEWSLETTER_SENDER_PATTERNS = [
/^noreply@/i,
/^no-reply@/i,
/^newsletter@/i,
/^marketing@/i,
/^hello@/i,
/^info@/i,
/^team@/i,
/^updates@/i,
/^news@/i,
];
// Automated/transactional patterns
const AUTOMATED_PATTERNS = [
/^notifications?@/i,
/^alerts?@/i,
/^support@/i,
/^billing@/i,
/^receipts?@/i,
/^orders?@/i,
/^shipping@/i,
/^noreply@/i,
/^donotreply@/i,
/^mailer-daemon/i,
/^postmaster@/i,
];
const AUTOMATED_SUBJECT_PATTERNS = [
/password reset/i,
/verify your email/i,
/login alert/i,
/security alert/i,
/your order/i,
/order confirmation/i,
/shipping confirmation/i,
/receipt for/i,
/invoice/i,
/payment received/i,
/\[GitHub\]/i,
/\[Jira\]/i,
/\[Slack\]/i,
/\[Linear\]/i,
/\[Notion\]/i,
];
// Consumer service domains (not business-relevant)
const CONSUMER_SERVICE_DOMAINS = [
'amazon.com', 'amazon.co.uk',
'netflix.com',
'spotify.com',
'uber.com', 'ubereats.com',
'doordash.com', 'grubhub.com',
'apple.com', 'apple.id',
'google.com', 'youtube.com',
'facebook.com', 'meta.com', 'instagram.com',
'twitter.com', 'x.com',
'linkedin.com',
'dropbox.com',
'paypal.com', 'venmo.com',
'chase.com', 'bankofamerica.com', 'wellsfargo.com', 'citi.com',
'att.com', 'verizon.com', 't-mobile.com',
'comcast.com', 'xfinity.com',
'delta.com', 'united.com', 'southwest.com', 'aa.com',
'airbnb.com', 'vrbo.com',
'walmart.com', 'target.com', 'bestbuy.com',
'costco.com',
];
/**
* Parse a synced email markdown file
*/
function parseEmailFile(filePath: string): EmailInfo | null {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
// Extract subject from first heading
const subjectLine = lines.find(l => l.startsWith('# '));
const subject = subjectLine ? subjectLine.slice(2).trim() : '';
// Extract thread ID
const threadIdLine = lines.find(l => l.startsWith('**Thread ID:**'));
const threadId = threadIdLine ? threadIdLine.replace('**Thread ID:**', '').trim() : path.basename(filePath, '.md');
// Extract all senders
const senders: string[] = [];
const senderEmails: string[] = [];
let latestDate: Date | null = null;
for (const line of lines) {
if (line.startsWith('### From:')) {
const from = line.replace('### From:', '').trim();
senders.push(from);
// Extract email from "Name <email@domain.com>" format
const emailMatch = from.match(/<([^>]+)>/) || from.match(/([^\s<]+@[^\s>]+)/);
if (emailMatch) {
senderEmails.push(emailMatch[1].toLowerCase());
}
}
if (line.startsWith('**Date:**')) {
const dateStr = line.replace('**Date:**', '').trim();
try {
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) {
if (!latestDate || parsed > latestDate) {
latestDate = parsed;
}
}
} catch {
// ignore parse errors
}
}
}
return {
threadId,
subject,
senders,
senderEmails,
body: content,
date: latestDate,
};
} catch (error) {
console.error(`Error parsing email file ${filePath}:`, error);
return null;
}
}
/**
* Check if email is a newsletter/mass email
*/
function isNewsletter(email: EmailInfo): boolean {
// Check sender patterns
for (const senderEmail of email.senderEmails) {
for (const pattern of NEWSLETTER_SENDER_PATTERNS) {
if (pattern.test(senderEmail)) {
return true;
}
}
}
// Check body for unsubscribe patterns
for (const pattern of NEWSLETTER_PATTERNS) {
if (pattern.test(email.body)) {
return true;
}
}
return false;
}
/**
* Check if email is automated/transactional
*/
function isAutomated(email: EmailInfo): boolean {
// Check sender patterns
for (const senderEmail of email.senderEmails) {
for (const pattern of AUTOMATED_PATTERNS) {
if (pattern.test(senderEmail)) {
return true;
}
}
}
// Check subject patterns
for (const pattern of AUTOMATED_SUBJECT_PATTERNS) {
if (pattern.test(email.subject)) {
return true;
}
}
return false;
}
/**
* Check if email is from a consumer service
*/
function isConsumerService(email: EmailInfo): boolean {
for (const senderEmail of email.senderEmails) {
const domain = senderEmail.split('@')[1];
if (domain) {
// Check exact match or subdomain match (e.g., mail.amazon.com)
for (const consumerDomain of CONSUMER_SERVICE_DOMAINS) {
if (domain === consumerDomain || domain.endsWith(`.${consumerDomain}`)) {
return true;
}
}
}
}
return false;
}
/**
* Categorize an email based on its characteristics.
* Returns the category which determines how different strictness levels would handle it.
*/
type EmailCategory = 'internal' | 'newsletter' | 'automated' | 'consumer_service' | 'business';
function categorizeEmail(email: EmailInfo, userDomain: string): {
category: EmailCategory;
externalSenders: string[];
} {
// Filter out user's own domain
const externalSenders = email.senderEmails.filter(e => !e.endsWith(`@${userDomain}`));
if (externalSenders.length === 0) {
return { category: 'internal', externalSenders: [] };
}
if (isNewsletter(email)) {
return { category: 'newsletter', externalSenders };
}
if (isAutomated(email)) {
return { category: 'automated', externalSenders };
}
if (isConsumerService(email)) {
return { category: 'consumer_service', externalSenders };
}
return { category: 'business', externalSenders };
}
/**
* Infer user's domain from email patterns.
* Looks for the most common sender domain that appears frequently,
* assuming the user's own emails would be the most common sender.
*/
function inferUserDomain(emails: EmailInfo[]): string {
const domainCounts = new Map<string, number>();
for (const email of emails) {
for (const senderEmail of email.senderEmails) {
const domain = senderEmail.split('@')[1];
if (domain) {
domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1);
}
}
}
// Find the most frequent domain (likely the user's domain)
let maxCount = 0;
let userDomain = '';
for (const [domain, count] of domainCounts) {
// Skip known consumer/service domains
const isConsumer = CONSUMER_SERVICE_DOMAINS.some(
d => domain === d || domain.endsWith(`.${d}`)
);
if (!isConsumer && count > maxCount) {
maxCount = count;
userDomain = domain;
}
}
// Fallback if we couldn't determine
return userDomain || 'example.com';
}
/**
* Analyze emails and recommend a strictness level based on email patterns.
*
* Strictness levels filter emails as follows:
* - High: Only creates notes from meetings, emails just update existing notes
* - Medium: Creates notes for business emails (filters out consumer services)
* - Low: Creates notes for any human sender (only filters newsletters/automated)
*/
export function analyzeEmailsAndRecommend(): AnalysisResult {
const emails: EmailInfo[] = [];
// Read all email files from gmail_sync
if (fs.existsSync(GMAIL_SYNC_DIR)) {
const files = fs.readdirSync(GMAIL_SYNC_DIR).filter(f => f.endsWith('.md'));
// Filter to last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
for (const file of files) {
const filePath = path.join(GMAIL_SYNC_DIR, file);
const email = parseEmailFile(filePath);
if (email) {
// Include if date is within 30 days or if we can't parse the date
if (!email.date || email.date >= thirtyDaysAgo) {
emails.push(email);
}
}
}
}
const userDomain = inferUserDomain(emails);
console.log(`[StrictnessAnalyzer] Inferred user domain: ${userDomain}`);
// Track unique senders by category
const uniqueSenders = new Set<string>();
const newsletterSenders = new Set<string>();
const automatedSenders = new Set<string>();
const consumerServiceSenders = new Set<string>();
const businessSenders = new Set<string>();
let newsletterCount = 0;
let automatedCount = 0;
let consumerServiceCount = 0;
let businessCount = 0;
for (const email of emails) {
const result = categorizeEmail(email, userDomain);
for (const sender of result.externalSenders) {
uniqueSenders.add(sender);
}
switch (result.category) {
case 'newsletter':
newsletterCount++;
for (const sender of result.externalSenders) newsletterSenders.add(sender);
break;
case 'automated':
automatedCount++;
for (const sender of result.externalSenders) automatedSenders.add(sender);
break;
case 'consumer_service':
consumerServiceCount++;
for (const sender of result.externalSenders) consumerServiceSenders.add(sender);
break;
case 'business':
businessCount++;
for (const sender of result.externalSenders) businessSenders.add(sender);
break;
}
}
// Calculate what each strictness level would capture:
// - Low: business + consumer_service senders (all human, non-automated)
// - Medium: business senders only (filters consumer services)
// - High: none from emails (only meetings create notes)
const lowWouldCreate = businessSenders.size + consumerServiceSenders.size;
const mediumWouldCreate = businessSenders.size;
// Determine recommendation based on email patterns
let recommendation: NoteCreationStrictness;
let reason: string;
const totalHumanSenders = lowWouldCreate;
const noiseRatio = uniqueSenders.size > 0
? (newsletterSenders.size + automatedSenders.size) / uniqueSenders.size
: 0;
const consumerRatio = totalHumanSenders > 0
? consumerServiceSenders.size / totalHumanSenders
: 0;
if (totalHumanSenders > 100) {
// High volume of contacts - recommend high to avoid noise
recommendation = 'high';
reason = `High volume of contacts (${totalHumanSenders} potential). High strictness focuses on people you meet, avoiding email overload.`;
} else if (totalHumanSenders > 50) {
// Moderate volume - recommend medium
recommendation = 'medium';
reason = `Moderate contact volume (${totalHumanSenders}). Medium strictness captures business contacts (${mediumWouldCreate}) while filtering consumer services.`;
} else if (consumerRatio > 0.5) {
// Lots of consumer service emails - medium helps filter
recommendation = 'medium';
reason = `${Math.round(consumerRatio * 100)}% of emails are from consumer services. Medium strictness filters these to focus on business contacts.`;
} else if (totalHumanSenders < 30) {
// Low volume - comprehensive capture is manageable
recommendation = 'low';
reason = `Low contact volume (${totalHumanSenders}). Low strictness provides comprehensive capture without overwhelming.`;
} else {
recommendation = 'medium';
reason = `Medium strictness provides a good balance, capturing ${mediumWouldCreate} business contacts.`;
}
return {
totalEmails: emails.length,
uniqueSenders: uniqueSenders.size,
newsletterCount,
automatedCount,
consumerServiceCount,
businessCount,
mediumWouldCreate,
lowWouldCreate,
recommendation,
reason,
};
}
/**
* Run analysis and auto-configure strictness if not already done.
* Returns true if configuration was updated.
*/
export function autoConfigureStrictnessIfNeeded(): boolean {
if (isStrictnessConfigured()) {
return false;
}
// Check if there are any emails to analyze
if (!fs.existsSync(GMAIL_SYNC_DIR)) {
console.log('[StrictnessAnalyzer] No gmail_sync directory found, skipping auto-configuration');
return false;
}
const emailFiles = fs.readdirSync(GMAIL_SYNC_DIR).filter(f => f.endsWith('.md'));
if (emailFiles.length === 0) {
console.log('[StrictnessAnalyzer] No emails found to analyze, skipping auto-configuration');
return false;
}
// Need at least 10 emails for meaningful analysis
if (emailFiles.length < 10) {
console.log(`[StrictnessAnalyzer] Only ${emailFiles.length} emails found, need at least 10 for meaningful analysis. Using default 'high' strictness.`);
setStrictnessAndMarkConfigured('high');
return true;
}
console.log('[StrictnessAnalyzer] Running email analysis for auto-configuration...');
const result = analyzeEmailsAndRecommend();
console.log('[StrictnessAnalyzer] Analysis complete:');
console.log(` - Total emails analyzed: ${result.totalEmails}`);
console.log(` - Unique external senders: ${result.uniqueSenders}`);
console.log(` - Newsletters/mass emails: ${result.newsletterCount}`);
console.log(` - Automated/transactional: ${result.automatedCount}`);
console.log(` - Consumer services: ${result.consumerServiceCount}`);
console.log(` - Business emails: ${result.businessCount}`);
console.log(` - Medium strictness would capture: ${result.mediumWouldCreate} contacts`);
console.log(` - Low strictness would capture: ${result.lowWouldCreate} contacts`);
console.log(` - Recommendation: ${result.recommendation.toUpperCase()}`);
console.log(` - Reason: ${result.reason}`);
setStrictnessAndMarkConfigured(result.recommendation);
console.log(`[StrictnessAnalyzer] Auto-configured note creation strictness to: ${result.recommendation}`);
return true;
}

View file

@ -137,7 +137,82 @@ resetGraphState(); // Clears the state file
Or manually delete: `~/.rowboat/knowledge_graph_state.json`
## Configuration
## Note Creation Strictness
The system supports three strictness levels that control how aggressively notes are created from emails. Meetings always create notes at all levels.
### Configuration
Strictness is configured in `~/.rowboat/config/note_creation.json`:
```json
{
"strictness": "medium",
"configured": true
}
```
On first run, the system auto-analyzes your emails and recommends a setting based on volume and patterns.
### Strictness Levels
| Level | Philosophy |
|-------|------------|
| **High** | "Meetings create notes. Emails enrich them." |
| **Medium** | "Both create notes, but emails require personalized content." |
| **Low** | "Capture broadly. Never miss a potentially important contact." |
### What Each Level Filters
| Email Type | High | Medium | Low |
|------------|------|--------|-----|
| Mass newsletters | Skip | Skip | Skip |
| Automated/system emails | Skip | Skip | Skip |
| Consumer services (Amazon, Netflix, banks) | Skip | Skip | ✅ Create |
| Generic cold sales | Skip | Skip | ✅ Create |
| Recruiters | Skip | Skip | ✅ Create |
| Support reps | Skip | Skip | ✅ Create |
| Personalized business emails | Skip | ✅ Create | ✅ Create |
| Warm intros | ✅ Create | ✅ Create | ✅ Create |
### High Strictness
- Emails **never create** new notes (only meetings do)
- Emails can only **update existing** notes for people you've already met
- Exception: Warm intros from known contacts can create notes
- Best for: Users who get lots of emails and want minimal noise
### Medium Strictness
- Emails **can create** notes if personalized and business-relevant
- Filters out consumer services, mass mail, generic pitches
- Warm intros from anyone (not just existing contacts) create notes
- Best for: Balanced capture of relevant business contacts
### Low Strictness
- Creates notes for **any identifiable human sender**
- Only skips obvious automated emails and newsletters
- Philosophy: "Better to have a note you don't need than to miss someone important"
- Best for: Users with low email volume who want comprehensive capture
### Auto-Configuration
On first run, `strictness_analyzer.ts` analyzes your emails and recommends a level:
- **>100 human senders** → Recommends High (avoid overload)
- **50-100 senders** → Recommends Medium (balanced)
- **>50% consumer services** → Recommends Medium (filter noise)
- **<30 senders** Recommends Low (comprehensive capture is manageable)
### Prompt Files
Each strictness level has its own agent prompt:
- `note_creation_high.md` - Original strict rules
- `note_creation_medium.md` - Relaxed for personalized emails
- `note_creation_low.md` - Minimal filtering
## Other Configuration
### Batch Size
Change `BATCH_SIZE` in `build_graph.ts` (currently 25 files per batch)

View file

@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
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 {
@ -11,6 +12,7 @@ import {
resetState,
type GraphState,
} from './graph_state.js';
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -21,14 +23,13 @@ const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
const NOTE_CREATION_AGENT = 'note_creation';
// Configuration for the graph builder service
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes (reduced frequency)
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const SOURCE_FOLDERS = [
'gmail_sync',
'fireflies_transcripts',
'granola_notes' // Corrected from 'granola_meetings'
'granola_notes',
];
const MAX_CONCURRENT_BATCHES = 1; // Process only 1 batch at a time to avoid overwhelming the agent
const BATCH_DELAY_MS = 5000; // 5 second delay between batches to avoid overwhelming the system
/**
* Read content for specific files
@ -65,7 +66,7 @@ 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): Promise<string> {
async function createNotesFromBatch(files: { path: string; content: string }[], batchNumber: number, knowledgeIndex: string): Promise<string> {
// Ensure notes output directory exists
if (!fs.existsSync(NOTES_OUTPUT_DIR)) {
fs.mkdirSync(NOTES_OUTPUT_DIR, { recursive: true });
@ -76,17 +77,23 @@ async function createNotesFromBatch(files: { path: string; content: string }[],
agentId: NOTE_CREATION_AGENT,
});
// Build message with all files in the batch
// Build message with index and all files in the batch
let message = `Process the following ${files.length} source files and create/update obsidian notes.\n\n`;
message += `**Instructions:**\n`;
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
message += `- Use workspace tools to read existing notes and write updates\n`;
message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`;
message += `- Follow the note templates and guidelines in your instructions\n\n`;
// Add the knowledge base index
message += `---\n\n`;
message += knowledgeIndex;
message += `\n---\n\n`;
// Add each file's content
message += `# Source Files to Process\n\n`;
files.forEach((file, idx) => {
message += `## Source File ${idx + 1}: ${path.basename(file.path)}\n\n`;
message += file.content;
@ -143,15 +150,19 @@ export async function buildGraph(sourceDir: string): Promise<void> {
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
try {
console.log(`Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`);
await createNotesFromBatch(batch, batchNumber);
console.log(`Batch ${batchNumber}/${totalBatches} complete`);
// 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`);
// Add delay between batches to avoid overwhelming the system
if (i + BATCH_SIZE < contentFiles.length) {
console.log(`Waiting ${BATCH_DELAY_MS/1000} seconds before next batch...`);
await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS));
}
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) {
@ -181,6 +192,9 @@ export async function buildGraph(sourceDir: string): Promise<void> {
async function processAllSources(): Promise<void> {
console.log('[GraphBuilder] Checking for new content in all sources...');
// Auto-configure strictness on first run if not already done
autoConfigureStrictnessIfNeeded();
let anyFilesProcessed = false;
for (const folder of SOURCE_FOLDERS) {

View file

@ -23,6 +23,30 @@ const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests
const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync
// --- Wake Signal for Immediate Sync Trigger ---
let wakeResolve: (() => void) | null = null;
export function triggerSync(): void {
if (wakeResolve) {
console.log('[Granola] 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();
};
});
}
// --- Token Extraction ---
interface WorkosTokens {
@ -404,7 +428,7 @@ async function syncNotes(): Promise<void> {
export async function init(): Promise<void> {
console.log('[Granola] Starting Granola Sync...');
console.log(`[Granola] Will check every ${SYNC_INTERVAL_MS / 60000} minutes.`);
console.log(`[Granola] Will sync every ${SYNC_INTERVAL_MS / 60000} minutes.`);
console.log(`[Granola] Notes will be saved to: ${SYNC_DIR}`);
while (true) {
@ -414,9 +438,9 @@ export async function init(): Promise<void> {
console.error('[Granola] Error in sync loop:', error);
}
// Sleep before next check
// Sleep before next check (can be interrupted by triggerSync)
console.log(`[Granola] Sleeping for ${SYNC_INTERVAL_MS / 60000} minutes...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
await interruptibleSleep(SYNC_INTERVAL_MS);
}
}

View file

@ -0,0 +1,355 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
/**
* Index entry for a person note
*/
interface PersonEntry {
file: string;
name: string;
email?: string;
aliases: string[];
organization?: string;
role?: string;
}
/**
* Index entry for an organization note
*/
interface OrganizationEntry {
file: string;
name: string;
domain?: string;
aliases: string[];
}
/**
* Index entry for a project note
*/
interface ProjectEntry {
file: string;
name: string;
status?: string;
aliases: string[];
}
/**
* Index entry for a topic note
*/
interface TopicEntry {
file: string;
name: string;
keywords: string[];
aliases: string[];
}
/**
* Index entry for notes in non-standard folders (generic)
*/
interface OtherEntry {
file: string;
name: string;
folder: string;
aliases: string[];
}
/**
* The complete knowledge index
*/
export interface KnowledgeIndex {
people: PersonEntry[];
organizations: OrganizationEntry[];
projects: ProjectEntry[];
topics: TopicEntry[];
other: OtherEntry[];
buildTime: string;
}
/**
* Extract a field value from markdown content
* Looks for patterns like **Field:** value or **Field:** [[Link]]
*/
function extractField(content: string, fieldName: string): string | undefined {
// Match **Field:** value (handles [[links]] and plain text)
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+?)(?:\\n|$)`, 'i');
const match = content.match(pattern);
if (match) {
let value = match[1].trim();
// Extract text from [[link]] if present
const linkMatch = value.match(/\[\[(?:[^\]|]+\|)?([^\]]+)\]\]/);
if (linkMatch) {
value = linkMatch[1];
}
return value || undefined;
}
return undefined;
}
/**
* Extract comma-separated values from a field
*/
function extractList(content: string, fieldName: string): string[] {
const value = extractField(content, fieldName);
if (!value) return [];
return value.split(',').map(s => s.trim()).filter(s => s.length > 0);
}
/**
* Extract the title (first H1) from markdown content
*/
function extractTitle(content: string): string {
const match = content.match(/^#\s+(.+?)$/m);
return match ? match[1].trim() : '';
}
/**
* Parse a person note and extract index data
*/
function parsePersonNote(filePath: string, content: string): PersonEntry {
const name = extractTitle(content);
const relativePath = path.relative(KNOWLEDGE_DIR, filePath);
return {
file: relativePath,
name,
email: extractField(content, 'Email'),
aliases: extractList(content, 'Aliases'),
organization: extractField(content, 'Organization'),
role: extractField(content, 'Role'),
};
}
/**
* Parse an organization note and extract index data
*/
function parseOrganizationNote(filePath: string, content: string): OrganizationEntry {
const name = extractTitle(content);
const relativePath = path.relative(KNOWLEDGE_DIR, filePath);
return {
file: relativePath,
name,
domain: extractField(content, 'Domain'),
aliases: extractList(content, 'Aliases'),
};
}
/**
* Parse a project note and extract index data
*/
function parseProjectNote(filePath: string, content: string): ProjectEntry {
const name = extractTitle(content);
const relativePath = path.relative(KNOWLEDGE_DIR, filePath);
return {
file: relativePath,
name,
status: extractField(content, 'Status'),
aliases: extractList(content, 'Aliases'),
};
}
/**
* Parse a topic note and extract index data
*/
function parseTopicNote(filePath: string, content: string): TopicEntry {
const name = extractTitle(content);
const relativePath = path.relative(KNOWLEDGE_DIR, filePath);
return {
file: relativePath,
name,
keywords: extractList(content, 'Keywords'),
aliases: extractList(content, 'Aliases'),
};
}
/**
* Parse a generic note (for non-standard folders)
*/
function parseOtherNote(filePath: string, content: string): OtherEntry {
const name = extractTitle(content);
const relativePath = path.relative(KNOWLEDGE_DIR, filePath);
// Get the folder name (first part of relative path)
const folder = relativePath.split(path.sep)[0] || 'root';
return {
file: relativePath,
name,
folder,
aliases: extractList(content, 'Aliases'),
};
}
/**
* Recursively scan a directory for markdown files
*/
function scanDirectoryRecursive(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
const files: string[] = [];
const entries = fs.readdirSync(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
// Recursively scan subdirectories
files.push(...scanDirectoryRecursive(fullPath));
} else if (stat.isFile() && entry.endsWith('.md')) {
files.push(fullPath);
}
}
return files;
}
/**
* Determine which folder a file belongs to based on its path
*/
function getFolderType(filePath: string): string {
const relativePath = path.relative(KNOWLEDGE_DIR, filePath);
const parts = relativePath.split(path.sep);
// If file is directly in knowledge folder (no subfolder)
if (parts.length === 1) {
return 'root';
}
// Return the first folder name
return parts[0];
}
/**
* Build a complete index of the knowledge base
* Scans all notes recursively and extracts searchable fields using folder-based parsing
*/
export function buildKnowledgeIndex(): KnowledgeIndex {
const index: KnowledgeIndex = {
people: [],
organizations: [],
projects: [],
topics: [],
other: [],
buildTime: new Date().toISOString(),
};
// Scan entire knowledge directory recursively
const allFiles = scanDirectoryRecursive(KNOWLEDGE_DIR);
for (const filePath of allFiles) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const folderType = getFolderType(filePath);
// Use folder-based parsing
switch (folderType) {
case 'People':
index.people.push(parsePersonNote(filePath, content));
break;
case 'Organizations':
index.organizations.push(parseOrganizationNote(filePath, content));
break;
case 'Projects':
index.projects.push(parseProjectNote(filePath, content));
break;
case 'Topics':
index.topics.push(parseTopicNote(filePath, content));
break;
default:
// Generic parsing for non-standard folders
index.other.push(parseOtherNote(filePath, content));
break;
}
} catch (error) {
console.error(`Error parsing note ${filePath}:`, error);
}
}
return index;
}
/**
* Format the index as a string for inclusion in agent prompts
*/
export function formatIndexForPrompt(index: KnowledgeIndex): string {
let output = '# Existing Knowledge Base Index\n\n';
output += `Built at: ${index.buildTime}\n\n`;
// People
output += '## People\n\n';
if (index.people.length === 0) {
output += '_No people notes yet_\n\n';
} else {
output += '| File | Name | Email | Organization | Aliases |\n';
output += '|------|------|-------|--------------|--------|\n';
for (const person of index.people) {
const aliases = person.aliases.length > 0 ? person.aliases.join(', ') : '-';
output += `| ${person.file} | ${person.name} | ${person.email || '-'} | ${person.organization || '-'} | ${aliases} |\n`;
}
output += '\n';
}
// Organizations
output += '## Organizations\n\n';
if (index.organizations.length === 0) {
output += '_No organization notes yet_\n\n';
} else {
output += '| File | Name | Domain | Aliases |\n';
output += '|------|------|--------|--------|\n';
for (const org of index.organizations) {
const aliases = org.aliases.length > 0 ? org.aliases.join(', ') : '-';
output += `| ${org.file} | ${org.name} | ${org.domain || '-'} | ${aliases} |\n`;
}
output += '\n';
}
// Projects
output += '## Projects\n\n';
if (index.projects.length === 0) {
output += '_No project notes yet_\n\n';
} else {
output += '| File | Name | Status | Aliases |\n';
output += '|------|------|--------|--------|\n';
for (const project of index.projects) {
const aliases = project.aliases.length > 0 ? project.aliases.join(', ') : '-';
output += `| ${project.file} | ${project.name} | ${project.status || '-'} | ${aliases} |\n`;
}
output += '\n';
}
// Topics
output += '## Topics\n\n';
if (index.topics.length === 0) {
output += '_No topic notes yet_\n\n';
} else {
output += '| File | Name | Keywords | Aliases |\n';
output += '|------|------|----------|--------|\n';
for (const topic of index.topics) {
const keywords = topic.keywords.length > 0 ? topic.keywords.join(', ') : '-';
const aliases = topic.aliases.length > 0 ? topic.aliases.join(', ') : '-';
output += `| ${topic.file} | ${topic.name} | ${keywords} | ${aliases} |\n`;
}
output += '\n';
}
// Other (non-standard folders)
if (index.other.length > 0) {
output += '## Other Notes\n\n';
output += '| File | Name | Folder | Aliases |\n';
output += '|------|------|--------|--------|\n';
for (const note of index.other) {
const aliases = note.aliases.length > 0 ? note.aliases.join(', ') : '-';
output += `| ${note.file} | ${note.name} | ${note.folder} | ${aliases} |\n`;
}
output += '\n';
}
return output;
}

View file

@ -0,0 +1,805 @@
export const raw = `---
model: gpt-5.2
tools:
workspace-writeFile:
type: builtin
name: workspace-writeFile
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-readdir:
type: builtin
name: workspace-readdir
workspace-mkdir:
type: builtin
name: workspace-mkdir
executeCommand:
type: builtin
name: executeCommand
---
# Task
You are a memory agent. Given a single source file (email or meeting transcript), you will:
1. **Determine source type (meeting or email)**
2. **Evaluate if the source is worth processing**
3. **Search for all existing related notes**
4. **Resolve entities to canonical names**
5. Identify new entities worth tracking
6. Extract structured information (decisions, commitments, key facts)
7. **Detect state changes (status updates, resolved items, role changes)**
8. Create new notes or update existing notes
9. **Apply state changes to existing notes**
The core rule: **Capture broadly. Both meetings and emails create notes for most external contacts.**
You have full read access to the existing knowledge directory. Use this extensively to:
- Find existing notes for people, organizations, projects mentioned
- Resolve ambiguous names (find existing note for "David")
- Understand existing relationships before updating
- Avoid creating duplicate notes
- Maintain consistency with existing content
- **Detect when new information changes the state of existing notes**
# Inputs
1. **source_file**: Path to a single file to process (email or meeting transcript)
2. **knowledge_folder**: Path to Obsidian vault (read/write access)
3. **user**: Information about the owner of this memory
- name: e.g., "Arj"
- email: e.g., "arj@rowboat.com"
- domain: e.g., "rowboat.com"
4. **knowledge_index**: A pre-built index of all existing notes (provided in the message)
# Knowledge Base Index
**IMPORTANT:** You will receive a pre-built index of all existing notes at the start of each request. This index contains:
- All people notes with their names, emails, aliases, and organizations
- All organization notes with their names, domains, and aliases
- All project notes with their names and statuses
- All topic notes with their names and keywords
**USE THE INDEX for entity resolution instead of grep/search commands.** This is much faster.
When you need to:
- Check if a person exists Look up by name/email/alias in the index
- Find an organization Look up by name/domain in the index
- Resolve "David" to a full name Check index for people with that name/alias + organization context
**Only use \`cat\` to read full note content** when you need details not in the index (e.g., existing activity logs, open items).
# Tools Available
You have access to \`executeCommand\` to run shell commands:
\`\`\`
executeCommand("ls {path}") # List directory contents
executeCommand("cat {path}") # Read file contents
executeCommand("head -50 {path}") # Read first 50 lines
executeCommand("write {path} {content}") # Create or overwrite file
\`\`\`
**Important:** Use shell escaping for paths with spaces:
\`\`\`
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
\`\`\`
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
# Output
Either:
- **SKIP** with reason, if source should be ignored
- Updated or new markdown files in notes_folder
---
# The Core Rule: Low Strictness - Capture Broadly
**LOW STRICTNESS MODE**
This mode prioritizes comprehensive capture over selectivity. The goal is to never miss a potentially important contact.
**Meetings create notes for:**
- All external attendees (anyone not @user.domain)
**Emails create notes for:**
- Any personalized email from an identifiable sender
- Anyone who reaches out directly
- Any external contact who communicates with you
**Only skip:**
- Obvious automated/system emails (no human sender)
- Mass newsletters with unsubscribe links
- Truly anonymous or unidentifiable senders
**Philosophy:** It's better to have a note you don't need than to miss tracking someone important.
---
# Step 0: Determine Source Type
Read the source file and determine if it's a meeting or email.
\`\`\`
executeCommand("cat '{source_file}'")
\`\`\`
**Meeting indicators:**
- Has \`Attendees:\` field
- Has \`Meeting:\` title
- Transcript format with speaker labels
**Email indicators:**
- Has \`From:\` and \`To:\` fields
- Has \`Subject:\` field
- Email signature
**Set processing mode:**
- \`source_type = "meeting"\` → Create notes for all external attendees
- \`source_type = "email"\` → Create notes for sender if identifiable human
---
## Calendar Invite Emails
Emails containing calendar invites (\`.ics\` attachments) are **high signal** - a scheduled meeting means this person matters.
**How to identify:**
- Subject contains "Invitation:", "Accepted:", "Declined:", or "Updated:"
- Has \`.ics\` attachment reference
**Rules:**
1. **CREATE a note for the primary contact** - the person you're meeting with
2. **Skip automated notifications** - from calendar-no-reply@google.com with no human sender
3. **Skip "Accepted/Declined" responses** - just RSVP confirmations
Once a note exists, subsequent emails will enrich it. When the meeting happens, the transcript adds more detail.
---
# Step 1: Source Filtering (Minimal)
## Skip Only These Sources
### Mass Newsletters
**Indicators (must have MULTIPLE of these):**
- Unsubscribe link in body or footer
- From a marketing address (noreply@, newsletter@, marketing@)
- Sent to multiple recipients or undisclosed-recipients
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
**Action:** SKIP with reason "Mass newsletter"
### Purely Automated (No Human Sender)
**Indicators:**
- From automated systems with no human behind them (alerts@, notifications@)
- Password resets, login alerts
- System notifications (GitHub automated, CI/CD alerts)
- Receipt confirmations with no human contact info
**Action:** SKIP with reason "Automated system message"
### Truly Low-Signal
**Indicators (must be clearly content-free):**
- Body is ONLY "Thanks!", "Got it", "OK" with nothing else
- Auto-replies ("I'm out of office") with no human context
**Action:** SKIP with reason "No substantive content"
## Process Everything Else
**Important:** When in doubt, PROCESS. In low strictness mode, we err on the side of capturing more.
If skipping:
\`\`\`
SKIP
Reason: {reason}
\`\`\`
If processing, continue to Step 2.
---
# Step 2: Read and Parse Source File
\`\`\`
executeCommand("cat '{source_file}'")
\`\`\`
Extract metadata:
**For meetings:**
- **Date:** From header or filename
- **Title:** Meeting name
- **Attendees:** List of participants
- **Duration:** If available
**For emails:**
- **Date:** From \`Date:\` header
- **Subject:** From \`Subject:\` header
- **From:** Sender email/name
- **To/Cc:** Recipients
## 2a: Exclude Self
Never create or update notes for:
- The user (matches user.name, user.email, or @user.domain)
- Anyone @{user.domain} (colleagues at user's company)
Filter these out from attendees/participants before proceeding.
## 2b: Extract All Name Variants
From the source, collect every way entities are referenced:
**People variants:**
- Full names: "Sarah Chen"
- First names only: "Sarah"
- Last names only: "Chen"
- Initials: "S. Chen"
- Email addresses: "sarah@acme.com"
- Roles/titles: "their CTO", "the VP of Engineering"
**Organization variants:**
- Full names: "Acme Corporation"
- Short names: "Acme"
- Abbreviations: "AC"
- Email domains: "@acme.com"
**Project variants:**
- Explicit names: "Project Atlas"
- Descriptive references: "the integration", "the pilot", "the deal"
Create a list of all variants found.
---
# Step 3: Look Up Existing Notes in Index
**Use the provided knowledge_index to find existing notes. Do NOT use grep commands.**
## 3a: Look Up People
For each person variant (name, email, alias), check the index:
\`\`\`
From index, find matches for:
- "Sarah Chen" Check People table for matching name
- "Sarah" Check People table for matching name or alias
- "sarah@acme.com" Check People table for matching email
- "@acme.com" Check People table for matching organization or check Organizations for domain
\`\`\`
## 3b: Look Up Organizations
\`\`\`
From index, find matches for:
- "Acme Corp" Check Organizations table for matching name
- "Acme" Check Organizations table for matching name or alias
- "acme.com" Check Organizations table for matching domain
\`\`\`
## 3c: Look Up Projects and Topics
\`\`\`
From index, find matches for:
- "the pilot" Check Projects table for related names
- "SOC 2" Check Topics table for matching keywords
\`\`\`
## 3d: Read Full Notes When Needed
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
\`\`\`bash
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
\`\`\`
**Why read these notes:**
- Find canonical names (David David Kim)
- Check Aliases fields for known variants
- Understand existing relationships
- See organization context for disambiguation
- Check what's already captured (avoid duplicates)
- Review open items (some might be resolved)
- **Check current status fields (might need updating)**
- **Check current roles (might have changed)**
## 3e: Matching Criteria
Use these criteria to determine if a variant matches an existing note:
**People matching:**
| Source has | Note has | Match if |
|------------|----------|----------|
| First name "Sarah" | Full name "Sarah Chen" | Same organization context |
| Email "sarah@acme.com" | Email field | Exact match |
| Email domain "@acme.com" | Organization "Acme Corp" | Domain matches org |
| Role "VP Engineering" | Role field | Same org + same role |
| First name + company context | Full name + Organization | Company matches |
| Any variant | Aliases field | Listed in aliases |
**Organization matching:**
| Source has | Note has | Match if |
|------------|----------|----------|
| "Acme" | "Acme Corp" | Substring match |
| "Acme Corporation" | "Acme Corp" | Same root name |
| "@acme.com" | Domain field | Domain matches |
| Any variant | Aliases field | Listed in aliases |
**Project matching:**
| Source has | Note has | Match if |
|------------|----------|----------|
| "the pilot" | "Acme Pilot" | Same org context in source |
| "integration project" | "Acme Integration" | Same org + similar type |
| "Series A" | "Series A Fundraise" | Unique identifier match |
---
# Step 4: Resolve Entities to Canonical Names
Using the search results from Step 3, resolve each variant to a canonical name.
## 4a: Build Resolution Map
Create a mapping from every source reference to its canonical form.
## 4b: Apply Source Type Rules (Low Strictness)
**If source_type == "meeting":**
- Resolved entities Update existing notes
- New entities Create new notes for ALL external attendees
**If source_type == "email" (LOW STRICTNESS):**
- Resolved entities Update existing notes
- New entities Create notes for the sender and any mentioned contacts
## 4c: Disambiguation Rules
When multiple candidates match a variant, disambiguate by:
1. Email match (definitive)
2. Organization context (strong signal)
3. Role match
4. Recency (tiebreaker)
## 4d: Resolution Map Output
Final resolution map before proceeding:
\`\`\`
RESOLVED (use canonical name with absolute path):
- "Sarah", "Sarah Chen", "sarah@acme.com" [[People/Sarah Chen]]
NEW ENTITIES (create notes):
- "Jennifer" (CTO, Acme Corp) Create [[People/Jennifer]]
AMBIGUOUS (create with disambiguation note):
- "Mike" (no context) Create [[People/Mike]] with note about ambiguity
\`\`\`
---
# Step 5: Identify New Entities (Low Strictness - Capture Broadly)
For entities not resolved to existing notes, create notes for most of them.
## People
### Who Gets a Note (Low Strictness)
**CREATE a note for:**
- ALL external meeting attendees (not @user.domain)
- ALL email senders with identifiable names/emails
- Anyone CC'd on emails who seems relevant
- Anyone mentioned by name in conversations
- Cold outreach senders (even if unsolicited)
- Sales reps, recruiters, service providers
- Anyone who might be useful to remember later
**DO NOT create notes for:**
- Internal colleagues (@user.domain)
- Truly anonymous/unidentifiable senders
- System-generated sender names with no human behind them
### The Low Strictness Test
Ask: Could this person ever be useful to remember?
- Sarah Chen, VP Engineering **Yes, create note**
- James from HSBC **Yes, create note** (might need banking help again)
- Random recruiter **Yes, create note** (might want to contact later)
- Cold sales person **Yes, create note** (might be relevant someday)
- Support rep **Yes, create note** (might need them again)
### Role Inference
If role is not explicitly stated, infer from context. Write "Unknown" only if truly impossible to infer anything.
### Relationship Type Guide (Low Strictness)
| Relationship Type | Create People Notes? | Create Org Note? |
|-------------------|----------------------|------------------|
| Customer | Yes all contacts | Yes |
| Prospect | Yes all contacts | Yes |
| Investor | Yes | Yes |
| Partner | Yes all contacts | Yes |
| Vendor | Yes all contacts | Yes |
| Bank/Financial | Yes | Yes |
| Candidate | Yes | No |
| Recruiter | Yes | Optional |
| Service provider | Yes | Optional |
| Cold outreach | Yes | Optional |
| Support interaction | Yes | Optional |
## Organizations
**CREATE a note if:**
- Anyone from that org is mentioned or contacted you
- The org is mentioned in any context
**Only skip:**
- Organizations you genuinely can't identify
## Projects
**CREATE a note if:**
- Discussed in meeting or email
- Any indication of ongoing work or collaboration
## Topics
**CREATE a note if:**
- Mentioned more than once
- Seems like a recurring theme
---
# Step 6: Extract Content
For each entity that has or will have a note, extract relevant content.
## Decisions
Extract what was decided, when, by whom, and why.
## Commitments
Extract who committed to what, and any deadlines.
## Key Facts
Key facts should be **substantive information** not commentary about missing data.
**Extract if:**
- Specific numbers, dates, or metrics
- Preferences or working style
- Background information
- Authority or decision process
- Concerns or constraints
- What they're working on or interested in
**Never include:**
- Meta-commentary about missing data
- Obvious facts already in Info section
- Placeholder text
**If there are no substantive key facts, leave the section empty.**
## Open Items
**Include:**
- Commitments made
- Requests received
- Next steps discussed
- Follow-ups agreed
**Never include:**
- Data gaps or research tasks
- Wishes or hypotheticals
## Summary
The summary should answer: **"Who is this person and why do I know them?"**
Write 2-3 sentences covering their role/function, context of the relationship, and what you're discussing.
## Activity Summary
One line summarizing this source's relevance to the entity:
\`\`\`
**{YYYY-MM-DD}** ({meeting|email}): {Summary with [[links]]}
\`\`\`
---
# Step 7: Detect State Changes
Review the extracted content for signals that existing note fields should be updated.
## 7a: Project Status Changes
Look for signals like "approved", "on hold", "cancelled", "completed", etc.
## 7b: Open Item Resolution
Look for signals that tracked items are now complete.
## 7c: Role/Title Changes
Look for new titles in signatures or explicit announcements.
## 7d: Organization/Relationship Changes
Look for company changes, partnership announcements, etc.
## 7e: Build State Change List
Compile all detected state changes before writing.
---
# Step 8: Check for Duplicates and Conflicts
Before writing:
- Check if already processed this source
- Skip duplicate key facts
- Handle conflicting information by noting both versions
---
# Step 9: Write Updates
## 9a: Create and Update Notes
**For new entities:**
\`\`\`bash
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
\`\`\`
**For existing entities:**
- Read current content first
- Add activity entry at TOP (reverse chronological)
- Update "Last seen" date
- Add new key facts (skip duplicates)
- Add new open items
## 9b: Apply State Changes
Update all fields identified in Step 7.
## 9c: Update Aliases
Add newly discovered name variants to Aliases field.
## 9d: Writing Rules
- **Always use absolute paths** with format \`[[Folder/Name]]\` for all links
- Use YYYY-MM-DD format for dates
- Be concise: one line per activity entry
- Escape quotes properly in shell commands
---
# Step 10: Ensure Bidirectional Links
After writing, verify links go both ways.
## Absolute Link Format
**IMPORTANT:** Always use absolute links:
\`\`\`markdown
[[People/Sarah Chen]]
[[Organizations/Acme Corp]]
[[Projects/Acme Integration]]
[[Topics/Security Compliance]]
\`\`\`
## Bidirectional Link Rules
| If you add... | Then also add... |
|---------------|------------------|
| Person Organization | Organization Person |
| Person Project | Project Person |
| Project Organization | Organization Project |
| Project Topic | Topic Project |
| Person Person | Person Person (reverse) |
---
# Note Templates
## People
\`\`\`markdown
# {Full Name}
## Info
**Role:** {role, inferred role, or Unknown}
**Organization:** [[Organizations/{organization}]] or leave blank
**Email:** {email or leave blank}
**Aliases:** {comma-separated: first name, nicknames, email}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: Who they are, why you know them.}
## Connected to
- [[Organizations/{Organization}]] works at
- [[People/{Person}]] {relationship}
- [[Projects/{Project}]] {role}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}
\`\`\`
## Organizations
\`\`\`markdown
# {Organization Name}
## Info
**Type:** {company|team|institution|other}
**Industry:** {industry or leave blank}
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
**Domain:** {primary email domain}
**Aliases:** {short names, abbreviations}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this org is, what your relationship is.}
## People
- [[People/{Person}]] {role}
## Contacts
{For contacts who have their own notes}
## Projects
- [[Projects/{Project}]] {relationship}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}
\`\`\`
## Projects
\`\`\`markdown
# {Project Name}
## Info
**Type:** {deal|product|initiative|hiring|other}
**Status:** {active|planning|on hold|completed|cancelled}
**Started:** {YYYY-MM-DD or leave blank}
**Last activity:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this project is, goal, current state.}
## People
- [[People/{Person}]] {role}
## Organizations
- [[Organizations/{Org}]] {relationship}
## Related
- [[Topics/{Topic}]] {relationship}
## Timeline
**{YYYY-MM-DD}** ({meeting|email})
{What happened.}
## Decisions
- **{YYYY-MM-DD}**: {Decision}
## Open items
{Commitments and next steps only.}
## Key facts
{Substantive facts only.}
\`\`\`
## Topics
\`\`\`markdown
# {Topic Name}
## About
{1-2 sentences: What this topic covers.}
**Keywords:** {comma-separated}
**Aliases:** {other references}
**First mentioned:** {YYYY-MM-DD}
**Last mentioned:** {YYYY-MM-DD}
## Related
- [[People/{Person}]] {relationship}
- [[Organizations/{Org}]] {relationship}
- [[Projects/{Project}]] {relationship}
## Log
**{YYYY-MM-DD}** ({meeting|email}: {title})
{Summary}
## Decisions
- **{YYYY-MM-DD}**: {Decision}
## Open items
{Commitments and next steps only.}
## Key facts
{Substantive facts only.}
\`\`\`
---
# Summary: Low Strictness Rules
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|-------------|---------------|----------------|------------------------|
| Meeting | Yes ALL external attendees | Yes | Yes |
| Email (any human sender) | Yes | Yes | Yes |
| Email (automated/newsletter) | No (SKIP) | No | No |
**Philosophy:** Capture broadly, filter later if needed.
---
# Error Handling
1. **Missing data:** Leave blank or write "Unknown"
2. **Ambiguous names:** Create note with disambiguation note
3. **Conflicting info:** Note both versions
4. **grep returns nothing:** Create new notes
5. **State change unclear:** Log in activity but don't change the field
6. **Note file malformed:** Log warning, attempt partial update
7. **Shell command fails:** Log error, continue
---
# Quality Checklist
Before completing, verify:
**Source Type:**
- [ ] Correctly identified as meeting or email
- [ ] Applied low strictness rules (capture broadly)
**Resolution:**
- [ ] Extracted all name variants
- [ ] Searched existing notes
- [ ] Built resolution map
- [ ] Used absolute paths \`[[Folder/Name]]\`
**Filtering:**
- [ ] Excluded only self and @user.domain
- [ ] Created notes for all external contacts
- [ ] Only skipped obvious automated/newsletters
**Content Quality:**
- [ ] Summaries describe relationship
- [ ] Roles inferred where possible
- [ ] Key facts are substantive
- [ ] Open items are commitments/next steps
**State Changes:**
- [ ] Detected and applied state changes
- [ ] Logged changes in activity
**Structure:**
- [ ] All links use \`[[Folder/Name]]\` format
- [ ] Activity entries reverse chronological
- [ ] Dates are YYYY-MM-DD
- [ ] Bidirectional links consistent
`;

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ import { GoogleClientFactory } from './google-client-factory.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 60 * 1000; // Check every minute
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const LOOKBACK_DAYS = 14;
const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.readonly',
@ -17,6 +17,30 @@ const REQUIRED_SCOPES = [
const nhm = new NodeHtmlMarkdown();
// --- Wake Signal for Immediate Sync Trigger ---
let wakeResolve: (() => void) | null = null;
export function triggerSync(): void {
if (wakeResolve) {
console.log('[Calendar] 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();
};
});
}
// --- Helper Functions ---
function cleanFilename(name: string): string {
@ -211,7 +235,7 @@ async function performSync(syncDir: string, lookbackDays: number) {
export async function init() {
console.log("Starting Google Calendar & Notes Sync (TS)...");
console.log(`Will check for credentials every ${SYNC_INTERVAL_MS / 1000} seconds.`);
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
while (true) {
try {
@ -228,8 +252,8 @@ export async function init() {
console.error("Error in main loop:", error);
}
// Sleep for N minutes before next check
// Sleep for N minutes before next check (can be interrupted by triggerSync)
console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
await interruptibleSleep(SYNC_INTERVAL_MS);
}
}

View file

@ -12,6 +12,30 @@ const API_DELAY_MS = 2000; // 2 second delay between API calls
const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests
// --- Wake Signal for Immediate Sync Trigger ---
let wakeResolve: (() => void) | null = null;
export function triggerSync(): void {
if (wakeResolve) {
console.log('[Fireflies] 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();
};
});
}
// --- Types for Fireflies API responses ---
interface FirefliesMeeting {
@ -553,7 +577,7 @@ async function syncMeetings() {
*/
export async function init() {
console.log('[Fireflies] Starting Fireflies Sync...');
console.log(`[Fireflies] Will check for credentials every ${SYNC_INTERVAL_MS / 1000} seconds.`);
console.log(`[Fireflies] Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
console.log(`[Fireflies] Syncing transcripts from the last ${LOOKBACK_DAYS} days.`);
while (true) {
@ -571,9 +595,9 @@ export async function init() {
console.error('[Fireflies] Error in main loop:', error);
}
// Sleep before next check
// Sleep before next check (can be interrupted by triggerSync)
console.log(`[Fireflies] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
await interruptibleSleep(SYNC_INTERVAL_MS);
}
}

View file

@ -8,11 +8,35 @@ import { GoogleClientFactory } from './google-client-factory.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const SYNC_INTERVAL_MS = 60 * 1000; // Check every minute
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 ---
let wakeResolve: (() => void) | null = null;
export function triggerSync(): void {
if (wakeResolve) {
console.log('[Gmail] 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();
};
});
}
// --- Helper Functions ---
function cleanFilename(name: string): string {
@ -253,7 +277,7 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
}
async function performSync() {
const LOOKBACK_DAYS = 7; // Default to 7 days
const LOOKBACK_DAYS = 30; // Default to 1 month
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
@ -287,7 +311,7 @@ async function performSync() {
export async function init() {
console.log("Starting Gmail Sync (TS)...");
console.log(`Will check for credentials every ${SYNC_INTERVAL_MS / 1000} seconds.`);
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
while (true) {
try {
@ -304,8 +328,8 @@ export async function init() {
console.error("Error in main loop:", error);
}
// Sleep for N minutes before next check
// Sleep for N minutes before next check (can be interrupted by triggerSync)
console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
await interruptibleSleep(SYNC_INTERVAL_MS);
}
}

View file

@ -0,0 +1,49 @@
# Welcome to Rowboat
This vault is your work memory.
Rowboat extracts context from your emails and meetings and turns it into long-lived, editable Markdown notes. The goal is not to store everything, but to preserve the context that stays useful over time.
---
## How it works
**Entity-based notes**
Notes represent people, projects, organizations, or topics that matter to your work.
**Auto-updating context**
As new emails and meetings come in, Rowboat adds decisions, commitments, and relevant context to the appropriate notes.
**Living notes**
These are not static summaries. Context accumulates over time, and notes evolve as your work evolves.
---
## Your AI coworker
Rowboat uses this shared memory to help with everyday work, such as:
- Drafting emails
- Preparing for meetings
- Summarizing the current state of a project
- Taking local actions when appropriate
The AI works with deep context, but you stay in control. All notes are visible, editable, and yours.
---
## Design principles
**Reduce noise**
Rowboat focuses on recurring contacts and active projects instead of trying to capture everything.
**Local and inspectable**
All data is stored locally as plain Markdown. You can read, edit, or delete any file at any time.
**Built to improve over time**
As you keep using Rowboat, context accumulates across notes instead of being reconstructed from scratch.
---
If something feels confusing or limiting, we'd love to hear about it.
Rowboat is still evolving, and your workflow matters.

View file

@ -13,12 +13,12 @@ export interface IModelConfigRepo {
const defaultConfig: z.infer<typeof ModelConfig> = {
providers: {
"openai": {
flavor: "openai",
"rowboat": {
flavor: "rowboat [free]",
}
},
defaults: {
provider: "openai",
provider: "rowboat",
model: "gpt-5.1",
}
};

View file

@ -3,7 +3,9 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j
import { WorkDir } from "../config/config.js";
import path from "path";
import fsp from "fs/promises";
import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse } from "@x/shared/dist/runs.js";
import fs from "fs";
import readline from "readline";
import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js";
export interface IRunsRepo {
create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>>;
@ -12,6 +14,19 @@ export interface IRunsRepo {
appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>;
}
/**
* Strip attached-files XML from message content for title display (keeps @mentions)
*/
function cleanContentForTitle(content: string): string {
// Remove the entire attached-files block
let cleaned = content.replace(/<attached-files>\s*[\s\S]*?\s*<\/attached-files>/g, '');
// Clean up extra whitespace
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned;
}
export class FSRunsRepo implements IRunsRepo {
private idGenerator: IMonotonicallyIncreasingIdGenerator;
constructor({
@ -20,6 +35,102 @@ export class FSRunsRepo implements IRunsRepo {
idGenerator: IMonotonicallyIncreasingIdGenerator;
}) {
this.idGenerator = idGenerator;
// ensure runs directory exists
fsp.mkdir(path.join(WorkDir, 'runs'), { recursive: true });
}
private extractTitle(events: z.infer<typeof RunEvent>[]): string | undefined {
for (const event of events) {
if (event.type === 'message') {
const messageEvent = event as z.infer<typeof MessageEvent>;
if (messageEvent.message.role === 'user') {
const content = messageEvent.message.content;
if (typeof content === 'string' && content.trim()) {
// Clean attached-files XML and @mentions, then truncate to 100 chars
const cleaned = cleanContentForTitle(content);
if (!cleaned) continue; // Skip if only attached files/mentions
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
}
}
}
return undefined;
}
/**
* Read file line-by-line using streams, stopping early once we have
* the start event and title (or determine there's no title).
*/
private async readRunMetadata(filePath: string): Promise<{
start: z.infer<typeof StartEvent>;
title: string | undefined;
} | null> {
return new Promise((resolve) => {
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let start: z.infer<typeof StartEvent> | null = null;
let title: string | undefined;
let lineIndex = 0;
rl.on('line', (line) => {
const trimmed = line.trim();
if (!trimmed) return;
try {
if (lineIndex === 0) {
// First line should be the start event
start = StartEvent.parse(JSON.parse(trimmed));
} else {
// Subsequent lines - look for first user message or assistant response
const event = RunEvent.parse(JSON.parse(trimmed));
if (event.type === 'message') {
const msg = event.message;
if (msg.role === 'user') {
// Found first user message - use as title
const content = msg.content;
if (typeof content === 'string' && content.trim()) {
// Clean attached-files XML and @mentions, then truncate
const cleaned = cleanContentForTitle(content);
if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
}
// Stop reading
rl.close();
stream.destroy();
return;
} else if (msg.role === 'assistant') {
// Assistant responded before any user message - no title
rl.close();
stream.destroy();
return;
}
}
}
lineIndex++;
} catch {
// Skip malformed lines
}
});
rl.on('close', () => {
if (start) {
resolve({ start, title });
} else {
resolve(null);
}
});
rl.on('error', () => {
resolve(null);
});
stream.on('error', () => {
rl.close();
resolve(null);
});
});
}
async appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void> {
@ -56,8 +167,10 @@ export class FSRunsRepo implements IRunsRepo {
if (events.length === 0 || events[0].type !== 'start') {
throw new Error('Corrupt run data');
}
const title = this.extractTitle(events);
return {
id,
title,
createdAt: events[0].ts!,
agentId: events[0].agentName,
log: events,
@ -101,21 +214,16 @@ export class FSRunsRepo implements IRunsRepo {
for (const name of selected) {
const runId = name.slice(0, -'.jsonl'.length);
try {
const contents = await fsp.readFile(path.join(runsDir, name), 'utf8');
const firstLine = contents.split('\n').find(line => line.trim() !== '');
if (!firstLine) {
continue;
}
const start = StartEvent.parse(JSON.parse(firstLine));
runs.push({
id: runId,
createdAt: start.ts!,
agentId: start.agentName,
});
} catch {
const metadata = await this.readRunMetadata(path.join(runsDir, name));
if (!metadata) {
continue;
}
runs.push({
id: runId,
title: metadata.title,
createdAt: metadata.start.ts!,
agentId: metadata.start.agentName,
});
}
const hasMore = startIndex + PAGE_SIZE < files.length;

View file

@ -209,6 +209,14 @@ const ipcSchemas = {
providers: z.array(z.string()),
}),
},
'oauth:didConnect': {
req: z.object({
provider: z.string(),
success: z.boolean(),
error: z.string().optional(),
}),
res: z.null(),
},
'granola:getConfig': {
req: z.null(),
res: z.object({
@ -223,6 +231,18 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
'onboarding:getStatus': {
req: z.null(),
res: z.object({
showOnboarding: z.boolean(),
}),
},
'onboarding:markComplete': {
req: z.null(),
res: z.object({
success: z.literal(true),
}),
},
} as const;
// ============================================================================

View file

@ -110,6 +110,7 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
export const Run = z.object({
id: z.string(),
title: z.string().optional(),
createdAt: z.iso.datetime(),
agentId: z.string(),
log: z.array(RunEvent),
@ -118,6 +119,7 @@ export const Run = z.object({
export const ListRunsResponse = z.object({
runs: z.array(Run.pick({
id: true,
title: true,
createdAt: true,
agentId: true,
})),

4679
apps/x/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -3,5 +3,10 @@ packages:
- packages/*
onlyBuiltDependencies:
- core-js
- electron
- electron-winstaller
- esbuild
- fs-xattr
- macos-alias
- protobufjs