mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev
This commit is contained in:
commit
eb936e3290
19 changed files with 4442 additions and 175 deletions
48
.github/workflows/electron-build.yml
vendored
48
.github/workflows/electron-build.yml
vendored
|
|
@ -53,12 +53,48 @@ jobs:
|
|||
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 distributables
|
||||
run: npm run make
|
||||
- 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 }}
|
||||
run: npm run publish
|
||||
working-directory: apps/x/apps/main
|
||||
|
||||
- name: Diagnose built app
|
||||
|
|
@ -109,3 +145,11 @@ jobs:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -39,16 +39,20 @@ Prepares all build artifacts in a hidden `.package/` staging directory:
|
|||
- Copies preload/renderer dist to `.package/`
|
||||
|
||||
### 2. `packageAfterCopy` Hook (Post-copy)
|
||||
After Forge copies source to output, this hook fixes it:
|
||||
- Removes unbundled `dist/` (has unresolvable `@x/core` imports)
|
||||
- Copies bundled `dist-bundle/`, `preload/`, `renderer/` from staging
|
||||
After Forge copies source to output, this hook replaces source files with bundled/staged files:
|
||||
- **Hook signature**: `async (config, buildPath, electronVersion, platform, arch)`
|
||||
- `buildPath` already points to `Contents/Resources/app` (not the `.app` bundle root)
|
||||
- Removes unbundled `dist/` directory (has unresolvable `@x/core` imports)
|
||||
- Copies bundled `dist-bundle/` from `.package/` staging directory
|
||||
- Copies `preload/` and `renderer/` directories from staging
|
||||
- Updates `package.json`: sets `main` to `dist-bundle/main.js`, removes
|
||||
`"type": "module"` (since we bundle as CJS), removes dependencies
|
||||
- Cleans up source files (tsconfig.json, src/, etc.)
|
||||
`"type": "module"` (since we bundle as CJS), removes all dependencies/devDependencies
|
||||
- Cleans up source files (src/, tsconfig.json, forge.config.cjs, agents.md, .gitignore, bundle.mjs)
|
||||
|
||||
**Why this approach?** Electron Forge ignores `packagerConfig.dir` and always
|
||||
packages from the config file's directory. The `packageAfterCopy` hook is the
|
||||
reliable way to customize the packaged output.
|
||||
reliable way to customize the packaged output by modifying files after Forge
|
||||
copies the source directory but before the app bundle is finalized.
|
||||
|
||||
## Staged Build Directory (`.package/`)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ module.exports = {
|
|||
icon: './icons/icon', // .icns extension added automatically
|
||||
appBundleId: 'com.rowboat.app',
|
||||
appCategoryType: 'public.app-category.productivity',
|
||||
osxSign: {},
|
||||
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.
|
||||
|
|
@ -39,6 +48,21 @@ module.exports = {
|
|||
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/arm64/files
|
||||
}
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
|
|
@ -141,113 +165,82 @@ module.exports = {
|
|||
|
||||
console.log('✅ All assets staged in .package/');
|
||||
},
|
||||
|
||||
// Hook runs after Forge copies source to output directory
|
||||
// We use this to replace the unbundled code with our bundled version
|
||||
// Hook signature: (forgeConfig, buildPath, electronVersion, platform, arch)
|
||||
packageAfterCopy: async (forgeConfig, buildPath, electronVersion, platform, arch) => {
|
||||
// Hook signature: async (config, buildPath, electronVersion, platform, arch)
|
||||
// Called after Forge copies source directory to build output
|
||||
// This is where we replace source files with bundled/staged files
|
||||
packageAfterCopy: async (config, buildPath, electronVersion, platform, arch) => {
|
||||
const fs = require('fs');
|
||||
const packageDir = path.join(__dirname, '.package');
|
||||
|
||||
// buildPath is the app directory inside the packaged output
|
||||
// e.g., out/Rowboat-darwin-arm64/Rowboat.app/Contents/Resources/app
|
||||
// App bundle root is 3 levels up: buildPath/../../.. = Rowboat.app
|
||||
const appBundleRoot = path.resolve(buildPath, '../../..');
|
||||
console.log('Fixing packaged app at:', buildPath);
|
||||
console.log('App bundle root:', appBundleRoot);
|
||||
|
||||
// buildPath already points to the app directory (Contents/Resources/app)
|
||||
const appResourcesPath = buildPath;
|
||||
|
||||
// 1. Remove the unbundled dist/ directory (it has imports to @x/core, @x/shared)
|
||||
const distDir = path.join(buildPath, 'dist');
|
||||
if (fs.existsSync(distDir)) {
|
||||
console.log('📦 Copying staged files from .package/ to packaged app...');
|
||||
|
||||
// Remove unbundled dist/ directory (source TypeScript output)
|
||||
const unbundledDist = path.join(appResourcesPath, 'dist');
|
||||
if (fs.existsSync(unbundledDist)) {
|
||||
console.log('Removing unbundled dist/...');
|
||||
fs.rmSync(distDir, { recursive: true });
|
||||
fs.rmSync(unbundledDist, { recursive: true });
|
||||
}
|
||||
|
||||
// 2. Copy the bundled dist-bundle/ from staging
|
||||
console.log('Copying bundled dist-bundle/...');
|
||||
const bundleSrc = path.join(packageDir, 'dist-bundle');
|
||||
const bundleDest = path.join(buildPath, 'dist-bundle');
|
||||
fs.cpSync(bundleSrc, bundleDest, { recursive: true });
|
||||
// Copy bundled dist-bundle/ from staging
|
||||
const distBundleSrc = path.join(packageDir, 'dist-bundle');
|
||||
const distBundleDest = path.join(appResourcesPath, 'dist-bundle');
|
||||
if (fs.existsSync(distBundleSrc)) {
|
||||
console.log('Copying dist-bundle/...');
|
||||
fs.mkdirSync(distBundleDest, { recursive: true });
|
||||
fs.cpSync(distBundleSrc, distBundleDest, { recursive: true });
|
||||
}
|
||||
|
||||
// 3. Copy preload from staging
|
||||
console.log('Copying preload/...');
|
||||
// Copy preload/ from staging
|
||||
const preloadSrc = path.join(packageDir, 'preload');
|
||||
const preloadDest = path.join(buildPath, 'preload');
|
||||
fs.cpSync(preloadSrc, preloadDest, { recursive: true });
|
||||
const preloadDest = path.join(appResourcesPath, 'preload');
|
||||
if (fs.existsSync(preloadSrc)) {
|
||||
console.log('Copying preload/...');
|
||||
// Remove old preload if it exists
|
||||
if (fs.existsSync(preloadDest)) {
|
||||
fs.rmSync(preloadDest, { recursive: true });
|
||||
}
|
||||
fs.cpSync(preloadSrc, preloadDest, { recursive: true });
|
||||
}
|
||||
|
||||
// 4. Copy renderer from staging
|
||||
console.log('Copying renderer/...');
|
||||
// Copy renderer/ from staging
|
||||
const rendererSrc = path.join(packageDir, 'renderer');
|
||||
const rendererDest = path.join(buildPath, 'renderer');
|
||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||
const rendererDest = path.join(appResourcesPath, 'renderer');
|
||||
if (fs.existsSync(rendererSrc)) {
|
||||
console.log('Copying renderer/...');
|
||||
// Remove old renderer if it exists
|
||||
if (fs.existsSync(rendererDest)) {
|
||||
fs.rmSync(rendererDest, { recursive: true });
|
||||
}
|
||||
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
|
||||
}
|
||||
|
||||
// 5. Update package.json to point to bundled entry
|
||||
console.log('Updating package.json...');
|
||||
const packageJsonPath = path.join(buildPath, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
packageJson.main = 'dist-bundle/main.js';
|
||||
// Remove workspace dependencies (they're bundled now)
|
||||
delete packageJson.dependencies;
|
||||
delete packageJson.devDependencies;
|
||||
delete packageJson.scripts;
|
||||
// Remove "type": "module" - we bundle as CommonJS for compatibility
|
||||
// with dependencies that use dynamic require()
|
||||
delete packageJson.type;
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
// Update package.json to point to bundled entry point
|
||||
const packageJsonPath = path.join(appResourcesPath, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
console.log('Updating package.json...');
|
||||
const packageJson = {
|
||||
name: '@x/main',
|
||||
version: '0.1.0',
|
||||
main: 'dist-bundle/main.js',
|
||||
// Note: No "type": "module" since we bundle as CommonJS
|
||||
// No dependencies/devDependencies since everything is bundled
|
||||
};
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
}
|
||||
|
||||
// 6. Clean up source files that shouldn't be in production
|
||||
const filesToRemove = ['tsconfig.json', 'forge.config.cjs', 'agents.md'];
|
||||
// Clean up source files that shouldn't be in packaged app
|
||||
const filesToRemove = ['src', 'tsconfig.json', 'forge.config.cjs', 'agents.md', '.gitignore', 'bundle.mjs'];
|
||||
for (const file of filesToRemove) {
|
||||
const filePath = path.join(buildPath, file);
|
||||
const filePath = path.join(appResourcesPath, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.rmSync(filePath);
|
||||
console.log(`Removing ${file}...`);
|
||||
fs.rmSync(filePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const srcDir = path.join(buildPath, 'src');
|
||||
if (fs.existsSync(srcDir)) {
|
||||
fs.rmSync(srcDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 7. Remove any signature metadata AFTER all file modifications
|
||||
// This prevents "code has no resources but signature indicates they must be present" error
|
||||
// which occurs when _CodeSignature exists but files it references have been moved/modified
|
||||
const codeSignatureDir = path.join(appBundleRoot, 'Contents', '_CodeSignature');
|
||||
if (fs.existsSync(codeSignatureDir)) {
|
||||
console.log('Removing _CodeSignature directory (files were modified after packaging)...');
|
||||
fs.rmSync(codeSignatureDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 8. Re-sign the app bundle with adhoc signature after file modifications
|
||||
// The original bundle signature references resources that we've moved/modified
|
||||
// Re-signing with adhoc signature creates a new signature that matches the current bundle structure
|
||||
// This prevents "code has no resources but signature indicates they must be present" error
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Re-sign the entire app bundle with an adhoc signature
|
||||
// This creates a fresh signature that matches the modified bundle structure
|
||||
execSync(`codesign --force --deep --sign - "${appBundleRoot}"`, { stdio: 'ignore' });
|
||||
console.log('Re-signed app bundle with adhoc signature (matches modified structure)');
|
||||
} catch (e) {
|
||||
// Ignore errors - codesign might fail, but app should still work
|
||||
console.log('Warning: Failed to re-sign app bundle:', e.message);
|
||||
}
|
||||
|
||||
// 9. Clear any signature-related extended attributes
|
||||
// Even without _CodeSignature or embedded signatures, extended attributes can contain invalid signature metadata
|
||||
// This prevents "code has no resources but signature indicates they must be present" error
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
// Clear extended attributes from the entire app bundle
|
||||
// This removes any signature metadata that might be stored in xattrs
|
||||
execSync(`xattr -cr "${appBundleRoot}"`, { stdio: 'ignore' });
|
||||
console.log('Cleared extended attributes from app bundle');
|
||||
} catch (e) {
|
||||
// Ignore errors - xattr might not be available or might fail silently
|
||||
}
|
||||
|
||||
console.log('✅ Packaged app fixed with bundled code');
|
||||
console.log('✅ Staged files copied to packaged app');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.1.0",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"start": "electron .",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"@x/core": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
"update-electron-app": "^3.0.0",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||
"@electron-forge/maker-zip": "^7.11.1"
|
||||
"@electron-forge/maker-zip": "^7.11.1",
|
||||
"@electron-forge/publisher-s3": "^7.11.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import path from "node:path";
|
|||
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
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,6 +15,10 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:14',message:'__dirname resolved',data:{__dirname,__filename,isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
// Path resolution differs between development and production:
|
||||
// - Development: main.js runs from dist/, preload is at ../../preload/dist/ (sibling dir)
|
||||
// - Production: main.js runs from .package/dist-bundle/, preload is at ../preload/dist/ (copied into .package/)
|
||||
|
|
@ -21,9 +27,17 @@ const preloadPath = app.isPackaged
|
|||
: path.join(__dirname, "../../preload/dist/preload.js"); // Development
|
||||
console.log("preloadPath", preloadPath);
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:22',message:'preloadPath computed',data:{preloadPath,exists:existsSync(preloadPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
// Register custom protocol for serving built renderer files in production
|
||||
function registerAppProtocol() {
|
||||
protocol.handle('app', (request) => {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:26',message:'protocol handler called',data:{url:request.url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
// Remove 'app://' prefix and get the path
|
||||
let urlPath = request.url.slice('app://'.length);
|
||||
|
||||
|
|
@ -45,6 +59,10 @@ function registerAppProtocol() {
|
|||
: path.join(__dirname, '../../renderer/dist');
|
||||
const filePath = path.join(rendererDistPath, urlPath);
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:46',message:'renderer path resolution',data:{rendererDistPath,filePath,urlPath,exists:existsSync(filePath),rendererDistExists:existsSync(rendererDistPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
return net.fetch(pathToFileURL(filePath).toString());
|
||||
});
|
||||
}
|
||||
|
|
@ -62,9 +80,23 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
|
||||
// #region agent log
|
||||
const loadURL = app.isPackaged ? 'app://./' : 'http://localhost:5173';
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:65',message:'createWindow called',data:{isPackaged:app.isPackaged,loadURL,preloadPath},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
if (app.isPackaged) {
|
||||
// Production: load from custom protocol (serves built renderer files)
|
||||
win.loadURL('app://./');
|
||||
|
||||
// #region agent log
|
||||
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => {
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:69',message:'window load failed',data:{errorCode,errorDescription,validatedURL},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
|
||||
});
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:72',message:'window load finished',data:{url:win.webContents.getURL()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
|
||||
});
|
||||
// #endregion
|
||||
} else {
|
||||
// Development: load from Vite dev server
|
||||
win.loadURL('http://localhost:5173');
|
||||
|
|
@ -72,9 +104,28 @@ function createWindow() {
|
|||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:74',message:'app.whenReady triggered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
// Register custom protocol before creating window (for production builds)
|
||||
registerAppProtocol();
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:77',message:'protocol registered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -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 && mkdir -p dist/knowledge dist/pre_built && cp src/knowledge/note_creation_*.md dist/knowledge/ && cp src/pre_built/*.md dist/pre_built/",
|
||||
"dev": "tsc -w"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -245,16 +246,24 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return CopilotAgent;
|
||||
}
|
||||
|
||||
// Special case: load built-in agents from checked-in files
|
||||
// Built-in agents loaded 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]);
|
||||
// Resolve agent file path (note_creation is dynamic based on strictness config)
|
||||
let agentFilePath: string | null = null;
|
||||
const currentDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
if (id === 'note_creation') {
|
||||
const strictness = getNoteCreationStrictness();
|
||||
agentFilePath = path.join(currentDir, `../knowledge/note_creation_${strictness}.md`);
|
||||
} else if (id in builtinAgents) {
|
||||
agentFilePath = path.join(currentDir, builtinAgents[id]);
|
||||
}
|
||||
|
||||
if (agentFilePath) {
|
||||
const raw = fs.readFileSync(agentFilePath, "utf8");
|
||||
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
|
|
|
|||
|
|
@ -13,4 +13,16 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
ensureDefaultConfigs();
|
||||
94
apps/x/packages/core/src/config/note_creation_config.ts
Normal file
94
apps/x/packages/core/src/config/note_creation_config.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
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
|
||||
|
|
@ -28,7 +30,6 @@ const SOURCE_FOLDERS = [
|
|||
'granola_notes' // Corrected from 'granola_meetings'
|
||||
];
|
||||
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,15 @@ 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 index = buildKnowledgeIndex();
|
||||
const indexForPrompt = formatIndexForPrompt(index);
|
||||
console.log(`Index built: ${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)...`);
|
||||
await createNotesFromBatch(batch, batchNumber, indexForPrompt);
|
||||
console.log(`Batch ${batchNumber}/${totalBatches} complete`);
|
||||
|
||||
// Mark files in this batch as processed
|
||||
for (const file of batch) {
|
||||
|
|
@ -181,6 +188,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) {
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -49,16 +49,31 @@ You have full read access to the existing knowledge directory. Use this extensiv
|
|||
- 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("grep -r '{pattern}' {path}") # Search across files
|
||||
executeCommand("grep -r -l '{pattern}' {path}") # List files containing pattern
|
||||
executeCommand("grep -r -i '{pattern}' {path}") # Case-insensitive search
|
||||
executeCommand("cat {path}") # Read file contents
|
||||
executeCommand("head -50 {path}") # Read first 50 lines
|
||||
executeCommand("write {path} {content}") # Create or overwrite file
|
||||
```
|
||||
|
|
@ -66,9 +81,10 @@ executeCommand("write {path} {content}") # Create or overwrite file
|
|||
**Important:** Use shell escaping for paths with spaces:
|
||||
```
|
||||
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
|
||||
executeCommand("grep -r 'David' 'knowledge_folder/People/'")
|
||||
```
|
||||
|
||||
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
|
||||
|
||||
# Output
|
||||
|
||||
Either:
|
||||
|
|
@ -259,68 +275,44 @@ Variants found:
|
|||
|
||||
---
|
||||
|
||||
# Step 3: Search for Existing Notes
|
||||
# Step 3: Look Up Existing Notes in Index
|
||||
|
||||
For each variant identified, search the notes folder thoroughly.
|
||||
**Use the provided knowledge_index to find existing notes. Do NOT use grep commands.**
|
||||
|
||||
## 3a: Search by People
|
||||
```bash
|
||||
# Search by full name
|
||||
executeCommand("grep -r -i -l 'Sarah Chen' '{knowledge_folder}/'")
|
||||
## 3a: Look Up People
|
||||
|
||||
# Search by first name in People folder
|
||||
executeCommand("grep -r -i -l 'Sarah' '{knowledge_folder}/People/'")
|
||||
For each person variant (name, email, alias), check the index:
|
||||
|
||||
# Search by email
|
||||
executeCommand("grep -r -i -l 'sarah@acme.com' '{knowledge_folder}/'")
|
||||
|
||||
# Search by email domain (finds all people from same company)
|
||||
executeCommand("grep -r -i -l '@acme.com' '{knowledge_folder}/'")
|
||||
|
||||
# Search Aliases fields
|
||||
executeCommand("grep -r -i 'Aliases.*Sarah' '{knowledge_folder}/People/'")
|
||||
```
|
||||
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: Search by Organizations
|
||||
```bash
|
||||
# List all organization notes
|
||||
executeCommand("ls '{knowledge_folder}/Organizations/'")
|
||||
## 3b: Look Up Organizations
|
||||
|
||||
# Search for organization name
|
||||
executeCommand("grep -r -i -l 'Acme' '{knowledge_folder}/Organizations/'")
|
||||
|
||||
# Search by domain
|
||||
executeCommand("grep -r -i 'Domain.*acme.com' '{knowledge_folder}/Organizations/'")
|
||||
|
||||
# Search Aliases
|
||||
executeCommand("grep -r -i 'Aliases.*Acme' '{knowledge_folder}/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: Search by Projects and Topics
|
||||
```bash
|
||||
# List all projects
|
||||
executeCommand("ls '{knowledge_folder}/Projects/'")
|
||||
## 3c: Look Up Projects and Topics
|
||||
|
||||
# Search for project references
|
||||
executeCommand("grep -r -i 'pilot' '{knowledge_folder}/Projects/'")
|
||||
executeCommand("grep -r -i 'integration' '{knowledge_folder}/Projects/'")
|
||||
|
||||
# Search for projects involving the organization
|
||||
executeCommand("grep -r -i 'Acme' '{knowledge_folder}/Projects/'")
|
||||
|
||||
# List and search topics
|
||||
executeCommand("ls '{knowledge_folder}/Topics/'")
|
||||
executeCommand("grep -r -i 'SOC 2' '{knowledge_folder}/Topics/'")
|
||||
```
|
||||
From index, find matches for:
|
||||
- "the pilot" → Check Projects table for related names
|
||||
- "SOC 2" → Check Topics table for matching keywords
|
||||
```
|
||||
|
||||
## 3d: Read Candidate Notes
|
||||
## 3d: Read Full Notes When Needed
|
||||
|
||||
For every note file found in searches, read it to understand context:
|
||||
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'")
|
||||
executeCommand("cat '{knowledge_folder}/People/David Kim.md'")
|
||||
executeCommand("cat '{knowledge_folder}/Organizations/Acme Corp.md'")
|
||||
executeCommand("cat '{knowledge_folder}/Projects/Acme Integration.md'")
|
||||
```
|
||||
|
||||
**Why read these notes:**
|
||||
788
apps/x/packages/core/src/knowledge/note_creation_low.md
Normal file
788
apps/x/packages/core/src/knowledge/note_creation_low.md
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
---
|
||||
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
|
||||
- Calendar event metadata
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
1046
apps/x/packages/core/src/knowledge/note_creation_medium.md
Normal file
1046
apps/x/packages/core/src/knowledge/note_creation_medium.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
}) {
|
||||
this.idGenerator = idGenerator;
|
||||
// ensure runs directory exists
|
||||
fsp.mkdir(path.join(WorkDir, 'runs'), { recursive: true });
|
||||
}
|
||||
|
||||
async appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void> {
|
||||
|
|
|
|||
1308
apps/x/pnpm-lock.yaml
generated
1308
apps/x/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue