mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
commit
9084c739de
74 changed files with 13762 additions and 897 deletions
122
.github/workflows/electron-build.yml
vendored
Normal file
122
.github/workflows/electron-build.yml
vendored
Normal 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
168
README.md
|
|
@ -1,20 +1,20 @@
|
|||

|
||||
<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
|
||||
[](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\> '
|
||||
[](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 Rowboat’s 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 it’s 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
|
||||
[](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>
|
||||
|
|
|
|||
5
apps/x/apps/main/.gitignore
vendored
5
apps/x/apps/main/.gitignore
vendored
|
|
@ -1,2 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist/
|
||||
# Staging directory for Electron Forge packaging (contains bundled main process, copied preload/renderer)
|
||||
.package/
|
||||
out/
|
||||
37
apps/x/apps/main/bundle.mjs
Normal file
37
apps/x/apps/main/bundle.mjs
Normal 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');
|
||||
144
apps/x/apps/main/forge.config.cjs
Normal file
144
apps/x/apps/main/forge.config.cjs
Normal 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/');
|
||||
},
|
||||
}
|
||||
};
|
||||
BIN
apps/x/apps/main/icons/icon.icns
Normal file
BIN
apps/x/apps/main/icons/icon.icns
Normal file
Binary file not shown.
BIN
apps/x/apps/main/icons/icon.png
Normal file
BIN
apps/x/apps/main/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
185
apps/x/apps/renderer/src/components/mention-popover.tsx
Normal file
185
apps/x/apps/renderer/src/components/mention-popover.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
446
apps/x/apps/renderer/src/components/onboarding-modal.tsx
Normal file
446
apps/x/apps/renderer/src/components/onboarding-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
apps/x/apps/renderer/src/components/settings-dialog.tsx
Normal file
229
apps/x/apps/renderer/src/components/settings-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
34
apps/x/apps/renderer/src/components/ui/sonner.tsx
Normal file
34
apps/x/apps/renderer/src/components/ui/sonner.tsx
Normal 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 }
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
|
||||
export type ActiveSection = "knowledge" | "agents"
|
||||
export type ActiveSection = "knowledge" | "tasks"
|
||||
|
||||
type SidebarSectionContextProps = {
|
||||
activeSection: ActiveSection
|
||||
|
|
|
|||
168
apps/x/apps/renderer/src/extensions/image-upload.tsx
Normal file
168
apps/x/apps/renderer/src/extensions/image-upload.tsx
Normal 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()
|
||||
}
|
||||
}
|
||||
111
apps/x/apps/renderer/src/hooks/use-mention-detection.ts
Normal file
111
apps/x/apps/renderer/src/hooks/use-mention-detection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
38
apps/x/apps/renderer/src/lib/mention-files.ts
Normal file
38
apps/x/apps/renderer/src/lib/mention-files.ts
Normal 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
|
||||
}
|
||||
115
apps/x/apps/renderer/src/lib/mention-highlights.ts
Normal file
115
apps/x/apps/renderer/src/lib/mention-highlights.ts
Normal 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 }
|
||||
}
|
||||
119
apps/x/apps/renderer/src/lib/textarea-caret.ts
Normal file
119
apps/x/apps/renderer/src/lib/textarea-caret.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 safe—double-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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
136
apps/x/packages/core/src/config/note_creation_config.ts
Normal file
136
apps/x/packages/core/src/config/note_creation_config.ts
Normal 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));
|
||||
}
|
||||
482
apps/x/packages/core/src/config/strictness_analyzer.ts
Normal file
482
apps/x/packages/core/src/config/strictness_analyzer.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
355
apps/x/packages/core/src/knowledge/knowledge_index.ts
Normal file
355
apps/x/packages/core/src/knowledge/knowledge_index.ts
Normal 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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
805
apps/x/packages/core/src/knowledge/note_creation_low.ts
Normal file
805
apps/x/packages/core/src/knowledge/note_creation_low.ts
Normal 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
|
||||
`;
|
||||
1175
apps/x/packages/core/src/knowledge/note_creation_medium.ts
Normal file
1175
apps/x/packages/core/src/knowledge/note_creation_medium.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
apps/x/packages/core/src/knowledge/welcome.md
Normal file
49
apps/x/packages/core/src/knowledge/welcome.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
4679
apps/x/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,5 +3,10 @@ packages:
|
|||
- packages/*
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- core-js
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- esbuild
|
||||
- fs-xattr
|
||||
- macos-alias
|
||||
- protobufjs
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue