Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev

This commit is contained in:
tusharmagar 2026-01-20 00:47:59 +05:30
commit eb936e3290
19 changed files with 4442 additions and 175 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && tsc && mkdir -p dist/knowledge dist/pre_built && cp src/knowledge/note_creation.md dist/knowledge/ && cp src/pre_built/*.md dist/pre_built/",
"build": "rm -rf dist && tsc && 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": {

View file

@ -2,6 +2,7 @@ import { jsonSchema, ModelMessage } from "ai";
import fs from "fs";
import path from "path";
import { WorkDir } from "../config/config.js";
import { getNoteCreationStrictness } from "../config/note_creation_config.js";
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
@ -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> = {

View file

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

View 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}`;
}

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { autoConfigureStrictnessIfNeeded } from '../config/strictness_analyzer.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import {
@ -11,6 +12,7 @@ import {
resetState,
type GraphState,
} from './graph_state.js';
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -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) {

View file

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

View file

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

View 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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff