From b5424d92f92275a6e982be15d51d58f2ebbf174f Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 26 Feb 2026 11:42:43 +0530 Subject: [PATCH 01/13] Add OS-aware runtime context for cross-platform shell execution Detect the runtime platform and default shell at startup, inject platform context into assistant instructions, and replace hardcoded /bin/sh with the detected shell in command executors (cli + electron). Made-with: Cursor --- .../src/application/assistant/instructions.ts | 5 ++ .../application/assistant/runtime-context.ts | 69 +++++++++++++++++++ .../src/application/lib/command-executor.ts | 6 +- .../src/application/assistant/instructions.ts | 11 ++- .../application/assistant/runtime-context.ts | 69 +++++++++++++++++++ .../src/application/lib/command-executor.ts | 10 +-- 6 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/application/assistant/runtime-context.ts create mode 100644 apps/x/packages/core/src/application/assistant/runtime-context.ts diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index b6e49cf0..b22425c6 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -1,5 +1,8 @@ import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../../config/config.js"; +import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; + +const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks. @@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. +${runtimeContextPrompt} + ## Workspace access & scope - You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually. - If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location. diff --git a/apps/cli/src/application/assistant/runtime-context.ts b/apps/cli/src/application/assistant/runtime-context.ts new file mode 100644 index 00000000..f1011c2c --- /dev/null +++ b/apps/cli/src/application/assistant/runtime-context.ts @@ -0,0 +1,69 @@ +export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh'; +export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown'; + +export interface RuntimeContext { + platform: NodeJS.Platform; + osName: RuntimeOsName; + shellDialect: RuntimeShellDialect; + shellExecutable: string; +} + +export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; +} + +export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { + if (platform === 'win32') { + return { + platform, + osName: 'Windows', + shellDialect: 'windows-cmd', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'darwin') { + return { + platform, + osName: 'macOS', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'linux') { + return { + platform, + osName: 'Linux', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + return { + platform, + osName: 'Unknown', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; +} + +export function getRuntimeContextPrompt(runtime: RuntimeContext): string { + if (runtime.shellDialect === 'windows-cmd') { + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax) +- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`). +- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`). +- Do not assume macOS/Linux command syntax when the runtime is Windows.`; + } + + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax) +- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`). +- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux). +- Do not assume Windows command syntax when the runtime is POSIX.`; +} diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts index 814d9801..cd16f05e 100644 --- a/apps/cli/src/application/lib/command-executor.ts +++ b/apps/cli/src/application/lib/command-executor.ts @@ -1,11 +1,13 @@ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js'; +import { getExecutionShell } from '../assistant/runtime-context.js'; const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); +const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"]+|['"]+$/g, ''); @@ -91,7 +93,7 @@ export async function executeCommand( cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB - shell: '/bin/sh', // use sh for cross-platform compatibility + shell: EXECUTION_SHELL, }); return { @@ -125,7 +127,7 @@ export function executeCommandSync( cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', - shell: '/bin/sh', + shell: EXECUTION_SHELL, }); return { diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index c0365c0f..96b50bb3 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -1,5 +1,8 @@ import { skillCatalog } from "./skills/index.js"; import { WorkDir as BASE_DIR } from "../../config/config.js"; +import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; + +const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything. @@ -150,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear - Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files. - Keep user data safe—double-check before editing or deleting important resources. +${runtimeContextPrompt} + ## Workspace Access & Scope - **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. - **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. - **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). **CRITICAL - When the user asks you to work with files outside ~/.rowboat:** -- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command). +- Follow the detected runtime platform above for shell syntax and filesystem path style. +- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS). +- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\\\Desktop\`). - You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths. - NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`. - NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`. - NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder"). -- NEVER ask what OS the user is on - they are on macOS. +- NEVER ask what OS the user is on if runtime platform is already available. - Load the \`organize-files\` skill for guidance on file organization tasks. ## Builtin Tools vs Shell Commands diff --git a/apps/x/packages/core/src/application/assistant/runtime-context.ts b/apps/x/packages/core/src/application/assistant/runtime-context.ts new file mode 100644 index 00000000..f1011c2c --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/runtime-context.ts @@ -0,0 +1,69 @@ +export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh'; +export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown'; + +export interface RuntimeContext { + platform: NodeJS.Platform; + osName: RuntimeOsName; + shellDialect: RuntimeShellDialect; + shellExecutable: string; +} + +export function getExecutionShell(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh'; +} + +export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext { + if (platform === 'win32') { + return { + platform, + osName: 'Windows', + shellDialect: 'windows-cmd', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'darwin') { + return { + platform, + osName: 'macOS', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + if (platform === 'linux') { + return { + platform, + osName: 'Linux', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; + } + + return { + platform, + osName: 'Unknown', + shellDialect: 'posix-sh', + shellExecutable: getExecutionShell(platform), + }; +} + +export function getRuntimeContextPrompt(runtime: RuntimeContext): string { + if (runtime.shellDialect === 'windows-cmd') { + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax) +- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`). +- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`). +- Do not assume macOS/Linux command syntax when the runtime is Windows.`; + } + + return `## Runtime Platform (CRITICAL) +- Detected platform: **${runtime.platform}** +- Detected OS: **${runtime.osName}** +- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax) +- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`). +- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux). +- Do not assume Windows command syntax when the runtime is POSIX.`; +} diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 870f6a0c..947f49a0 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -1,11 +1,13 @@ import { exec, execSync, spawn, ChildProcess } from 'child_process'; import { promisify } from 'util'; import { getSecurityAllowList } from '../../config/security.js'; +import { getExecutionShell } from '../assistant/runtime-context.js'; const execPromise = promisify(exec); const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); +const EXECUTION_SHELL = getExecutionShell(); function sanitizeToken(token: string): string { return token.trim().replace(/^['"()]+|['"()]+$/g, ''); @@ -85,7 +87,7 @@ export async function executeCommand( cwd: options?.cwd, timeout: options?.timeout, maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB - shell: '/bin/sh', // use sh for cross-platform compatibility + shell: EXECUTION_SHELL, }); return { @@ -145,7 +147,7 @@ export function executeCommandAbortable( // Check if already aborted before spawning if (options?.signal?.aborted) { // Return a dummy process and a resolved result - const dummyProc = spawn('true', { shell: true }); + const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']); dummyProc.kill(); return { process: dummyProc, @@ -159,7 +161,7 @@ export function executeCommandAbortable( } const proc = spawn(command, [], { - shell: '/bin/sh', + shell: EXECUTION_SHELL, cwd: options?.cwd, detached: process.platform !== 'win32', // Create process group on Unix stdio: ['ignore', 'pipe', 'pipe'], @@ -273,7 +275,7 @@ export function executeCommandSync( cwd: options?.cwd, timeout: options?.timeout, encoding: 'utf-8', - shell: '/bin/sh', + shell: EXECUTION_SHELL, }); return { From 72534052e00da1e7c88490bc92b8fd61646a78e1 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Thu, 26 Feb 2026 15:29:16 +0530 Subject: [PATCH 02/13] fix: cmd+z behaviour on notes --- apps/x/apps/renderer/src/App.tsx | 63 +++++++++++++++++++ .../src/components/markdown-editor.tsx | 26 +++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4f8859ef..d3782925 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -452,6 +452,7 @@ function ContentHeader({ function App() { type ShortcutPane = 'left' | 'right' + type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) @@ -596,6 +597,8 @@ function App() { // File tab state const [fileTabs, setFileTabs] = useState([]) const [activeFileTabId, setActiveFileTabId] = useState(null) + const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) + const fileHistoryHandlersRef = useRef>(new Map()) const fileTabIdCounterRef = useRef(0) const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}` @@ -2036,6 +2039,13 @@ function App() { } return next }) + setEditorSessionByTabId((prev) => { + if (!(tabId in prev)) return prev + const next = { ...prev } + delete next[tabId] + return next + }) + fileHistoryHandlersRef.current.delete(tabId) }, [activeFileTabId, fileTabs, removeEditorCacheForPath]) const handleNewChatTab = useCallback(() => { @@ -2136,6 +2146,11 @@ function App() { setFileTabs((prev) => prev.map((tab) => ( tab.id === activeFileTabId ? { ...tab, path } : tab ))) + // Rebinds this tab to a different note path: reset editor session to clear undo history. + setEditorSessionByTabId((prev) => ({ + ...prev, + [activeFileTabId]: (prev[activeFileTabId] ?? 0) + 1, + })) return } } @@ -2352,6 +2367,46 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, []) + // Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior). + useEffect(() => { + const handleHistoryKeyDown = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey + if (!mod || e.altKey) return + + const key = e.key.toLowerCase() + const wantsUndo = key === 'z' && !e.shiftKey + const wantsRedo = (key === 'z' && e.shiftKey) || (!isMac && key === 'y') + if (!wantsUndo && !wantsRedo) return + + if (!selectedPath || !selectedPath.endsWith('.md') || !activeFileTabId) return + + const target = e.target as EventTarget | null + if (target instanceof HTMLElement) { + const inTipTapEditor = Boolean(target.closest('.tiptap-editor')) + const inOtherTextInput = ( + target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || target.isContentEditable + ) && !inTipTapEditor + if (inOtherTextInput) return + } + + const handlers = fileHistoryHandlersRef.current.get(activeFileTabId) + if (!handlers) return + + e.preventDefault() + e.stopPropagation() + if (wantsUndo) { + handlers.undo() + } else { + handlers.redo() + } + } + + document.addEventListener('keydown', handleHistoryKeyDown, true) + return () => document.removeEventListener('keydown', handleHistoryKeyDown, true) + }, [activeFileTabId, isMac, selectedPath]) + // Keyboard shortcuts for tab management useEffect(() => { const handleTabKeyDown = (e: KeyboardEvent) => { @@ -3136,6 +3191,14 @@ function App() { placeholder="Start writing..." wikiLinks={wikiLinkConfig} onImageUpload={handleImageUpload} + editorSessionKey={editorSessionByTabId[tab.id] ?? 0} + onHistoryHandlersChange={(handlers) => { + if (handlers) { + fileHistoryHandlersRef.current.set(tab.id, handlers) + } else { + fileHistoryHandlersRef.current.delete(tab.id) + } + }} /> ) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 74ad10b8..2db7bcbe 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -195,6 +195,8 @@ interface MarkdownEditorProps { placeholder?: string wikiLinks?: WikiLinkConfig onImageUpload?: (file: File) => Promise + editorSessionKey?: number + onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void } type WikiLinkMatch = { @@ -278,6 +280,8 @@ export function MarkdownEditor({ placeholder = 'Start writing...', wikiLinks, onImageUpload, + editorSessionKey = 0, + onHistoryHandlersChange, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -400,7 +404,7 @@ export function MarkdownEditor({ return false }, }, - }) + }, [editorSessionKey]) const orderedFiles = useMemo(() => { if (!wikiLinks) return [] @@ -489,12 +493,30 @@ export function MarkdownEditor({ isInternalUpdate.current = true // Pre-process to preserve blank lines const preprocessed = preprocessMarkdown(content) - editor.commands.setContent(preprocessed) + // Treat tab-open content as baseline: do not add hydration to undo history. + editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run() isInternalUpdate.current = false } } }, [editor, content]) + useEffect(() => { + if (!onHistoryHandlersChange) return + if (!editor) { + onHistoryHandlersChange(null) + return + } + + onHistoryHandlersChange({ + undo: () => editor.chain().focus().undo().run(), + redo: () => editor.chain().focus().redo().run(), + }) + + return () => { + onHistoryHandlersChange(null) + } + }, [editor, onHistoryHandlersChange]) + // Force re-render decorations when selection highlight changes useEffect(() => { if (editor) { From cccb7a8a65f62e6d37a47e41604002245f71e0e3 Mon Sep 17 00:00:00 2001 From: Tushar <47842976+tusharmagar@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:41:17 +0530 Subject: [PATCH 03/13] Add plus button to prompt input for file and image attachments (#381) * Add plus button to prompt input for file and image attachments Co-authored-by: Cursor * Refactor chat message attachment handling and improve UI for attachments in chat input and sidebar * fixed review comments --------- Co-authored-by: Cursor Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com> --- apps/x/apps/renderer/src/App.tsx | 190 ++++++++++--- .../components/chat-input-with-mentions.tsx | 263 ++++++++++++++---- .../components/chat-message-attachments.tsx | 137 +++++++++ .../renderer/src/components/chat-sidebar.tsx | 17 +- .../src/lib/attachment-presentation.ts | 107 +++++++ .../renderer/src/lib/chat-conversation.ts | 9 + apps/x/apps/renderer/src/lib/file-utils.ts | 61 ++++ apps/x/packages/core/src/agents/runtime.ts | 42 ++- .../core/src/application/lib/message-queue.ts | 10 +- apps/x/packages/core/src/runs/repo.ts | 30 +- apps/x/packages/core/src/runs/runs.ts | 4 +- apps/x/packages/shared/src/ipc.ts | 3 +- apps/x/packages/shared/src/message.ts | 23 +- 13 files changed, 782 insertions(+), 114 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chat-message-attachments.tsx create mode 100644 apps/x/apps/renderer/src/lib/attachment-presentation.ts create mode 100644 apps/x/apps/renderer/src/lib/file-utils.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d3782925..0ee4fa5d 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -9,7 +9,8 @@ import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeft import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; -import { ChatInputWithMentions } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; @@ -52,6 +53,7 @@ import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { + type ChatMessage, type ChatTabViewState, type ConversationItem, type ToolCall, @@ -1171,19 +1173,41 @@ function App() { if (msg.role === 'user' || msg.role === 'assistant') { // Extract text content from message let textContent = '' + let msgAttachments: ChatMessage['attachments'] = undefined if (typeof msg.content === 'string') { textContent = msg.content } else if (Array.isArray(msg.content)) { - // Extract text parts - textContent = msg.content - .filter((part: { type: string }) => part.type === 'text') - .map((part: { type: string; text?: string }) => part.text || '') + const contentParts = msg.content as Array<{ + type: string + text?: string + path?: string + filename?: string + mimeType?: string + size?: number + toolCallId?: string + toolName?: string + arguments?: ToolUIPart['input'] + }> + + textContent = contentParts + .filter((part) => part.type === 'text') + .map((part) => part.text || '') .join('') - + + const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path) + if (attachmentParts.length > 0) { + msgAttachments = attachmentParts.map((part) => ({ + path: part.path!, + filename: part.filename || part.path!.split('/').pop() || part.path!, + mimeType: part.mimeType || 'application/octet-stream', + size: part.size, + })) + } + // Also extract tool-call parts from assistant messages if (msg.role === 'assistant') { - for (const part of msg.content) { - if (part.type === 'tool-call') { + for (const part of contentParts) { + if (part.type === 'tool-call' && part.toolCallId && part.toolName) { const toolCall: ToolCall = { id: part.toolCallId, name: part.toolName, @@ -1197,11 +1221,12 @@ function App() { } } } - if (textContent) { + if (textContent || msgAttachments) { items.push({ id: event.messageId, role: msg.role, content: textContent, + attachments: msgAttachments, timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), }) } @@ -1618,20 +1643,35 @@ function App() { return cleanup }, [handleRunEvent]) - const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => { + const handlePromptSubmit = async ( + message: PromptInputMessage, + mentions?: FileMention[], + stagedAttachments: StagedAttachment[] = [] + ) => { if (isProcessing) return - const { text } = message; + const { text } = message const userMessage = text.trim() - if (!userMessage) return + const hasAttachments = stagedAttachments.length > 0 + if (!userMessage && !hasAttachments) return setMessage('') const userMessageId = `user-${Date.now()}` - setConversation(prev => [...prev, { + const displayAttachments: ChatMessage['attachments'] = hasAttachments + ? stagedAttachments.map((attachment) => ({ + path: attachment.path, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + thumbnailUrl: attachment.thumbnailUrl, + })) + : undefined + setConversation((prev) => [...prev, { id: userMessageId, role: 'user', content: userMessage, + attachments: displayAttachments, timestamp: Date.now(), }]) @@ -1647,42 +1687,98 @@ function App() { newRunCreatedAt = run.createdAt setRunId(currentRunId) // Update active chat tab's runId to the new run - setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t)) + setChatTabs((prev) => prev.map((tab) => ( + tab.id === activeChatTabId + ? { ...tab, runId: currentRunId } + : tab + ))) isNewRun = true } - // Read mentioned file contents and format message with XML context - let formattedMessage = userMessage - if (mentions && mentions.length > 0) { - const attachedFiles = await Promise.all( - mentions.map(async (m) => { - try { - const result = await window.ipc.invoke('workspace:readFile', { path: m.path }) - return { path: m.path, content: result.data as string } - } catch (err) { - console.error('Failed to read mentioned file:', m.path, err) - return { path: m.path, content: `[Error reading file: ${m.path}]` } - } - }) - ) + let titleSource = userMessage - if (attachedFiles.length > 0) { - const filesXml = attachedFiles - .map(f => `\n${f.content}\n`) - .join('\n') - formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + if (hasAttachments) { + type ContentPart = + | { type: 'text'; text: string } + | { + type: 'attachment' + path: string + filename: string + mimeType: string + size?: number + } + + const contentParts: ContentPart[] = [] + + if (mentions && mentions.length > 0) { + for (const mention of mentions) { + contentParts.push({ + type: 'attachment', + path: mention.path, + filename: mention.displayName || mention.path.split('/').pop() || mention.path, + mimeType: 'text/markdown', + }) + } } + + for (const attachment of stagedAttachments) { + contentParts.push({ + type: 'attachment', + path: attachment.path, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + }) + } + + if (userMessage) { + contentParts.push({ type: 'text', text: userMessage }) + } else { + titleSource = stagedAttachments[0]?.filename ?? '' + } + + // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. + const attachmentPayload = contentParts as unknown as string + await window.ipc.invoke('runs:createMessage', { + runId: currentRunId, + message: attachmentPayload, + }) + } else { + // Legacy path: plain string with optional XML-formatted @mentions. + let formattedMessage = userMessage + if (mentions && mentions.length > 0) { + const attachedFiles = await Promise.all( + mentions.map(async (mention) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: mention.path }) + return { path: mention.path, content: result.data as string } + } catch (err) { + console.error('Failed to read mentioned file:', mention.path, err) + return { path: mention.path, content: `[Error reading file: ${mention.path}]` } + } + }) + ) + + if (attachedFiles.length > 0) { + const filesXml = attachedFiles + .map((file) => `\n${file.content}\n`) + .join('\n') + formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + } + } + + await window.ipc.invoke('runs:createMessage', { + runId: currentRunId, + message: formattedMessage, + }) + + titleSource = formattedMessage } - await window.ipc.invoke('runs:createMessage', { - runId: currentRunId, - message: formattedMessage, - }) - if (isNewRun) { - const inferredTitle = inferRunTitleFromMessage(formattedMessage) - setRuns(prev => { - const withoutCurrent = prev.filter(run => run.id !== currentRunId) + const inferredTitle = inferRunTitleFromMessage(titleSource) + setRuns((prev) => { + const withoutCurrent = prev.filter((run) => run.id !== currentRunId) return [{ id: currentRunId!, title: inferredTitle, @@ -2849,6 +2945,18 @@ function App() { const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { + if (item.attachments && item.attachments.length > 0) { + return ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 31bcba17..d3554c00 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -1,7 +1,28 @@ -import { useCallback, useEffect } from 'react' -import { ArrowUp, LoaderIcon, Square } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + ArrowUp, + AudioLines, + FileArchive, + FileCode2, + FileIcon, + FileSpreadsheet, + FileText, + FileVideo, + LoaderIcon, + Plus, + Square, + X, +} from 'lucide-react' import { Button } from '@/components/ui/button' +import { + type AttachmentIconKind, + getAttachmentDisplayName, + getAttachmentIconKind, + getAttachmentToneClass, + getAttachmentTypeLabel, +} from '@/lib/attachment-presentation' +import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils' import { cn } from '@/lib/utils' import { type FileMention, @@ -10,9 +31,41 @@ import { PromptInputTextarea, usePromptInputController, } from '@/components/ai-elements/prompt-input' +import { toast } from 'sonner' + +export type StagedAttachment = { + id: string + path: string + filename: string + mimeType: string + isImage: boolean + size: number + thumbnailUrl?: string +} + +const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB + +function getAttachmentIcon(kind: AttachmentIconKind) { + switch (kind) { + case 'audio': + return AudioLines + case 'video': + return FileVideo + case 'spreadsheet': + return FileSpreadsheet + case 'archive': + return FileArchive + case 'code': + return FileCode2 + case 'text': + return FileText + default: + return FileIcon + } +} interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -38,7 +91,10 @@ function ChatInputInner({ }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value - const canSubmit = Boolean(message.trim()) && !isProcessing + const [attachments, setAttachments] = useState([]) + const [focusNonce, setFocusNonce] = useState(0) + const fileInputRef = useRef(null) + const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing // Restore the tab draft when this input mounts. useEffect(() => { @@ -59,12 +115,48 @@ function ChatInputInner({ } }, [presetMessage, controller.textInput, onPresetMessageConsumed]) + const addFiles = useCallback(async (paths: string[]) => { + const newAttachments: StagedAttachment[] = [] + for (const filePath of paths) { + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath }) + if (result.size > MAX_ATTACHMENT_SIZE) { + toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`) + continue + } + const mime = result.mimeType || getMimeFromExtension(getExtension(filePath)) + const image = isImageMime(mime) + newAttachments.push({ + id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + path: filePath, + filename: getFileDisplayName(filePath), + mimeType: mime, + isImage: image, + size: result.size, + thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined, + }) + } catch (err) { + console.error('Failed to read file:', filePath, err) + toast.error(`Failed to read: ${getFileDisplayName(filePath)}`) + } + } + if (newAttachments.length > 0) { + setAttachments((prev) => [...prev, ...newAttachments]) + setFocusNonce((value) => value + 1) + } + }, []) + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) + }, []) + const handleSubmit = useCallback(() => { if (!canSubmit) return - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments) controller.textInput.clear() controller.mentions.clearMentions() - }, [canSubmit, message, onSubmit, controller]) + setAttachments([]) + }, [attachments, canSubmit, controller, message, onSubmit]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -88,11 +180,9 @@ function ChatInputInner({ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { const paths = Array.from(e.dataTransfer.files) .map((file) => window.electronUtils?.getPathForFile(file)) - .filter(Boolean) + .filter(Boolean) as string[] if (paths.length > 0) { - const currentText = controller.textInput.value - const pathText = paths.join(' ') - controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText) + void addFiles(paths) } } } @@ -103,50 +193,119 @@ function ChatInputInner({ document.removeEventListener('dragover', onDragOver) document.removeEventListener('drop', onDrop) } - }, [controller, isActive]) + }, [addFiles, isActive]) return ( -
- - {isProcessing ? ( - - ) : ( - +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment) => { + const attachmentType = getAttachmentTypeLabel(attachment) + const attachmentName = getAttachmentDisplayName(attachment) + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + + return ( + + + {attachment.isImage && attachment.thumbnailUrl ? ( + + ) : ( + + )} + + + {attachmentName} + {attachmentType} + + + + ) + })} +
)} +
+ { + const files = e.target.files + if (!files || files.length === 0) return + const paths = Array.from(files) + .map((file) => window.electronUtils?.getPathForFile(file)) + .filter(Boolean) as string[] + if (paths.length > 0) { + void addFiles(paths) + } + e.target.value = '' + }} + /> + + + {isProcessing ? ( + + ) : ( + + )} +
) } @@ -155,7 +314,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: string[] visibleFiles: string[] - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-message-attachments.tsx b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx new file mode 100644 index 00000000..298e5f03 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx @@ -0,0 +1,137 @@ +import { + AudioLines, + FileArchive, + FileCode2, + FileIcon, + FileSpreadsheet, + FileText, + FileVideo, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import type { MessageAttachment } from '@/lib/chat-conversation' +import { + type AttachmentIconKind, + getAttachmentDisplayName, + getAttachmentIconKind, + getAttachmentToneClass, + getAttachmentTypeLabel, +} from '@/lib/attachment-presentation' +import { isImageMime, toFileUrl } from '@/lib/file-utils' +import { cn } from '@/lib/utils' + +function getAttachmentIcon(kind: AttachmentIconKind) { + switch (kind) { + case 'audio': + return AudioLines + case 'video': + return FileVideo + case 'spreadsheet': + return FileSpreadsheet + case 'archive': + return FileArchive + case 'code': + return FileCode2 + case 'text': + return FileText + default: + return FileIcon + } +} + +function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) { + const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path]) + const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl) + const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl)) + + useEffect(() => { + const nextSrc = attachment.thumbnailUrl || fallbackFileUrl + setSrc(nextSrc) + setTriedBase64(Boolean(attachment.thumbnailUrl)) + }, [attachment.thumbnailUrl, fallbackFileUrl]) + + const loadBase64 = useMemo( + () => async () => { + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path }) + const mimeType = result.mimeType || attachment.mimeType || 'image/*' + setSrc(`data:${mimeType};base64,${result.data}`) + } catch { + // Keep current src; fallback rendering (broken image icon) is better than crashing. + } + }, + [attachment.mimeType, attachment.path] + ) + + useEffect(() => { + if (attachment.thumbnailUrl || triedBase64) return + setTriedBase64(true) + void loadBase64() + }, [attachment.thumbnailUrl, loadBase64, triedBase64]) + + return ( + Image attachment { + if (triedBase64) return + setTriedBase64(true) + void loadBase64() + }} + /> + ) +} + +interface ChatMessageAttachmentsProps { + attachments: MessageAttachment[] + className?: string +} + +export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) { + if (attachments.length === 0) return null + + const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType)) + const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType)) + + return ( +
+ {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + ))} +
+ )} + {fileAttachments.length > 0 && ( +
+ {fileAttachments.map((attachment, index) => { + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + const attachmentName = getAttachmentDisplayName(attachment) + const attachmentType = getAttachmentTypeLabel(attachment) + return ( + + + + + + {attachmentName} + {attachmentType} + + + ) + })} +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 170e8869..f020cdae 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -25,7 +25,8 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions' +import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { type ChatTabViewState, @@ -89,7 +90,7 @@ interface ChatSidebarProps { isProcessing: boolean isStopping?: boolean onStop?: () => void - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void knowledgeFiles?: string[] recentFiles?: string[] visibleFiles?: string[] @@ -256,6 +257,18 @@ export function ChatSidebar({ const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { + if (item.attachments && item.attachments.length > 0) { + return ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( diff --git a/apps/x/apps/renderer/src/lib/attachment-presentation.ts b/apps/x/apps/renderer/src/lib/attachment-presentation.ts new file mode 100644 index 00000000..7ddedd30 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/attachment-presentation.ts @@ -0,0 +1,107 @@ +import { getExtension } from '@/lib/file-utils' + +export type AttachmentLike = { + filename?: string + path: string + mimeType: string +} + +export type AttachmentIconKind = + | 'audio' + | 'video' + | 'spreadsheet' + | 'archive' + | 'code' + | 'text' + | 'file' + +const ARCHIVE_EXTENSIONS = new Set([ + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', +]) + +const SPREADSHEET_EXTENSIONS = new Set([ + 'csv', 'tsv', 'xls', 'xlsx', +]) + +const CODE_EXTENSIONS = new Set([ + 'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml', + 'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp', + 'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md', +]) + +export function getAttachmentDisplayName(attachment: AttachmentLike): string { + if (attachment.filename) return attachment.filename + const fromPath = attachment.path.split(/[\\/]/).pop() + return fromPath || attachment.path +} + +export function getAttachmentTypeLabel(attachment: AttachmentLike): string { + const ext = getExtension(getAttachmentDisplayName(attachment)) + if (ext) return ext.toUpperCase() + + const mediaType = attachment.mimeType.toLowerCase() + if (mediaType.startsWith('audio/')) return 'AUDIO' + if (mediaType.startsWith('video/')) return 'VIDEO' + if (mediaType.startsWith('text/')) return 'TEXT' + if (mediaType.startsWith('image/')) return 'IMAGE' + + const [, subtypeRaw = 'file'] = mediaType.split('/') + const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file' + const cleaned = subtype.replace(/[^a-z0-9]/gi, '') + return cleaned ? cleaned.toUpperCase() : 'FILE' +} + +export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind { + const mediaType = attachment.mimeType.toLowerCase() + const ext = getExtension(attachment.filename || attachment.path) + + if (mediaType.startsWith('audio/')) return 'audio' + if (mediaType.startsWith('video/')) return 'video' + if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet' + if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive' + if ( + mediaType.includes('json') + || mediaType.includes('javascript') + || mediaType.includes('typescript') + || mediaType.includes('xml') + || CODE_EXTENSIONS.has(ext) + ) { + return 'code' + } + if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) { + return 'text' + } + + return 'file' +} + +export function getAttachmentToneClass(typeLabel: string): string { + switch (typeLabel) { + case 'PDF': + return 'bg-red-500 text-white' + case 'CSV': + case 'XLS': + case 'XLSX': + case 'TSV': + return 'bg-emerald-500 text-white' + case 'ZIP': + case 'RAR': + case '7Z': + case 'TAR': + case 'GZ': + return 'bg-amber-500 text-white' + case 'MP3': + case 'WAV': + case 'M4A': + case 'FLAC': + case 'AAC': + return 'bg-fuchsia-500 text-white' + case 'MP4': + case 'MOV': + case 'AVI': + case 'WEBM': + return 'bg-violet-500 text-white' + default: + return 'bg-primary/85 text-primary-foreground' + } +} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 4dcd4c8c..830e250b 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -2,10 +2,19 @@ import type { ToolUIPart } from 'ai' import z from 'zod' import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' +export interface MessageAttachment { + path: string + filename: string + mimeType: string + size?: number + thumbnailUrl?: string +} + export interface ChatMessage { id: string role: 'user' | 'assistant' content: string + attachments?: MessageAttachment[] timestamp: number } diff --git a/apps/x/apps/renderer/src/lib/file-utils.ts b/apps/x/apps/renderer/src/lib/file-utils.ts new file mode 100644 index 00000000..3ac3431a --- /dev/null +++ b/apps/x/apps/renderer/src/lib/file-utils.ts @@ -0,0 +1,61 @@ +const IMAGE_MIMES = new Set([ + 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', + 'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif', +]); + +const EXTENSION_TO_MIME: Record = { + // Images + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', + webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico', + avif: 'image/avif', tiff: 'image/tiff', + // Text / code + txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css', + csv: 'text/csv', xml: 'text/xml', + js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript', + tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml', + yml: 'text/yaml', toml: 'text/toml', + py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust', + go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++', + h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript', + // Documents + pdf: 'application/pdf', + // Archives + zip: 'application/zip', +}; + +export function isImageMime(mimeType: string): boolean { + return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/'); +} + +export function getMimeFromExtension(ext: string): string { + const normalized = ext.toLowerCase().replace(/^\./, ''); + return EXTENSION_TO_MIME[normalized] || 'application/octet-stream'; +} + +export function getFileDisplayName(filePath: string): string { + return filePath.split('/').pop() || filePath; +} + +export function getExtension(filePath: string): string { + const name = filePath.split('/').pop() || ''; + const dotIndex = name.lastIndexOf('.'); + return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : ''; +} + +export function toFileUrl(filePath: string): string { + if (!filePath) return filePath; + if ( + filePath.startsWith('data:') || + filePath.startsWith('file://') || + filePath.startsWith('http://') || + filePath.startsWith('https://') + ) { + return filePath; + } + const normalized = filePath.replace(/\\/g, '/'); + const encoded = encodeURI(normalized); + if (/^[A-Za-z]:\//.test(normalized)) { + return `file:///${encoded}`; + } + return `file://${encoded}`; +} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index abeeb53a..e1924523 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise> { return await repo.fetch(id); } +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function convertFromMessages(messages: z.infer[]): ModelMessage[] { const result: ModelMessage[] = []; for (const msg of messages) { @@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer[]): ModelM }); break; case "user": - result.push({ - role: "user", - content: msg.content, - providerOptions, - }); + if (typeof msg.content === 'string') { + // Legacy string — pass through unchanged + result.push({ + role: "user", + content: msg.content, + providerOptions, + }); + } else { + // New content parts array — collapse to text for LLM + const textSegments: string[] = []; + const attachmentLines: string[] = []; + + for (const part of msg.content) { + if (part.type === "attachment") { + const sizeStr = part.size ? `, ${formatBytes(part.size)}` : ''; + attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`); + } else { + textSegments.push(part.text); + } + } + + if (attachmentLines.length > 0) { + textSegments.unshift("User has attached the following files:", ...attachmentLines, ""); + } + + result.push({ + role: "user", + content: textSegments.join("\n"), + providerOptions, + }); + } break; case "tool": result.push({ diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts index c60ecd1f..2b864840 100644 --- a/apps/x/packages/core/src/application/lib/message-queue.ts +++ b/apps/x/packages/core/src/application/lib/message-queue.ts @@ -1,12 +1,16 @@ import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js"; +import { UserMessageContent } from "@x/shared/dist/message.js"; +import z from "zod"; + +export type UserMessageContentType = z.infer; type EnqueuedMessage = { messageId: string; - message: string; + message: UserMessageContentType; }; export interface IMessageQueue { - enqueue(runId: string, message: string): Promise; + enqueue(runId: string, message: UserMessageContentType): Promise; dequeue(runId: string): Promise; } @@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue { this.idGenerator = idGenerator; } - async enqueue(runId: string, message: string): Promise { + async enqueue(runId: string, message: UserMessageContentType): Promise { if (!this.store[runId]) { this.store[runId] = []; } diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 15873c49..5d563f1f 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo { const messageEvent = event as z.infer; if (messageEvent.message.role === 'user') { const content = messageEvent.message.content; - if (typeof content === 'string' && content.trim()) { - // Clean attached-files XML and @mentions, then truncate to 100 chars - const cleaned = cleanContentForTitle(content); - if (!cleaned) continue; // Skip if only attached files/mentions + let textContent: string | undefined; + if (typeof content === 'string') { + textContent = content; + } else { + textContent = content + .filter(p => p.type === 'text') + .map(p => p.text) + .join(''); + } + if (textContent && textContent.trim()) { + const cleaned = cleanContentForTitle(textContent); + if (!cleaned) continue; return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; } } @@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo { if (msg.role === 'user') { // Found first user message - use as title const content = msg.content; - if (typeof content === 'string' && content.trim()) { - // Clean attached-files XML and @mentions, then truncate - const cleaned = cleanContentForTitle(content); + let textContent: string | undefined; + if (typeof content === 'string') { + textContent = content; + } else { + textContent = content + .filter(p => p.type === 'text') + .map(p => p.text) + .join(''); + } + if (textContent && textContent.trim()) { + const cleaned = cleanContentForTitle(textContent); if (cleaned) { title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 75f71d5f..0f123497 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -1,6 +1,6 @@ import z from "zod"; import container from "../di/container.js"; -import { IMessageQueue } from "../application/lib/message-queue.js"; +import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js"; import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js"; import { IRunsRepo } from "./repo.js"; import { IAgentRuntime } from "../agents/runtime.js"; @@ -19,7 +19,7 @@ export async function createRun(opts: z.infer): Promise return run; } -export async function createMessage(runId: string, message: string): Promise { +export async function createMessage(runId: string, message: UserMessageContentType): Promise { const queue = container.resolve('messageQueue'); const id = await queue.enqueue(runId, message); const runtime = container.resolve('agentRuntime'); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 71491f8f..c0276d9a 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; +import { UserMessageContent } from './message.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -128,7 +129,7 @@ const ipcSchemas = { 'runs:createMessage': { req: z.object({ runId: z.string(), - message: z.string(), + message: UserMessageContent, }), res: z.object({ messageId: z.string(), diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts index 702b103a..be761853 100644 --- a/apps/x/packages/shared/src/message.ts +++ b/apps/x/packages/shared/src/message.ts @@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([ ToolCallPart, ]); +// A piece of user-typed text within a content array +export const UserTextPart = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +// An attachment within a content array +export const UserAttachmentPart = z.object({ + type: z.literal("attachment"), + path: z.string(), // absolute file path + filename: z.string(), // display name ("photo.png") + mimeType: z.string(), // MIME type ("image/png", "text/plain") + size: z.number().optional(), // bytes +}); + +// Any single part of a user message (text or attachment) +export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]); + +// Named type for user message content — used everywhere instead of repeating the union +export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]); + export const UserMessage = z.object({ role: z.literal("user"), - content: z.string(), + content: UserMessageContent, providerOptions: ProviderOptions.optional(), }); From 9df1bb6765226a8516bbbd2d027d53286c89ce15 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:52:19 +0530 Subject: [PATCH 04/13] Config graph model (#411) * config to specify graph model --- .../src/components/onboarding-modal.tsx | 121 +++++++++++------ .../src/components/settings-dialog.tsx | 123 ++++++++++++------ apps/x/packages/core/src/agents/runtime.ts | 7 +- apps/x/packages/shared/src/models.ts | 1 + 4 files changed, 166 insertions(+), 86 deletions(-) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 4855cab7..9398f2fe 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -57,14 +57,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "" }, - anthropic: { apiKey: "", baseURL: "", model: "" }, - google: { apiKey: "", baseURL: "", model: "" }, - openrouter: { apiKey: "", baseURL: "", model: "" }, - aigateway: { apiKey: "", baseURL: "", model: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, }) const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle", @@ -87,7 +87,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [slackConnecting, setSlackConnecting] = useState(false) const updateProviderConfig = useCallback( - (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { + (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [provider]: { ...prev[provider], ...updates }, @@ -287,6 +287,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const apiKey = activeConfig.apiKey.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined const model = activeConfig.model.trim() + const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined const providerConfig = { provider: { flavor: llmProvider, @@ -294,6 +295,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { baseURL, }, model, + knowledgeGraphModel, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -657,39 +659,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { )}
-
- Model - {modelsLoading ? ( -
- - Loading models... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { model: e.target.value })} - placeholder="Enter model" - /> - ) : ( - - )} - {modelsError && ( -
{modelsError}
- )} +
+
+ Assistant model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { model: e.target.value })} + placeholder="Enter model" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ +
+ Knowledge graph model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{showApiKey && ( diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 840b9cf4..2948ae02 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -167,14 +167,14 @@ const defaultBaseURLs: Partial> = { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState("openai") - const [providerConfigs, setProviderConfigs] = useState>({ - openai: { apiKey: "", baseURL: "", model: "" }, - anthropic: { apiKey: "", baseURL: "", model: "" }, - google: { apiKey: "", baseURL: "", model: "" }, - openrouter: { apiKey: "", baseURL: "", model: "" }, - aigateway: { apiKey: "", baseURL: "", model: "" }, - ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, - "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, + const [providerConfigs, setProviderConfigs] = useState>({ + openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" }, + ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" }, + "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" }, }) const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) @@ -199,7 +199,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) const updateConfig = useCallback( - (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { + (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { setProviderConfigs(prev => ({ ...prev, [prov]: { ...prev[prov], ...updates }, @@ -229,6 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { apiKey: parsed.provider.apiKey || "", baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), model: parsed.model, + knowledgeGraphModel: parsed.knowledgeGraphModel || "", }, })) } @@ -296,6 +297,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { baseURL: activeConfig.baseURL.trim() || undefined, }, model: activeConfig.model.trim(), + knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, } const result = await window.ipc.invoke("models:test", providerConfig) if (result.success) { @@ -362,40 +364,75 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { )}
- {/* Model selection */} -
- Model - {modelsLoading ? ( -
- - Loading models... -
- ) : showModelInput ? ( - updateConfig(provider, { model: e.target.value })} - placeholder="Enter model" - /> - ) : ( - - )} - {modelsError && ( -
{modelsError}
- )} + {/* Model selection - side by side */} +
+
+ Assistant model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { model: e.target.value })} + placeholder="Enter model" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ +
+ Knowledge graph model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { knowledgeGraphModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{/* API Key */} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index e1924523..0aeb167f 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -706,7 +706,12 @@ export async function* streamAgent({ // set up provider + model const provider = createProvider(modelConfig.provider); - const model = provider.languageModel(modelConfig.model); + const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"]; + const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel) + ? modelConfig.knowledgeGraphModel + : modelConfig.model; + const model = provider.languageModel(modelId); + logger.log(`using model: ${modelId}`); let loopCounter = 0; while (true) { diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 14e91689..48085e9f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -10,4 +10,5 @@ export const LlmProvider = z.object({ export const LlmModelConfig = z.object({ provider: LlmProvider, model: z.string(), + knowledgeGraphModel: z.string().optional(), }); From d7dc27a77e38ad93135999d44a32328489bb788a Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:22:54 +0530 Subject: [PATCH 05/13] History (#406) * notes history --- apps/x/apps/main/src/ipc.ts | 29 +++ apps/x/apps/renderer/src/App.tsx | 152 ++++++++--- .../src/components/markdown-editor.tsx | 10 + .../src/components/version-history-panel.tsx | 177 +++++++++++++ apps/x/packages/core/package.json | 1 + apps/x/packages/core/src/config/config.ts | 7 +- apps/x/packages/core/src/index.ts | 5 +- .../core/src/knowledge/build_graph.ts | 15 ++ .../core/src/knowledge/version_history.ts | 243 ++++++++++++++++++ .../packages/core/src/workspace/workspace.ts | 21 ++ apps/x/packages/shared/src/ipc.ts | 24 ++ apps/x/pnpm-lock.yaml | 216 +++++++++++++++- 12 files changed, 860 insertions(+), 40 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/version-history-panel.tsx create mode 100644 apps/x/packages/core/src/knowledge/version_history.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b0757881..4d272275 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -31,6 +31,7 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { search } from '@x/core/dist/search/search.js'; +import { versionHistory } from '@x/core'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -105,6 +106,18 @@ let watcher: FSWatcher | null = null; const changeQueue = new Set(); let debounceTimer: ReturnType | null = null; +/** + * Emit knowledge commit event to all renderer windows + */ +function emitKnowledgeCommitEvent(): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('knowledge:didCommit', {}); + } + } +} + /** * Emit workspace change event to all renderer windows */ @@ -283,6 +296,9 @@ export function stopServicesWatcher(): void { * Add new handlers here as you add channels to IPCChannels */ export function setupIpcHandlers() { + // Forward knowledge commit events to renderer for panel refresh + versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + registerIpcHandlers({ 'app:getVersions': async () => { // args is null for this channel (no request payload) @@ -498,6 +514,19 @@ export function setupIpcHandlers() { const mimeType = mimeMap[ext] || 'application/octet-stream'; return { data: buffer.toString('base64'), mimeType, size: stat.size }; }, + // Knowledge version history handlers + 'knowledge:history': async (_event, args) => { + const commits = await versionHistory.getFileHistory(args.path); + return { commits }; + }, + 'knowledge:fileAtCommit': async (_event, args) => { + const content = await versionHistory.getFileAtCommit(args.path, args.oid); + return { content }; + }, + 'knowledge:restore': async (_event, args) => { + await versionHistory.restoreFile(args.path, args.oid); + return { ok: true }; + }, // Search handler 'search:query': async (_event, args) => { return search(args.query, args.limit, args.types); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 0ee4fa5d..af6a4740 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -49,6 +49,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin import { OnboardingModal } from '@/components/onboarding-modal' import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' @@ -506,6 +507,13 @@ function App() { const initialContentRef = useRef('') const renameInProgressRef = useRef(false) + // Version history state + const [versionHistoryPath, setVersionHistoryPath] = useState(null) + const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{ + oid: string + content: string + } | null>(null) + // Chat state const [, setMessage] = useState('') const [conversation, setConversation] = useState([]) @@ -1072,6 +1080,14 @@ function App() { saveFile() }, [debouncedContent, setHistory]) + // Close version history panel when switching files + useEffect(() => { + if (versionHistoryPath && selectedPath !== versionHistoryPath) { + setVersionHistoryPath(null) + setViewingHistoricalVersion(null) + } + }, [selectedPath, versionHistoryPath]) + // Load runs list (all pages) const loadRuns = useCallback(async () => { try { @@ -3213,6 +3229,31 @@ function App() { ) : null}
)} + {selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && ( + + + + + Version history + + )} {!selectedPath && !isGraphOpen && !selectedTask && ( @@ -3276,41 +3317,80 @@ function App() { ) : selectedPath ? ( selectedPath.endsWith('.md') ? ( -
- {openMarkdownTabs.map((tab) => { - const isActive = activeFileTabId - ? tab.id === activeFileTabId || tab.path === selectedPath - : tab.path === selectedPath - const tabContent = editorContentByPath[tab.path] - ?? (isActive && editorPathRef.current === tab.path ? editorContent : '') - return ( -
- handleEditorChange(tab.path, markdown)} - placeholder="Start writing..." - wikiLinks={wikiLinkConfig} - onImageUpload={handleImageUpload} - editorSessionKey={editorSessionByTabId[tab.id] ?? 0} - onHistoryHandlersChange={(handlers) => { - if (handlers) { - fileHistoryHandlersRef.current.set(tab.id, handlers) - } else { - fileHistoryHandlersRef.current.delete(tab.id) - } - }} - /> -
- ) - })} +
+
+ {openMarkdownTabs.map((tab) => { + const isActive = activeFileTabId + ? tab.id === activeFileTabId || tab.path === selectedPath + : tab.path === selectedPath + const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path + const tabContent = isViewingHistory + ? viewingHistoricalVersion.content + : editorContentByPath[tab.path] + ?? (isActive && editorPathRef.current === tab.path ? editorContent : '') + return ( +
+ { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} + placeholder="Start writing..." + wikiLinks={wikiLinkConfig} + onImageUpload={handleImageUpload} + editorSessionKey={editorSessionByTabId[tab.id] ?? 0} + onHistoryHandlersChange={(handlers) => { + if (handlers) { + fileHistoryHandlersRef.current.set(tab.id, handlers) + } else { + fileHistoryHandlersRef.current.delete(tab.id) + } + }} + editable={!isViewingHistory} + /> +
+ ) + })} +
+ {versionHistoryPath && ( + { + setVersionHistoryPath(null) + setViewingHistoricalVersion(null) + }} + onSelectVersion={(oid, content) => { + if (oid === null) { + setViewingHistoricalVersion(null) + } else { + setViewingHistoricalVersion({ oid, content }) + } + }} + onRestore={async (oid) => { + try { + await window.ipc.invoke('knowledge:restore', { + path: versionHistoryPath.startsWith('knowledge/') + ? versionHistoryPath.slice('knowledge/'.length) + : versionHistoryPath, + oid, + }) + // Reload file content + const result = await window.ipc.invoke('workspace:readFile', { path: versionHistoryPath }) + handleEditorChange(versionHistoryPath, result.data) + setViewingHistoricalVersion(null) + setVersionHistoryPath(null) + } catch (err) { + console.error('Failed to restore version:', err) + } + }} + /> + )}
) : (
diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 2db7bcbe..6bcaef29 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -197,6 +197,7 @@ interface MarkdownEditorProps { onImageUpload?: (file: File) => Promise editorSessionKey?: number onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void + editable?: boolean } type WikiLinkMatch = { @@ -282,6 +283,7 @@ export function MarkdownEditor({ onImageUpload, editorSessionKey = 0, onHistoryHandlersChange, + editable = true, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -303,6 +305,7 @@ export function MarkdownEditor({ ) const editor = useEditor({ + editable, extensions: [ StarterKit.configure({ heading: { @@ -517,6 +520,13 @@ export function MarkdownEditor({ } }, [editor, onHistoryHandlersChange]) + // Update editable state when prop changes + useEffect(() => { + if (editor) { + editor.setEditable(editable) + } + }, [editor, editable]) + // Force re-render decorations when selection highlight changes useEffect(() => { if (editor) { diff --git a/apps/x/apps/renderer/src/components/version-history-panel.tsx b/apps/x/apps/renderer/src/components/version-history-panel.tsx new file mode 100644 index 00000000..f8d03d38 --- /dev/null +++ b/apps/x/apps/renderer/src/components/version-history-panel.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState, useCallback } from 'react' +import { X, Clock } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' + +interface CommitInfo { + oid: string + message: string + timestamp: number + author: string +} + +interface VersionHistoryPanelProps { + path: string // knowledge-relative file path (e.g. "knowledge/People/John.md") + onClose: () => void + onSelectVersion: (oid: string | null, content: string) => void // null = current + onRestore: (oid: string) => void +} + +function formatTimestamp(unixSeconds: number): { date: string; time: string } { + const d = new Date(unixSeconds * 1000) + const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' }) + const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) + return { date, time } +} + +export function VersionHistoryPanel({ + path, + onClose, + onSelectVersion, + onRestore, +}: VersionHistoryPanelProps) { + const [commits, setCommits] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedOid, setSelectedOid] = useState(null) // null = current/latest + const [error, setError] = useState(null) + + // Strip "knowledge/" prefix for IPC calls + const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path + + const loadHistory = useCallback(async () => { + setLoading(true) + setError(null) + try { + const result = await window.ipc.invoke('knowledge:history', { path: relPath }) + setCommits(result.commits) + } catch (err) { + console.error('Failed to load version history:', err) + setError('Failed to load history') + } finally { + setLoading(false) + } + }, [relPath]) + + useEffect(() => { + loadHistory() + }, [loadHistory]) + + // Refresh when new commits land + useEffect(() => { + return window.ipc.on('knowledge:didCommit', () => { + loadHistory() + }) + }, [loadHistory]) + + const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => { + if (isLatest) { + setSelectedOid(null) + // Read current file content + try { + const result = await window.ipc.invoke('workspace:readFile', { path }) + onSelectVersion(null, result.data) + } catch (err) { + console.error('Failed to read current file:', err) + } + return + } + + setSelectedOid(oid) + try { + const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid }) + onSelectVersion(oid, result.content) + } catch (err) { + console.error('Failed to load file at commit:', err) + } + }, [path, relPath, onSelectVersion]) + + const handleRestore = useCallback(() => { + if (selectedOid) { + onRestore(selectedOid) + } + }, [selectedOid, onRestore]) + + return ( +
+ {/* Header */} +
+ Version history + +
+ + {/* Commit list */} +
+ {loading ? ( +
+ Loading... +
+ ) : error ? ( +
+ {error} +
+ ) : commits.length === 0 ? ( +
+ No history available +
+ ) : ( +
+ {commits.map((commit, index) => { + const isLatest = index === 0 + const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid + const { date, time } = formatTimestamp(commit.timestamp) + + return ( + + ) + })} +
+ )} +
+ + {/* Footer */} + {selectedOid && ( +
+ +
+ )} +
+ ) +} diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index bcfac93d..53495637 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -27,6 +27,7 @@ "cron-parser": "^5.5.0", "glob": "^13.0.0", "google-auth-library": "^10.5.0", + "isomorphic-git": "^1.29.0", "googleapis": "^169.0.0", "mammoth": "^1.11.0", "node-html-markdown": "^2.0.0", diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index caefad82..4a91e101 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -91,4 +91,9 @@ function ensureWelcomeFile() { ensureDirs(); ensureDefaultConfigs(); -ensureWelcomeFile(); \ No newline at end of file +ensureWelcomeFile(); + +// Initialize version history repo (async, fire-and-forget on startup) +import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => { + console.error('[VersionHistory] Failed to init repo:', err); +}); diff --git a/apps/x/packages/core/src/index.ts b/apps/x/packages/core/src/index.ts index b7718cba..0eab08e3 100644 --- a/apps/x/packages/core/src/index.ts +++ b/apps/x/packages/core/src/index.ts @@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js'; export * as watcher from './workspace/watcher.js'; // Config initialization -export { initConfigs } from './config/initConfigs.js'; \ No newline at end of file +export { initConfigs } from './config/initConfigs.js'; + +// Knowledge version history +export * as versionHistory from './knowledge/version_history.js'; diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index a1b7e135..a119dfa6 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -15,6 +15,7 @@ import { } from './graph_state.js'; import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js'; import { limitEventItems } from './limit_event_items.js'; +import { commitAll } from './version_history.js'; /** * Build obsidian-style knowledge graph by running topic extraction @@ -320,6 +321,13 @@ async function buildGraphWithFiles( // Save state after each successful batch // This ensures partial progress is saved even if later batches fail saveState(state); + + // Commit knowledge changes to version history + try { + await commitAll('Knowledge update', 'Rowboat'); + } catch (err) { + console.error(`[GraphBuilder] Failed to commit version history:`, err); + } } catch (error) { hadError = true; console.error(`Error processing batch ${batchNumber}:`, error); @@ -467,6 +475,13 @@ async function processVoiceMemosForKnowledge(): Promise { // Save state after each batch saveState(state); + + // Commit knowledge changes to version history + try { + await commitAll('Knowledge update', 'Rowboat'); + } catch (err) { + console.error(`[GraphBuilder] Failed to commit version history:`, err); + } } catch (error) { hadError = true; console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error); diff --git a/apps/x/packages/core/src/knowledge/version_history.ts b/apps/x/packages/core/src/knowledge/version_history.ts new file mode 100644 index 00000000..a6504f67 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/version_history.ts @@ -0,0 +1,243 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import git from 'isomorphic-git'; +import { WorkDir } from '../config/config.js'; + +const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); + +// Simple promise-based mutex to serialize commits +let commitLock: Promise = Promise.resolve(); + +// Commit listeners for notifying other layers (e.g. renderer refresh) +type CommitListener = () => void; +const commitListeners: CommitListener[] = []; + +export function onCommit(listener: CommitListener): () => void { + commitListeners.push(listener); + return () => { + const idx = commitListeners.indexOf(listener); + if (idx >= 0) commitListeners.splice(idx, 1); + }; +} + +/** + * Initialize a git repo in the knowledge directory if one doesn't exist. + * Stages all existing .md files and makes an initial commit. + */ +export async function initRepo(): Promise { + const gitDir = path.join(KNOWLEDGE_DIR, '.git'); + if (fs.existsSync(gitDir)) { + return; + } + + // Ensure knowledge dir exists + if (!fs.existsSync(KNOWLEDGE_DIR)) { + fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true }); + } + + await git.init({ fs, dir: KNOWLEDGE_DIR }); + + // Stage all existing .md files + const files = getAllMdFiles(KNOWLEDGE_DIR, ''); + for (const file of files) { + await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file }); + } + + if (files.length > 0) { + await git.commit({ + fs, + dir: KNOWLEDGE_DIR, + message: 'Initial snapshot', + author: { name: 'Rowboat', email: 'local' }, + }); + } +} + +/** + * Recursively find all .md files relative to the knowledge dir. + */ +function getAllMdFiles(baseDir: string, relDir: string): string[] { + const results: string[] = []; + const absDir = relDir ? path.join(baseDir, relDir) : baseDir; + let entries: string[]; + try { + entries = fs.readdirSync(absDir); + } catch { + return results; + } + for (const entry of entries) { + if (entry === '.git' || entry.startsWith('.')) continue; + const fullPath = path.join(absDir, entry); + const relPath = relDir ? `${relDir}/${entry}` : entry; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + results.push(...getAllMdFiles(baseDir, relPath)); + } else if (entry.endsWith('.md')) { + results.push(relPath); + } + } + return results; +} + +/** + * Stage all changes to .md files and commit. No-op if nothing changed. + * Serialized via a promise lock to prevent concurrent git index corruption. + */ +export async function commitAll(message: string, authorName: string): Promise { + const prev = commitLock; + let resolve: () => void; + commitLock = new Promise(r => { resolve = r; }); + + await prev; + try { + await commitAllInner(message, authorName); + } finally { + resolve!(); + } +} + +async function commitAllInner(message: string, authorName: string): Promise { + const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR }); + + let hasChanges = false; + for (const [filepath, head, workdir, stage] of matrix) { + // Skip non-md files + if (!filepath.endsWith('.md')) continue; + + // [filepath, HEAD, WORKDIR, STAGE] + // Unchanged: [f, 1, 1, 1] + if (head === 1 && workdir === 1 && stage === 1) continue; + + hasChanges = true; + + if (workdir === 0) { + // File deleted from workdir + await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath }); + } else { + // File added or modified + await git.add({ fs, dir: KNOWLEDGE_DIR, filepath }); + } + } + + if (!hasChanges) return; + + await git.commit({ + fs, + dir: KNOWLEDGE_DIR, + message, + author: { name: authorName, email: 'local' }, + }); + + for (const listener of commitListeners) { + try { listener(); } catch { /* ignore */ } + } +} + +export interface CommitInfo { + oid: string; + message: string; + timestamp: number; + author: string; +} + +const MAX_FILE_HISTORY = 50; + +/** + * Get commit history for a specific file. + * Returns commits where the file content changed, most recent first. + * Capped at MAX_FILE_HISTORY entries. + */ +export async function getFileHistory(knowledgeRelPath: string): Promise { + // Normalize path separators for git (always forward slashes) + const filepath = knowledgeRelPath.replace(/\\/g, '/'); + + let commits: Awaited>; + try { + commits = await git.log({ fs, dir: KNOWLEDGE_DIR }); + } catch { + return []; + } + + if (commits.length === 0) return []; + + const result: CommitInfo[] = []; + + // Walk through commits and check if file changed between consecutive commits + for (let i = 0; i < commits.length; i++) { + if (result.length >= MAX_FILE_HISTORY) break; + + const commit = commits[i]!; + const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit + + const currentOid = await getBlobOidAtCommit(commit.oid, filepath); + const parentOid = parentCommit + ? await getBlobOidAtCommit(parentCommit.oid, filepath) + : null; + + // Include this commit if: + // - The file existed and changed from parent + // - The file was added (parentOid is null but currentOid exists) + // - The file was deleted (currentOid is null but parentOid exists) + if (currentOid !== parentOid) { + result.push({ + oid: commit.oid, + message: commit.commit.message.trim(), + timestamp: commit.commit.author.timestamp, + author: commit.commit.author.name, + }); + } + } + + return result; +} + +/** + * Get the blob OID for a file at a specific commit, or null if not found. + */ +async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise { + try { + const result = await git.readBlob({ + fs, + dir: KNOWLEDGE_DIR, + oid: commitOid, + filepath, + }); + // Compute a content hash from the blob to compare + return result.oid; + } catch { + return null; + } +} + +/** + * Read file content at a specific commit. + */ +export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise { + const filepath = knowledgeRelPath.replace(/\\/g, '/'); + const result = await git.readBlob({ + fs, + dir: KNOWLEDGE_DIR, + oid, + filepath, + }); + return Buffer.from(result.blob).toString('utf-8'); +} + +/** + * Restore a file to its content at a given commit, then commit the restoration. + */ +export async function restoreFile(knowledgeRelPath: string, oid: string): Promise { + const content = await getFileAtCommit(knowledgeRelPath, oid); + const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath); + + // Ensure parent directory exists + const dir = path.dirname(absPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(absPath, content, 'utf-8'); + + const filename = path.basename(knowledgeRelPath); + await commitAll(`Restored ${filename}`, 'You'); +} diff --git a/apps/x/packages/core/src/workspace/workspace.ts b/apps/x/packages/core/src/workspace/workspace.ts index cd872f39..de1fe212 100644 --- a/apps/x/packages/core/src/workspace/workspace.ts +++ b/apps/x/packages/core/src/workspace/workspace.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js'; import { WorkDir } from '../config/config.js'; import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js'; +import { commitAll } from '../knowledge/version_history.js'; // ============================================================================ // Path Utilities @@ -218,6 +219,21 @@ export async function readFile( }; } +// Debounced commit for knowledge file edits +let knowledgeCommitTimer: ReturnType | null = null; + +function scheduleKnowledgeCommit(filename: string): void { + if (knowledgeCommitTimer) { + clearTimeout(knowledgeCommitTimer); + } + knowledgeCommitTimer = setTimeout(() => { + knowledgeCommitTimer = null; + commitAll(`Edit ${filename}`, 'You').catch(err => { + console.error('[VersionHistory] Failed to commit after edit:', err); + }); + }, 3 * 60 * 1000); +} + export async function writeFile( relPath: string, data: string, @@ -266,6 +282,11 @@ export async function writeFile( const stat = statToSchema(stats, 'file'); const etag = computeEtag(stats.size, stats.mtimeMs); + // Schedule a debounced version history commit for knowledge files + if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) { + scheduleKnowledgeCommit(path.basename(relPath)); + } + return { path: relPath, stat, diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c0276d9a..b5803ffc 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -397,6 +397,30 @@ const ipcSchemas = { req: z.object({ path: z.string() }), res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), }, + // Knowledge version history channels + 'knowledge:history': { + req: z.object({ path: RelPath }), + res: z.object({ + commits: z.array(z.object({ + oid: z.string(), + message: z.string(), + timestamp: z.number(), + author: z.string(), + })), + }), + }, + 'knowledge:fileAtCommit': { + req: z.object({ path: RelPath, oid: z.string() }), + res: z.object({ content: z.string() }), + }, + 'knowledge:restore': { + req: z.object({ path: RelPath, oid: z.string() }), + res: z.object({ ok: z.literal(true) }), + }, + 'knowledge:didCommit': { + req: z.object({}), + res: z.null(), + }, // Search channels 'search:query': { req: z.object({ diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index fa8765d6..d3525c4f 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -359,6 +359,9 @@ importers: googleapis: specifier: ^169.0.0 version: 169.0.0 + isomorphic-git: + specifier: ^1.29.0 + version: 1.37.2 mammoth: specifier: ^1.11.0 version: 1.11.0 @@ -3501,6 +3504,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abs-svg-path@0.1.1: resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} @@ -3627,6 +3634,9 @@ packages: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async@1.5.2: resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} @@ -3641,6 +3651,10 @@ packages: resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} engines: {node: '>=0.8'} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + awilix@12.0.5: resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==} engines: {node: '>=16.3.0'} @@ -3742,6 +3756,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3762,6 +3779,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -3825,6 +3846,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-git-ref@2.0.1: + resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -4256,6 +4280,9 @@ packages: dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff3@0.0.3: + resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} @@ -4496,6 +4523,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -4640,6 +4671,10 @@ packages: fontkit@2.0.4: resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -5104,6 +5139,10 @@ packages: is-arrayish@0.3.4: resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -5170,6 +5209,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -5184,6 +5227,9 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -5191,6 +5237,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-git@1.37.2: + resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==} + engines: {node: '>=14.17'} + hasBin: true + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -5762,6 +5813,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minimisted@2.0.1: + resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} + minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} engines: {node: '>= 8'} @@ -6169,6 +6223,10 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -6186,6 +6244,10 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -6220,6 +6282,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -6434,6 +6500,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -6649,12 +6719,21 @@ packages: server-destroy@1.0.1: resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -6701,6 +6780,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -6928,6 +7013,10 @@ packages: resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==} engines: {node: '>=0.12'} + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + to-data-view@1.1.0: resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==} @@ -6998,6 +7087,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + typescript-eslint@8.50.1: resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -7272,6 +7365,10 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -11316,6 +11413,10 @@ snapshots: abbrev@1.1.1: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + abs-svg-path@0.1.1: {} accepts@2.0.0: @@ -11440,6 +11541,8 @@ snapshots: arrify@2.0.1: {} + async-lock@1.4.1: {} + async@1.5.2: optional: true @@ -11449,6 +11552,10 @@ snapshots: author-regex@1.0.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + awilix@12.0.5: dependencies: camel-case: 4.1.2 @@ -11568,6 +11675,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bytes@3.1.2: {} cacache@16.1.3: @@ -11610,6 +11722,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -11672,6 +11791,8 @@ snapshots: dependencies: clsx: 2.1.1 + clean-git-ref@2.0.1: {} + clean-stack@2.2.0: {} cli-cursor@3.1.0: @@ -12061,7 +12182,6 @@ snapshots: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 - optional: true define-properties@1.2.1: dependencies: @@ -12095,6 +12215,8 @@ snapshots: dfa@1.2.0: {} + diff3@0.0.3: {} + dingbat-to-unicode@1.0.1: {} dir-compare@4.2.0: @@ -12451,6 +12573,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -12638,6 +12762,10 @@ snapshots: unicode-properties: 1.4.1 unicode-trie: 2.0.0 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -12983,7 +13111,6 @@ snapshots: has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 - optional: true has-symbols@1.1.0: {} @@ -13251,6 +13378,8 @@ snapshots: is-arrayish@0.3.4: {} + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -13300,6 +13429,10 @@ snapshots: is-stream@2.0.1: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + is-unicode-supported@0.1.0: {} is-url@1.2.4: {} @@ -13310,10 +13443,26 @@ snapshots: isarray@1.0.0: {} + isarray@2.0.5: {} + isbinaryfile@4.0.10: {} isexe@2.0.0: {} + isomorphic-git@1.37.2: + dependencies: + async-lock: 1.4.1 + clean-git-ref: 2.0.1 + crc-32: 1.2.2 + diff3: 0.0.3 + ignore: 5.3.2 + minimisted: 2.0.1 + pako: 1.0.11 + pify: 4.0.1 + readable-stream: 4.7.0 + sha.js: 2.4.12 + simple-get: 4.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -14139,6 +14288,10 @@ snapshots: minimist@1.2.8: {} + minimisted@2.0.1: + dependencies: + minimist: 1.2.8 + minipass-collect@1.0.2: dependencies: minipass: 3.3.6 @@ -14506,6 +14659,8 @@ snapshots: pify@2.3.0: {} + pify@4.0.1: {} + pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -14527,6 +14682,8 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} postcss@8.5.6: @@ -14565,6 +14722,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + progress@2.0.3: {} promise-inflight@1.0.1: {} @@ -14887,6 +15046,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@4.1.2: {} rechoir@0.8.0: @@ -15175,10 +15342,25 @@ snapshots: server-destroy@1.0.1: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} setprototypeof@1.2.0: {} + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -15236,6 +15418,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -15493,6 +15683,12 @@ snapshots: unorm: 1.6.0 optional: true + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-data-view@1.1.0: optional: true @@ -15550,6 +15746,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -15827,6 +16029,16 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@1.3.1: dependencies: isexe: 2.0.0 From 5a72ee06e1ff070d5b24e4a24390a97226be2ae0 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:42:46 +0530 Subject: [PATCH 06/13] Model switch (#413) Add ability to switch models in chat --- .../components/chat-input-with-mentions.tsx | 190 ++++++++-- .../src/components/settings-dialog.tsx | 324 ++++++++++++++---- apps/x/packages/core/src/models/repo.ts | 22 +- apps/x/packages/shared/src/models.ts | 1 + 4 files changed, 444 insertions(+), 93 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index d3554c00..42ea45bb 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { ArrowUp, AudioLines, + ChevronDown, FileArchive, FileCode2, FileIcon, @@ -15,6 +16,13 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { type AttachmentIconKind, getAttachmentDisplayName, @@ -45,6 +53,25 @@ export type StagedAttachment = { const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB +const providerDisplayNames: Record = { + openai: 'OpenAI', + anthropic: 'Anthropic', + google: 'Gemini', + ollama: 'Ollama', + openrouter: 'OpenRouter', + aigateway: 'AI Gateway', + 'openai-compatible': 'OpenAI-Compatible', +} + +interface ConfiguredModel { + flavor: string + model: string + apiKey?: string + baseURL?: string + headers?: Record + knowledgeGraphModel?: string +} + function getAttachmentIcon(kind: AttachmentIconKind) { switch (kind) { case 'audio': @@ -96,6 +123,90 @@ function ChatInputInner({ const fileInputRef = useRef(null) const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing + const [configuredModels, setConfiguredModels] = useState([]) + const [activeModelKey, setActiveModelKey] = useState('') + + // Load model config from disk (on mount and whenever tab becomes active) + const loadModelConfig = useCallback(async () => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + const models: ConfiguredModel[] = [] + if (parsed?.providers) { + for (const [flavor, entry] of Object.entries(parsed.providers)) { + const e = entry as Record + const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] + const singleModel = typeof e.model === 'string' ? e.model : '' + const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] + for (const model of allModels) { + if (model) { + models.push({ + flavor, + model, + apiKey: (e.apiKey as string) || undefined, + baseURL: (e.baseURL as string) || undefined, + headers: (e.headers as Record) || undefined, + knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, + }) + } + } + } + } + const defaultKey = parsed?.provider?.flavor && parsed?.model + ? `${parsed.provider.flavor}/${parsed.model}` + : '' + models.sort((a, b) => { + const aKey = `${a.flavor}/${a.model}` + const bKey = `${b.flavor}/${b.model}` + if (aKey === defaultKey) return -1 + if (bKey === defaultKey) return 1 + return 0 + }) + setConfiguredModels(models) + if (defaultKey) { + setActiveModelKey(defaultKey) + } + } catch { + // No config yet + } + }, []) + + useEffect(() => { + loadModelConfig() + }, [isActive, loadModelConfig]) + + // Reload when model config changes (e.g. from settings dialog) + useEffect(() => { + const handler = () => { loadModelConfig() } + window.addEventListener('models-config-changed', handler) + return () => window.removeEventListener('models-config-changed', handler) + }, [loadModelConfig]) + + const handleModelChange = useCallback(async (key: string) => { + const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) + if (!entry) return + setActiveModelKey(key) + // Collect all models for this provider so the full list is preserved + const providerModels = configuredModels + .filter((m) => m.flavor === entry.flavor) + .map((m) => m.model) + try { + await window.ipc.invoke('models:saveConfig', { + provider: { + flavor: entry.flavor, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }, + model: entry.model, + models: providerModels, + knowledgeGraphModel: entry.knowledgeGraphModel, + }) + } catch { + toast.error('Failed to switch model') + } + }, [configuredModels]) + // Restore the tab draft when this input mounts. useEffect(() => { if (initialDraft) { @@ -239,24 +350,33 @@ function ChatInputInner({ })}
)} -
- { - const files = e.target.files - if (!files || files.length === 0) return - const paths = Array.from(files) - .map((file) => window.electronUtils?.getPathForFile(file)) - .filter(Boolean) as string[] - if (paths.length > 0) { - void addFiles(paths) - } - e.target.value = '' - }} + { + const files = e.target.files + if (!files || files.length === 0) return + const paths = Array.from(files) + .map((file) => window.electronUtils?.getPathForFile(file)) + .filter(Boolean) as string[] + if (paths.length > 0) { + void addFiles(paths) + } + e.target.value = '' + }} + /> +
+ +
+
- +
+ {configuredModels.length > 0 && ( + + + + + + + {configuredModels.map((m) => { + const key = `${m.flavor}/${m.model}` + return ( + + {m.model} + {providerDisplayNames[m.flavor] || m.flavor} + + ) + })} + + + + )} {isProcessing ? ( - ) + const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { + const config = providerConfigs[prov] + const allModels = config.models.map(m => m.trim()).filter(Boolean) + if (!allModels[0]) return + try { + await window.ipc.invoke("models:saveConfig", { + provider: { + flavor: prov, + apiKey: config.apiKey.trim() || undefined, + baseURL: config.baseURL.trim() || undefined, + }, + model: allModels[0], + models: allModels, + knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, + }) + setDefaultProvider(prov) + window.dispatchEvent(new Event('models-config-changed')) + toast.success("Default provider updated") + } catch { + toast.error("Failed to set default provider") + } + }, [providerConfigs]) + + const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => { + try { + const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) + const parsed = JSON.parse(result.data) + if (parsed?.providers?.[prov]) { + delete parsed.providers[prov] + } + // If the deleted provider is the current top-level active one, + // switch top-level config to the current default provider + if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) { + const defConfig = providerConfigs[defaultProvider] + const defModels = defConfig.models.map(m => m.trim()).filter(Boolean) + parsed.provider = { + flavor: defaultProvider, + apiKey: defConfig.apiKey.trim() || undefined, + baseURL: defConfig.baseURL.trim() || undefined, + } + parsed.model = defModels[0] || "" + parsed.models = defModels + parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined + } + await window.ipc.invoke("workspace:writeFile", { + path: "config/models.json", + data: JSON.stringify(parsed, null, 2), + }) + setProviderConfigs(prev => ({ + ...prev, + [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" }, + })) + setTestState({ status: "idle" }) + window.dispatchEvent(new Event('models-config-changed')) + toast.success("Provider configuration removed") + } catch { + toast.error("Failed to remove provider") + } + }, [defaultProvider, providerConfigs]) + + const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => { + const isDefault = defaultProvider === p.id + const isSelected = provider === p.id + const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0 + return ( + + ) + } if (configLoading) { return ( @@ -366,6 +529,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { {/* Model selection - side by side */}
+ {/* Assistant models (left column) */}
Assistant model {modelsLoading ? ( @@ -373,34 +537,58 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { Loading...
- ) : showModelInput ? ( - updateConfig(provider, { model: e.target.value })} - placeholder="Enter model" - /> ) : ( - +
+ {activeConfig.models.map((model, index) => ( +
+ {showModelInput ? ( + updateModelAt(provider, index, e.target.value)} + placeholder="Enter model" + /> + ) : ( + + )} + {activeConfig.models.length > 1 && ( + + )} +
+ ))} + +
)} {modelsError && (
{modelsError}
)}
+ {/* Knowledge graph model (right column) */}
Knowledge graph model {modelsLoading ? ( @@ -412,7 +600,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { updateConfig(provider, { knowledgeGraphModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} + placeholder={primaryModel || "Enter model"} /> ) : ( updateTag(globalIdx, "tag", e.target.value)} + className="h-7 text-xs" + placeholder="tag-name" + title={tag.tag} + /> + updateTag(globalIdx, "description", e.target.value)} + className="h-7 text-xs" + placeholder="Description" + title={tag.description} + /> + updateTag(globalIdx, "example", e.target.value)} + className="h-7 text-xs" + placeholder="Example" + title={tag.example || ""} + /> + + +
+ ) + })} +
+ )} + {!collapsedGroups.has(group.type) && group.tags.length === 0 && ( +
No tags in this group
+ )} +
+ ))} +
+
+
+ {hasChanges && ( + Unsaved changes + )} +
+
+ + +
+
+
+ ) +} + // --- Main Settings Dialog --- export function SettingsDialog({ children }: SettingsDialogProps) { @@ -708,7 +1020,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) { } const loadConfig = useCallback(async (tab: ConfigTab) => { - if (tab === "appearance" || tab === "models") return + if (tab === "appearance" || tab === "models" || tab === "note-tagging") return const tabConfig = tabs.find((t) => t.id === tab)! if (!tabConfig.path) return setLoading(true) @@ -814,9 +1126,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) { {/* Content */} -
+
{activeTab === "models" ? ( + ) : activeTab === "note-tagging" ? ( + ) : activeTab === "appearance" ? ( ) : loading ? ( diff --git a/apps/x/apps/renderer/src/components/tag-pills.tsx b/apps/x/apps/renderer/src/components/tag-pills.tsx new file mode 100644 index 00000000..eead6558 --- /dev/null +++ b/apps/x/apps/renderer/src/components/tag-pills.tsx @@ -0,0 +1,17 @@ +interface TagPillsProps { + tags: string[] +} + +export function TagPills({ tags }: TagPillsProps) { + if (tags.length === 0) return null + + return ( +
+ {tags.map((tag, i) => ( + + {tag} + + ))} +
+ ) +} diff --git a/apps/x/apps/renderer/src/lib/frontmatter.ts b/apps/x/apps/renderer/src/lib/frontmatter.ts new file mode 100644 index 00000000..a9b6b2ff --- /dev/null +++ b/apps/x/apps/renderer/src/lib/frontmatter.ts @@ -0,0 +1,164 @@ +/** + * Utilities for splitting, joining, and extracting tags from YAML frontmatter + * in knowledge notes and email files. + */ + +/** Split content into raw frontmatter block and body text. */ +export function splitFrontmatter(content: string): { raw: string | null; body: string } { + if (!content.startsWith('---')) { + return { raw: null, body: content } + } + const endIndex = content.indexOf('\n---', 3) + if (endIndex === -1) { + return { raw: null, body: content } + } + // raw includes both delimiters and the trailing newline after closing --- + const closingEnd = endIndex + 4 // '\n---' is 4 chars + const raw = content.slice(0, closingEnd) + // body starts after the closing --- and its trailing newline + let body = content.slice(closingEnd) + if (body.startsWith('\n')) { + body = body.slice(1) + } + return { raw, body } +} + +/** Re-prepend raw frontmatter before body when saving. */ +export function joinFrontmatter(raw: string | null, body: string): string { + if (!raw) return body + return raw + '\n' + body +} + +/** Tag category keys used in the categorized frontmatter format. */ +const TAG_CATEGORY_KEYS = new Set([ + 'relationship', + 'relationship_sub', + 'topic', + 'email_type', + 'action', + 'status', + 'source', +]) + +/** Keys that are metadata, not tags — skip when collecting tags. */ +const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at']) + +/** + * Extract tags from raw frontmatter YAML. + * + * Handles three formats: + * - Legacy flat list: `tags:` followed by ` - value` items + * - Categorized format: top-level keys like `relationship: customer` or + * `topic:` followed by ` - value` list items + * - Email format: `labels:` with nested keys (relationship, topics, type, filter, action) + * where values can be single strings or ` - value` arrays + * + * Skips metadata keys like `processed`, `labeled_at`, `tagged_at`. + */ +export function extractTags(raw: string | null): string[] { + if (!raw) return [] + + const lines = raw.split('\n') + const tags: string[] = [] + + let inTags = false + let inLabels = false + let inLabelSubKey = false + let inCategoryList = false + + for (const line of lines) { + // Top-level key detection — resets all nested state + if (/^\w/.test(line) || line === '---') { + inTags = false + inLabels = false + inLabelSubKey = false + inCategoryList = false + } + + // Legacy note format: tags: + if (/^tags:\s*$/.test(line)) { + inTags = true + inLabels = false + inCategoryList = false + continue + } + + // Email format: labels: + if (/^labels:\s*$/.test(line)) { + inLabels = true + inTags = false + inCategoryList = false + continue + } + + // Categorized format: top-level tag category key + const topKeyMatch = line.match(/^(\w+):\s*(.*)$/) + if (topKeyMatch) { + const key = topKeyMatch[1] + const inlineValue = topKeyMatch[2].trim() + + if (TAG_CATEGORY_KEYS.has(key)) { + if (inlineValue) { + // Single value: `relationship: customer` + tags.push(inlineValue) + inCategoryList = false + } else { + // List follows: `topic:\n - sales` + inCategoryList = true + } + continue + } + } + + // Collect tag items under `tags:` + if (inTags) { + const match = line.match(/^\s+-\s+(.+)$/) + if (match) { + tags.push(match[1].trim()) + } + continue + } + + // Collect list items under a category key + if (inCategoryList) { + const match = line.match(/^\s+-\s+(.+)$/) + if (match) { + tags.push(match[1].trim()) + } + continue + } + + // Handle labels: nested structure + if (inLabels) { + // Sub-key like ` relationship:` or ` topics:` + const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/) + if (subKeyMatch) { + const key = subKeyMatch[1] + const inlineValue = subKeyMatch[2].trim() + if (METADATA_KEYS.has(key)) { + inLabelSubKey = false + continue + } + if (inlineValue) { + // Inline value like ` type: person` + tags.push(inlineValue) + inLabelSubKey = false + } else { + // Array follows + inLabelSubKey = true + } + continue + } + + // Array item under a sub-key like ` - value` + if (inLabelSubKey) { + const itemMatch = line.match(/^\s{4}-\s+(.+)$/) + if (itemMatch) { + tags.push(itemMatch[1].trim()) + } + } + } + } + + return tags +} diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 31ce2bf1..6e1c0deb 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -237,6 +237,34 @@ flex-shrink: 0; } +/* Tag pills row shown between toolbar and editor content */ +.tag-pills-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 4px 12px; + border-bottom: 1px solid var(--border); + background-color: var(--background); + flex-shrink: 0; + max-height: 4.5em; + overflow: hidden; +} + +.tag-pill { + font-size: 11px; + line-height: 18px; + padding: 0 8px; + border-radius: 9999px; + background-color: color-mix(in srgb, var(--foreground) 8%, transparent); + color: var(--foreground); + white-space: nowrap; + user-select: none; +} + +.dark .tag-pill { + background-color: color-mix(in srgb, var(--foreground) 12%, transparent); +} + .editor-toolbar .separator { width: 1px; height: 1.5rem; diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 0aeb167f..c84634ac 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -2,7 +2,6 @@ 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"; @@ -25,9 +24,9 @@ import { IRunsLock } from "../runs/lock.js"; import { IAbortRegistry } from "../runs/abort-registry.js"; import { PrefixLogger } from "@x/shared"; import { parse } from "yaml"; -import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js"; -import { raw as noteCreationLowRaw } from "../knowledge/note_creation_low.js"; -import { raw as noteCreationHighRaw } from "../knowledge/note_creation_high.js"; +import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js"; +import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; +import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; export interface IAgentRuntime { trigger(runId: string): Promise; @@ -316,19 +315,7 @@ export async function loadAgent(id: string): Promise> { } if (id === 'note_creation') { - const strictness = getNoteCreationStrictness(); - let raw = ''; - switch (strictness) { - case 'medium': - raw = noteCreationMediumRaw; - break; - case 'low': - raw = noteCreationLowRaw; - break; - case 'high': - raw = noteCreationHighRaw; - break; - } + const raw = getNoteCreationRaw(); let agent: z.infer = { name: id, instructions: raw, @@ -353,6 +340,56 @@ export async function loadAgent(id: string): Promise> { return agent; } + if (id === 'labeling_agent') { + const labelingAgentRaw = getLabelingAgentRaw(); + let agent: z.infer = { + name: id, + instructions: labelingAgentRaw, + }; + + if (labelingAgentRaw.startsWith("---")) { + const end = labelingAgentRaw.indexOf("\n---", 3); + if (end !== -1) { + const fm = labelingAgentRaw.slice(3, end).trim(); + const content = labelingAgentRaw.slice(end + 4).trim(); + const yaml = parse(fm); + const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); + agent = { + ...agent, + ...parsed, + instructions: content, + }; + } + } + + return agent; + } + + if (id === 'note_tagging_agent') { + const noteTaggingAgentRaw = getNoteTaggingAgentRaw(); + let agent: z.infer = { + name: id, + instructions: noteTaggingAgentRaw, + }; + + if (noteTaggingAgentRaw.startsWith("---")) { + const end = noteTaggingAgentRaw.indexOf("\n---", 3); + if (end !== -1) { + const fm = noteTaggingAgentRaw.slice(3, end).trim(); + const content = noteTaggingAgentRaw.slice(end + 4).trim(); + const yaml = parse(fm); + const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); + agent = { + ...agent, + ...parsed, + instructions: content, + }; + } + } + + return agent; + } + const repo = container.resolve('agentsRepo'); return await repo.fetch(id); } @@ -706,7 +743,7 @@ export async function* streamAgent({ // set up provider + model const provider = createProvider(modelConfig.provider); - const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"]; + const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"]; const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel) ? modelConfig.knowledgeGraphModel : modelConfig.model; diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index 4a91e101..453fef59 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -23,7 +23,7 @@ function ensureDefaultConfigs() { const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json"); if (!fs.existsSync(noteCreationConfig)) { fs.writeFileSync(noteCreationConfig, JSON.stringify({ - strictness: "high", + strictness: "medium", configured: false }, null, 2)); } diff --git a/apps/x/packages/core/src/config/note_creation_config.ts b/apps/x/packages/core/src/config/note_creation_config.ts index a86e8c00..4aad826f 100644 --- a/apps/x/packages/core/src/config/note_creation_config.ts +++ b/apps/x/packages/core/src/config/note_creation_config.ts @@ -11,7 +11,7 @@ interface NoteCreationConfig { } const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json'); -const DEFAULT_STRICTNESS: NoteCreationStrictness = 'high'; +const DEFAULT_STRICTNESS: NoteCreationStrictness = 'medium'; /** * Read the full config file. diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index a119dfa6..c4174c3f 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -1,7 +1,6 @@ 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 { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; @@ -363,7 +362,19 @@ export async function buildGraph(sourceDir: string): Promise { console.log(`[buildGraph] State loaded. Previously processed: ${previouslyProcessedCount} files`); // Get files that need processing (new or changed) - const filesToProcess = getFilesToProcess(sourceDir, state); + let filesToProcess = getFilesToProcess(sourceDir, state); + + // For gmail_sync, only process emails that have been labeled (have YAML frontmatter) + if (sourceDir.endsWith('gmail_sync')) { + filesToProcess = filesToProcess.filter(filePath => { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return content.startsWith('---'); + } catch { + return false; + } + }); + } if (filesToProcess.length === 0) { console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`); @@ -525,8 +536,6 @@ async function processVoiceMemosForKnowledge(): Promise { async function processAllSources(): Promise { console.log('[GraphBuilder] Checking for new content in all sources...'); - // Auto-configure strictness on first run if not already done - autoConfigureStrictnessIfNeeded(); let anyFilesProcessed = false; @@ -555,7 +564,19 @@ async function processAllSources(): Promise { } try { - const filesToProcess = getFilesToProcess(sourceDir, state); + let filesToProcess = getFilesToProcess(sourceDir, state); + + // For gmail_sync, only process emails that have been labeled (have YAML frontmatter) + if (folder === 'gmail_sync') { + filesToProcess = filesToProcess.filter(filePath => { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return content.startsWith('---'); + } catch { + return false; + } + }); + } if (filesToProcess.length > 0) { console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`); diff --git a/apps/x/packages/core/src/knowledge/label_emails.ts b/apps/x/packages/core/src/knowledge/label_emails.ts new file mode 100644 index 00000000..a62f674a --- /dev/null +++ b/apps/x/packages/core/src/knowledge/label_emails.ts @@ -0,0 +1,269 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import { createRun, createMessage } from '../runs/runs.js'; +import { bus } from '../runs/bus.js'; +import { serviceLogger } from '../services/service_logger.js'; +import { limitEventItems } from './limit_event_items.js'; +import { + loadLabelingState, + saveLabelingState, + markFileAsLabeled, + type LabelingState, +} from './labeling_state.js'; + +const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes +const BATCH_SIZE = 15; +const LABELING_AGENT = 'labeling_agent'; +const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync'); +const MAX_CONTENT_LENGTH = 8000; + +/** + * Find email files that haven't been labeled yet + */ +function getUnlabeledEmails(state: LabelingState): string[] { + if (!fs.existsSync(GMAIL_SYNC_DIR)) { + return []; + } + + const unlabeled: string[] = []; + + function traverse(dir: 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()) { + traverse(fullPath); + } else if (stat.isFile() && entry.endsWith('.md')) { + // Skip if already tracked in state + if (state.processedFiles[fullPath]) { + continue; + } + + // Skip if file already has frontmatter + try { + const content = fs.readFileSync(fullPath, 'utf-8'); + if (content.startsWith('---')) { + continue; + } + } catch { + continue; + } + + unlabeled.push(fullPath); + } + } + } + + traverse(GMAIL_SYNC_DIR); + return unlabeled; +} + +/** + * Wait for a run to complete by listening for run-processing-end event + */ +async function waitForRunCompletion(runId: string): Promise { + return new Promise(async (resolve) => { + const unsubscribe = await bus.subscribe('*', async (event) => { + if (event.type === 'run-processing-end' && event.runId === runId) { + unsubscribe(); + resolve(); + } + }); + }); +} + +/** + * Label a batch of email files using the labeling agent + */ +async function labelEmailBatch( + files: { path: string; content: string }[] +): Promise<{ runId: string; filesEdited: Set }> { + const run = await createRun({ + agentId: LABELING_AGENT, + }); + + let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`; + message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const relativePath = path.relative(WorkDir, file.path); + const truncated = file.content.length > MAX_CONTENT_LENGTH + ? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]' + : file.content; + + message += `## File ${i + 1}: ${relativePath}\n\n`; + message += truncated; + message += `\n\n---\n\n`; + } + + const filesEdited = new Set(); + + const unsubscribe = await bus.subscribe(run.id, async (event) => { + if (event.type !== 'tool-invocation') { + return; + } + if (event.toolName !== 'workspace-edit') { + return; + } + try { + const parsed = JSON.parse(event.input) as { path?: string }; + if (typeof parsed.path === 'string') { + filesEdited.add(parsed.path); + } + } catch { + // ignore parse errors + } + }); + + await createMessage(run.id, message); + await waitForRunCompletion(run.id); + unsubscribe(); + + return { runId: run.id, filesEdited }; +} + +/** + * Process all unlabeled emails in batches + */ +async function processUnlabeledEmails(): Promise { + console.log('[EmailLabeling] Checking for unlabeled emails...'); + + const state = loadLabelingState(); + const unlabeled = getUnlabeledEmails(state); + + if (unlabeled.length === 0) { + console.log('[EmailLabeling] No unlabeled emails found'); + return; + } + + console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails`); + + const run = await serviceLogger.startRun({ + service: 'email_labeling', + message: `Labeling ${unlabeled.length} email${unlabeled.length === 1 ? '' : 's'}`, + trigger: 'timer', + }); + + const relativeFiles = unlabeled.map(f => path.relative(WorkDir, f)); + const limitedFiles = limitEventItems(relativeFiles); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${unlabeled.length} unlabeled email${unlabeled.length === 1 ? '' : 's'}`, + counts: { emails: unlabeled.length }, + items: limitedFiles.items, + truncated: limitedFiles.truncated, + }); + + const totalBatches = Math.ceil(unlabeled.length / BATCH_SIZE); + let totalEdited = 0; + let hadError = false; + + for (let i = 0; i < unlabeled.length; i += BATCH_SIZE) { + const batchPaths = unlabeled.slice(i, i + BATCH_SIZE); + const batchNumber = Math.floor(i / BATCH_SIZE) + 1; + + try { + // Read file contents for the batch + const files: { path: string; content: string }[] = []; + for (const filePath of batchPaths) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + files.push({ path: filePath, content }); + } catch (error) { + console.error(`[EmailLabeling] Error reading ${filePath}:`, error); + } + } + + if (files.length === 0) { + continue; + } + + console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`); + await serviceLogger.log({ + type: 'progress', + service: run.service, + runId: run.runId, + level: 'info', + message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`, + step: 'batch', + current: batchNumber, + total: totalBatches, + details: { filesInBatch: files.length }, + }); + + const result = await labelEmailBatch(files); + totalEdited += result.filesEdited.size; + + // Only mark files that were actually edited by the agent + for (const file of files) { + const relativePath = path.relative(WorkDir, file.path); + if (result.filesEdited.has(relativePath)) { + markFileAsLabeled(file.path, state); + } + } + + saveLabelingState(state); + console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`); + } catch (error) { + hadError = true; + console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error); + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: `Error processing batch ${batchNumber}`, + error: error instanceof Error ? error.message : String(error), + context: { batchNumber }, + }); + } + } + + state.lastRunTime = new Date().toISOString(); + saveLabelingState(state); + + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: hadError ? 'error' : 'info', + message: `Email labeling complete: ${totalEdited} files labeled`, + durationMs: Date.now() - run.startedAt, + outcome: hadError ? 'error' : 'ok', + summary: { + totalEmails: unlabeled.length, + filesLabeled: totalEdited, + }, + }); + + console.log(`[EmailLabeling] Done. ${totalEdited} emails labeled.`); +} + +/** + * Main entry point - runs as independent polling service + */ +export async function init() { + console.log('[EmailLabeling] Starting Email Labeling Service...'); + console.log(`[EmailLabeling] Will check for unlabeled emails every ${SYNC_INTERVAL_MS / 1000} seconds`); + + // Initial run + await processUnlabeledEmails(); + + // Periodic polling + while (true) { + await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); + + try { + await processUnlabeledEmails(); + } catch (error) { + console.error('[EmailLabeling] Error in main loop:', error); + } + } +} diff --git a/apps/x/packages/core/src/knowledge/labeling_agent.ts b/apps/x/packages/core/src/knowledge/labeling_agent.ts new file mode 100644 index 00000000..f6ff9597 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/labeling_agent.ts @@ -0,0 +1,59 @@ +import { renderTagSystemForEmails } from './tag_system.js'; + +export function getRaw(): string { + return `--- +model: gpt-5.2 +tools: + workspace-readFile: + type: builtin + name: workspace-readFile + workspace-edit: + type: builtin + name: workspace-edit + workspace-readdir: + type: builtin + name: workspace-readdir +--- +# Task + +You are an email labeling agent. Given a batch of email files, you will classify each email and prepend YAML frontmatter with structured labels. + +${renderTagSystemForEmails()} + +# Instructions + +1. For each email file provided in the message, read its content carefully. +2. Classify the email using the taxonomy above. Be accurate and conservative — only apply labels that clearly fit. +3. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line. +4. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp. +5. If the email already has frontmatter (starts with \`---\`), skip it. + +# Frontmatter Format + +\`\`\`yaml +--- +labels: + relationship: + - Investor + topics: + - Fundraising + - Finance + type: Intro + filter: + - Promotion + action: FYI +processed: true +labeled_at: "2026-02-28T12:00:00Z" +--- +\`\`\` + +# Rules + +- Every label category must be present in the frontmatter, even if empty (use \`[]\` for empty arrays). +- \`type\` and \`action\` are single values (strings), not arrays. +- \`relationship\`, \`topics\`, and \`filter\` are arrays. +- Use the exact label values from the taxonomy — do not invent new ones. +- The \`labeled_at\` timestamp should be the current time in ISO 8601 format. +- Process all files in the batch. Do not skip any unless they already have frontmatter. +`; +} diff --git a/apps/x/packages/core/src/knowledge/labeling_state.ts b/apps/x/packages/core/src/knowledge/labeling_state.ts new file mode 100644 index 00000000..ced922af --- /dev/null +++ b/apps/x/packages/core/src/knowledge/labeling_state.ts @@ -0,0 +1,48 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; + +const STATE_FILE = path.join(WorkDir, 'labeling_state.json'); + +export interface LabelingState { + processedFiles: Record; + lastRunTime: string; +} + +export function loadLabelingState(): LabelingState { + if (fs.existsSync(STATE_FILE)) { + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + } catch (error) { + console.error('Error loading labeling state:', error); + } + } + + return { + processedFiles: {}, + lastRunTime: new Date(0).toISOString(), + }; +} + +export function saveLabelingState(state: LabelingState): void { + try { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + } catch (error) { + console.error('Error saving labeling state:', error); + throw error; + } +} + +export function markFileAsLabeled(filePath: string, state: LabelingState): void { + state.processedFiles[filePath] = { + labeledAt: new Date().toISOString(), + }; +} + +export function resetLabelingState(): void { + const emptyState: LabelingState = { + processedFiles: {}, + lastRunTime: new Date().toISOString(), + }; + saveLabelingState(emptyState); +} diff --git a/apps/x/packages/core/src/knowledge/note_creation_medium.ts b/apps/x/packages/core/src/knowledge/note_creation.ts similarity index 70% rename from apps/x/packages/core/src/knowledge/note_creation_medium.ts rename to apps/x/packages/core/src/knowledge/note_creation.ts index 434078c4..d26bd97f 100644 --- a/apps/x/packages/core/src/knowledge/note_creation_medium.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -1,4 +1,7 @@ -export const raw = `--- +import { renderNoteTypesBlock } from './note_system.js'; + +export function getRaw(): string { + return `--- model: gpt-5.2 tools: workspace-writeFile: @@ -130,25 +133,26 @@ Either: --- -# The Core Rule: Medium Strictness +# The Core Rule: Label-Based Filtering -**MEDIUM STRICTNESS MODE** +**Emails now have YAML frontmatter with labels.** Use these labels to decide whether to process or skip. -**Meetings create notes because:** -- You chose to spend time with these people -- If you met them, they matter enough to track -- Meeting transcripts have rich context +**Meetings and voice memos always create notes** — no label check needed. -**Emails can create notes if:** -- The email contains personalized content (not mass mail) -- The sender seems relevant to your work (business context, not consumer services) -- The email is part of a meaningful exchange (not one-off transactional) +**For emails, read the YAML frontmatter labels and apply these rules:** -**Skip creating notes for:** -- Mass emails and newsletters -- Automated/transactional emails -- Consumer service providers (utilities, subscriptions, etc.) -- Cold sales outreach with no prior relationship indication +**CREATE/UPDATE notes if the email has ANY of these labels:** +- **Relationship:** Investor, Customer, Prospect, Partner, Vendor, Candidate, Team, Advisor, Personal, Press, Community, Government +- **Topic:** Sales, Support, Legal, Finance, Hiring, Fundraising, Event, Research +- **Type:** Intro, Followup +- **Action:** Action Required, Urgent, Waiting + +**SKIP if the email ONLY has these labels (and none from above):** +- **Relationship:** Product +- **Topic:** Travel, Shopping, Health, Learning +- **Type:** Scheduling, Cold Outreach, Newsletter, Notification +- **Filter:** Spam, Promotion, Social, Forums +- **Action:** FYI --- @@ -217,168 +221,55 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data --- -# Step 1: Source Filtering +# Step 1: Source Filtering (Label-Based) -## Skip These Sources (Both Meetings and Emails) +## For Meetings and Voice Memos +Always process — no filtering needed. -### Mass Emails and Newsletters +## For Emails — Read YAML Frontmatter -**Indicators:** -- Sent to a list (To: contains multiple addresses, or undisclosed-recipients) -- Unsubscribe link in body or footer -- From a no-reply or marketing address (noreply@, newsletter@, marketing@, hello@) -- Generic greeting ("Hi there", "Dear subscriber", "Hello!") -- Promotional language ("Don't miss out", "Limited time", "% off") -- Mailing list headers (List-Unsubscribe, Mailing-List) -- Sent via marketing platforms (via sendgrid, via mailchimp, etc.) +Emails have YAML frontmatter with labels prepended by the labeling agent: -**Action:** SKIP with reason "Newsletter/mass email" +\`\`\`yaml +--- +labels: + relationship: + - Investor + topics: + - Fundraising + type: Intro + filter: [] + action: FYI +processed: true +labeled_at: "2026-02-28T12:00:00Z" +--- +\`\`\` -### Product Updates & Changelogs +## Decision Rules -**Indicators:** -- Subject contains: "changelog", "what's new", "product update", "release notes", "v1.x", "new features" -- Content describes feature releases, bug fixes, or product changes -- Sent to all users/customers (not personalized to you specifically) -- From tools/SaaS you use: Cal.com, Notion, Slack, Linear, Figma, etc. -- No action required from you — purely informational -- Written in announcement style, not conversational +Check the labels against the create/skip lists: -**Examples to SKIP:** -- "Cal.com Changelog v6.1" — product update -- "What's new in Notion - January 2026" — feature announcement -- "Introducing new Slack features" — product marketing -- "Linear Release Notes" — changelog +**CREATE/UPDATE notes if ANY label matches:** +- relationship: Investor, Customer, Prospect, Partner, Vendor, Candidate, Team, Advisor, Personal, Press, Community, Government +- topics: Sales, Support, Legal, Finance, Hiring, Fundraising, Event, Research +- type: Intro, Followup +- action: Action Required, Urgent, Waiting -**Action:** SKIP with reason "Product update/changelog" +**SKIP if labels ONLY match:** +- relationship: Product +- topics: Travel, Shopping, Health, Learning +- type: Scheduling, Cold Outreach, Newsletter, Notification +- filter: Spam, Promotion, Social, Forums +- action: FYI -### Cold Outreach / Sales Emails - -**THE RULE: If someone emails you offering services and you never responded, SKIP.** - -It doesn't matter how personalized, detailed, or relevant the pitch seems. If: -1. They initiated contact (you didn't reach out first) -2. They're offering services/products -3. You never replied or engaged - -Then it's cold outreach and should be SKIPPED. Do NOT create notes for cold outreach senders or their organizations. - -**EXCEPTION:** If they reference a prior real-world interaction, CREATE a note: -- "Great meeting you at [conference/event]" -- "Following up on our conversation at..." -- "It was nice chatting at [place]" -- "[Mutual contact] suggested I reach out after we met" - -This indicates a real relationship that started offline, not cold outreach. - -**Indicators:** -- Unsolicited contact from someone you've never interacted with -- Offering services you didn't request (HR, payroll, compliance, bookkeeping, recruiting, dev shops, marketing, etc.) -- Sales-y language: "wanted to reach out", "thought this might help", "quick question about your..." -- Mentions your company growth/funding/hiring/tech stack as a hook -- Attaches "free guides", "case studies", "resources", or "frameworks" -- Asks for a call/meeting without any prior relationship -- From domains you've never contacted or met with before -- No existing note for this person or organization -- **No reply from the user in the email thread** - -**Examples to SKIP:** -- "Saw you raised funding, wanted to reach out about our services" -- "Quick question about your bookkeeping/compliance/hiring" -- "Shared this guide that might help with [your problem]" -- "Noticed you're scaling, we help startups with..." -- "Would love 15 minutes to show you how we can help" -- Detailed pitch about HR/payroll/India expansion services (still cold outreach!) -- Follow-up emails to previous cold outreach that got no response - -**Key distinction:** -- **You reaching out to a vendor** → worth tracking (you initiated) -- **You replied to their outreach** → worth tracking (you engaged) -- **Vendor cold emailing you with no response** → SKIP (no relationship exists) - -**IMPORTANT: CC'd people on cold outreach** -When an email is identified as cold outreach, skip notes for ALL parties involved: -- The sender (the person doing the outreach) -- Anyone CC'd on the email (colleagues of the sender, other contacts they're trying to connect) -- The organization they represent - -If someone only appears in your memory as "CC'd on outreach emails from [Sender]", they don't warrant a note — they're just incidentally included in cold outreach, not a real relationship. - -**Action:** SKIP with reason "Cold outreach/sales email - no engagement from user" - -### Automated/Transactional - -**Indicators:** -- From automated systems (notifications@, alerts@, no-reply@) -- Password resets, login alerts, shipping notifications -- Calendar invites without substance -- Receipts and invoices (unless from key vendor/customer) -- GitHub/Jira/Slack notifications - -**Action:** SKIP with reason "Automated/transactional" - -### Low-Signal - -**Indicators:** -- Very short with no substance ("Thanks!", "Sounds good", "Got it") -- Only contains forwarded message with no commentary -- Auto-replies ("I'm out of office") - -**Action:** SKIP with reason "Low signal" - -### Consumer Services (Medium strictness specific) - -**Indicators:** -- From consumer service companies (utilities, streaming, retail) -- Account management emails -- Subscription confirmations -- Delivery notifications - -**Action:** SKIP with reason "Consumer service" - -### Infrastructure & SaaS Providers - -**Skip emails from these types of services:** -- Domain registrars: GoDaddy, Namecheap, Google Domains, Cloudflare -- Hosting providers: AWS, Google Cloud, Azure, DigitalOcean, Heroku, Vercel, Netlify -- Email providers: Google Workspace, Microsoft 365, Zoho -- Payment processors: Stripe, PayPal, Square, Razorpay -- Developer tools: GitHub, GitLab, Bitbucket, npm, Docker Hub -- Analytics: Google Analytics, Mixpanel, Amplitude, Segment -- Auth providers: Auth0, Okta, Firebase Auth -- Support platforms: Zendesk, Intercom, Freshdesk -- HR/Payroll: Gusto, Rippling, Deel, Remote - -**Indicators:** -- Automated system notifications (renewal reminders, usage alerts, security notices) -- No personalized content from a human -- From domains like @godaddy.com, @aws.amazon.com, @stripe.com, etc. -- Templates about account status, billing, or technical alerts - -**Action:** SKIP with reason "Infrastructure/SaaS provider notification" - -## Email-Specific Processing (Medium Strictness) - -For emails, evaluate if the content is personalized and business-relevant: - -**Create note if:** -- The email is personally addressed and substantive -- The sender appears to be from a business/organization relevant to your work -- The content discusses work, projects, opportunities, or professional topics -- It's a warm intro from anyone (not just existing contacts) -- It's a thoughtful cold outreach that's specific to your work - -**Do not create note if:** -- Clearly mass/templated email -- Consumer service interaction -- Generic sales pitch with no personalization +**Logic:** If even one label falls in the "create" list, process the email. Only skip if ALL labels fall in the "skip" list. ## Filter Decision Output If skipping: \`\`\` SKIP -Reason: {reason} +Reason: Labels indicate skip-only categories: {list the labels} \`\`\` If processing, continue to Step 2. @@ -552,16 +443,16 @@ Resolution Map: - "the integration" → "Acme Integration" (same project) \`\`\` -## 4b: Apply Source Type Rules (Medium Strictness) +## 4b: Apply Source Type Rules -**If source_type == "meeting":** +**If source_type == "meeting" or "voice_memo":** - Resolved entities → Update existing notes - New entities that pass filters → Create new notes -**If source_type == "email" (MEDIUM STRICTNESS):** +**If source_type == "email":** +- The email already passed label-based filtering in Step 1 - Resolved entities → Update existing notes -- New entities → Create notes IF the email is personalized and business-relevant -- New entities from cold sales pitches without personalization → Skip +- New entities → Create notes (the labels already confirmed this email is worth processing) ## 4c: Disambiguation Rules @@ -628,39 +519,23 @@ For entities not resolved to existing notes, determine if they warrant new notes ## People -### Who Gets a Note (Medium Strictness) +### Who Gets a Note **CREATE a note for people who are:** - External (not @user.domain) - Attendees in meetings -- Email correspondents sending personalized, business-relevant content +- Email correspondents (emails that reach this step already passed label-based filtering) - Decision makers or contacts at customers, prospects, or partners - Investors or potential investors - Candidates you are interviewing - Advisors or mentors - Key collaborators - Introducers who connect you to valuable contacts -- Anyone reaching out with a specific, relevant opportunity **DO NOT create notes for:** -- Transactional service providers (bank employees, support reps) -- One-time administrative contacts - Large group meeting attendees you didn't interact with - Internal colleagues (@user.domain) - Assistants handling only logistics -- Generic role-based contacts -- Consumer service representatives -- Generic cold sales outreach with no personalization - -### The Relevance Test (Medium Strictness) - -Ask: Is this person relevant to my professional work or goals? - -- Sarah Chen, VP Engineering evaluating your product → **Yes, create note** -- James from HSBC who set up your account → **No, skip** -- Investor reaching out about your company → **Yes, create note** -- Cold recruiter with a generic pitch → **No, skip** -- Someone reaching out about a specific opportunity → **Yes, create note** ### Role Inference @@ -1025,153 +900,18 @@ After writing, verify links go both ways. --- -# Note Templates - -## People -\`\`\`markdown -# {Full Name} - -## Info -**Role:** {role, or inferred role with qualifier, or leave blank if truly 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, what you're working on together.} - -## Connected to -- [[Organizations/{Organization}]] — works at -- [[People/{Person}]] — {colleague, introduced by, reports to} -- [[Projects/{Project}]] — {role} - -## Activity -- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {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:** {comma-separated: 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 transactional contacts who don't get their own notes} - -## Projects -- [[Projects/{Project}]] — {relationship} - -## Activity -- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {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.} -\`\`\` - -## 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}]] — {customer|partner|etc.} - -## Related -- [[Topics/{Topic}]] — {relationship} -- [[Projects/{Project}]] — {relationship} - -## Timeline -**{YYYY-MM-DD}** ({meeting|email}) -{What happened.} - -## Decisions -- **{YYYY-MM-DD}**: {Decision}. {Rationale}. - -## Open items -{Commitments and next steps only. Leave empty if none.} - -## Key facts -{Substantive facts only. Leave empty if none.} -\`\`\` - -## Topics -\`\`\`markdown -# {Topic Name} - -## About -{1-2 sentences: What this topic covers.} - -**Keywords:** {comma-separated} -**Aliases:** {other ways this topic is referenced} -**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 with [[Folder/Name]] links} - -## Decisions -- **{YYYY-MM-DD}**: {Decision} - -## Open items -{Commitments and next steps only. Leave empty if none.} - -## Key facts -{Substantive facts only. Leave empty if none.} -\`\`\` +${renderNoteTypesBlock()} --- -# Summary: Medium Strictness Rules +# Summary: Label-Based Rules | Source Type | Creates Notes? | Updates Notes? | Detects State Changes? | |-------------|---------------|----------------|------------------------| | Meeting | Yes | Yes | Yes | | Voice memo | Yes | Yes | Yes | -| Email (personalized, business-relevant) | Yes | Yes | Yes | -| Email (mass/automated/consumer) | No (SKIP) | No | No | -| Email (cold outreach with personalization) | Yes | Yes | Yes | -| Email (generic cold outreach) | No | No | No | +| Email (has create label) | Yes | Yes | Yes | +| Email (only skip labels) | No (SKIP) | No | No | **Voice memo activity format:** Always include a link to the source voice memo: \`\`\` @@ -1198,7 +938,7 @@ Before completing, verify: **Source Type:** - [ ] Correctly identified as meeting or email -- [ ] Applied correct medium strictness rules +- [ ] Applied label-based filtering rules correctly **Resolution:** - [ ] Extracted all name variants from source @@ -1233,4 +973,5 @@ Before completing, verify: - [ ] Dates are YYYY-MM-DD - [ ] Bidirectional links are consistent - [ ] New notes in correct folders -`; \ No newline at end of file +`; +} \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/note_creation_high.ts b/apps/x/packages/core/src/knowledge/note_creation_high.ts deleted file mode 100644 index ce15c324..00000000 --- a/apps/x/packages/core/src/knowledge/note_creation_high.ts +++ /dev/null @@ -1,1950 +0,0 @@ -export const raw = `--- -model: gpt-5.2 -tools: - workspace-writeFile: - type: builtin - name: workspace-writeFile - workspace-readFile: - type: builtin - name: workspace-readFile - workspace-edit: - type: builtin - name: workspace-edit - workspace-readdir: - type: builtin - name: workspace-readdir - workspace-mkdir: - type: builtin - name: workspace-mkdir - workspace-grep: - type: builtin - name: workspace-grep - workspace-glob: - type: builtin - name: workspace-glob ---- -# Task - -You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), 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 (meetings only) -6. Extract structured information (decisions, commitments, key facts) -7. **Detect state changes (status updates, resolved items, role changes)** -8. Create new notes (meetings only) or update existing notes -9. **Apply state changes to existing notes** - -The core rule: **Meetings and voice memos create notes. Emails enrich them.** - -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 these tools: - -**For reading files:** -\`\`\` -workspace-readFile({ path: "knowledge/People/Sarah Chen.md" }) -\`\`\` - -**For creating NEW files:** -\`\`\` -workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." }) -\`\`\` - -**For editing EXISTING files (preferred for updates):** -\`\`\` -workspace-edit({ - path: "knowledge/People/Sarah Chen.md", - oldString: "## Activity\\n", - newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n" -}) -\`\`\` - -**For listing directories:** -\`\`\` -workspace-readdir({ path: "knowledge/People" }) -\`\`\` - -**For creating directories:** -\`\`\` -workspace-mkdir({ path: "knowledge/Projects", recursive: true }) -\`\`\` - -**For searching files:** -\`\`\` -workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" }) -\`\`\` - -**For finding files by pattern:** -\`\`\` -workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" }) -\`\`\` - -**IMPORTANT:** -- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields) -- Use \`workspace-writeFile\` only for creating new notes -- Prefer the knowledge_index for entity resolution (it's faster than grep) - -# Output - -Either: -- **SKIP** with reason, if source should be ignored -- Updated or new markdown files in notes_folder - ---- - -# The Core Rule: Meetings Create, Emails Enrich - -**Meetings create notes because:** -- You chose to spend time with these people -- If you met them, they matter enough to track -- Meeting transcripts have rich context - -**Emails only update existing notes because:** -- Most emails are noise -- Without a meeting, there's no established relationship worth tracking -- Prevents memory bloat from random contacts - -**The only exception:** Warm intros from someone already in your memory. - ---- - -# Step 0: Determine Source Type - -Read the source file and determine if it's a meeting or email. -\`\`\` -workspace-readFile({ path: "{source_file}" }) -\`\`\` - -**Meeting indicators:** -- Has \`Attendees:\` field -- Has \`Meeting:\` title -- Transcript format with speaker labels - -**Email indicators:** -- Has \`From:\` and \`To:\` fields -- Has \`Subject:\` field -- Email signature - -**Voice memo indicators:** -- Has \`**Type:** voice memo\` field -- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\` -- Has \`## Transcript\` section - -**Set processing mode:** -- \`source_type = "meeting"\` → Can create new notes -- \`source_type = "email"\` → Can only update existing notes -- \`source_type = "voice_memo"\` → Can create new notes (treat like meetings) - ---- - -## Calendar Invite Emails - -Emails containing calendar invites (\`.ics\` attachments or inline calendar data) are **high signal** - a scheduled meeting means this person matters. - -**How to identify:** -- Subject contains "Invitation:", "Accepted:", "Declined:", or "Updated:" -- Has \`.ics\` attachment reference -- Contains calendar metadata (VCALENDAR, VEVENT) - -**Rules for calendar invite emails:** -1. **CREATE a note for the primary contact** - the person you're actually meeting with -2. **Extract from the invite:** their name, email, organization (from email domain), meeting topic -3. **Skip automated notifications from Google/Outlook** - emails from calendar-no-reply@google.com with no human sender -4. **Skip "Accepted/Declined" responses** - these are just RSVP confirmations, not new contacts - -**Who is the primary contact?** -- For 1:1 meetings: the other person -- For group meetings: the organizer (unless it's an EA - check if organizer differs from attendees) -- Look at the meeting title for hints (e.g., "Coffee with Sarah" → Sarah is the contact) - -**What to extract:** -- Name and email from the invite -- Organization from email domain -- Meeting topic as context -- Note that you have an upcoming meeting scheduled - -**Examples:** -- "Invitation: Coffee with Sarah Chen" from sarah@acme.com → CREATE note for Sarah Chen at Acme -- "Invitation: Acme <> YourCompany sync" organized by sarah@acme.com → CREATE note for Sarah -- "Accepted: Meeting" from calendar-no-reply@google.com → SKIP (just a notification) -- "Declined: Sync" from john@example.com → SKIP (RSVP, not a new relationship) - -**Why this matters:** Once a note exists, subsequent emails from this person will enrich it. When the meeting happens, the transcript adds more detail. - ---- - -# Step 1: Source Filtering - -## Skip These Sources (Both Meetings and Emails) - -### Mass Emails and Newsletters - -**Indicators:** -- Sent to a list (To: contains multiple addresses, or undisclosed-recipients) -- Unsubscribe link in body or footer -- From a no-reply or marketing address (noreply@, newsletter@, marketing@, hello@) -- Generic greeting ("Hi there", "Dear subscriber", "Hello!") -- Promotional language ("Don't miss out", "Limited time", "% off") -- Mailing list headers (List-Unsubscribe, Mailing-List) -- Sent via marketing platforms (via sendgrid, via mailchimp, etc.) - -**Action:** SKIP with reason "Newsletter/mass email" - -### Product Updates & Changelogs - -**Indicators:** -- Subject contains: "changelog", "what's new", "product update", "release notes", "v1.x", "new features" -- Content describes feature releases, bug fixes, or product changes -- Sent to all users/customers (not personalized to you specifically) -- From tools/SaaS you use: Cal.com, Notion, Slack, Linear, Figma, etc. -- No action required from you — purely informational -- Written in announcement style, not conversational - -**Examples to SKIP:** -- "Cal.com Changelog v6.1" — product update -- "What's new in Notion - January 2026" — feature announcement -- "Introducing new Slack features" — product marketing -- "Linear Release Notes" — changelog - -**Action:** SKIP with reason "Product update/changelog" - -### Cold Outreach / Sales Emails - -**THE RULE: If someone emails you offering services and you never responded, SKIP.** - -It doesn't matter how personalized, detailed, or relevant the pitch seems. If: -1. They initiated contact (you didn't reach out first) -2. They're offering services/products -3. You never replied or engaged - -Then it's cold outreach and should be SKIPPED. Do NOT create notes for cold outreach senders or their organizations. - -**EXCEPTION:** If they reference a prior real-world interaction, CREATE a note: -- "Great meeting you at [conference/event]" -- "Following up on our conversation at..." -- "It was nice chatting at [place]" -- "[Mutual contact] suggested I reach out after we met" - -This indicates a real relationship that started offline, not cold outreach. - -**Indicators:** -- Unsolicited contact from someone you've never interacted with -- Offering services you didn't request (HR, payroll, compliance, bookkeeping, recruiting, dev shops, marketing, etc.) -- Sales-y language: "wanted to reach out", "thought this might help", "quick question about your..." -- Mentions your company growth/funding/hiring/tech stack as a hook -- Attaches "free guides", "case studies", "resources", or "frameworks" -- Asks for a call/meeting without any prior relationship -- From domains you've never contacted or met with before -- No existing note for this person or organization -- **No reply from the user in the email thread** - -**Examples to SKIP:** -- "Saw you raised funding, wanted to reach out about our services" -- "Quick question about your bookkeeping/compliance/hiring" -- "Shared this guide that might help with [your problem]" -- "Noticed you're scaling, we help startups with..." -- "Would love 15 minutes to show you how we can help" -- Detailed pitch about HR/payroll/India expansion services (still cold outreach!) -- Follow-up emails to previous cold outreach that got no response - -**Key distinction:** -- **You reaching out to a vendor** → worth tracking (you initiated) -- **You replied to their outreach** → worth tracking (you engaged) -- **Vendor cold emailing you with no response** → SKIP (no relationship exists) - -**IMPORTANT: CC'd people on cold outreach** -When an email is identified as cold outreach, skip notes for ALL parties involved: -- The sender (the person doing the outreach) -- Anyone CC'd on the email (colleagues of the sender, other contacts they're trying to connect) -- The organization they represent - -If someone only appears in your memory as "CC'd on outreach emails from [Sender]", they don't warrant a note — they're just incidentally included in cold outreach, not a real relationship. - -**Action:** SKIP with reason "Cold outreach/sales email - no engagement from user" - -### Automated/Transactional - -**Indicators:** -- From automated systems (notifications@, alerts@, no-reply@) -- Password resets, login alerts, shipping notifications -- Calendar invites without substance -- Receipts and invoices (unless from key vendor/customer) -- GitHub/Jira/Slack notifications - -**Action:** SKIP with reason "Automated/transactional" - -### Low-Signal - -**Indicators:** -- Very short with no substance ("Thanks!", "Sounds good", "Got it") -- Only contains forwarded message with no commentary -- Auto-replies ("I'm out of office") - -**Action:** SKIP with reason "Low signal" - -### Infrastructure & SaaS Providers - -**Skip emails from these types of services:** -- Domain registrars: GoDaddy, Namecheap, Google Domains, Cloudflare -- Hosting providers: AWS, Google Cloud, Azure, DigitalOcean, Heroku, Vercel, Netlify -- Email providers: Google Workspace, Microsoft 365, Zoho -- Payment processors: Stripe, PayPal, Square, Razorpay -- Developer tools: GitHub, GitLab, Bitbucket, npm, Docker Hub -- Analytics: Google Analytics, Mixpanel, Amplitude, Segment -- Auth providers: Auth0, Okta, Firebase Auth -- Support platforms: Zendesk, Intercom, Freshdesk -- HR/Payroll: Gusto, Rippling, Deel, Remote - -**Indicators:** -- Automated system notifications (renewal reminders, usage alerts, security notices) -- No personalized content from a human -- From domains like @godaddy.com, @aws.amazon.com, @stripe.com, etc. -- Templates about account status, billing, or technical alerts - -**Action:** SKIP with reason "Infrastructure/SaaS provider notification" - -## Email-Specific Filtering - -For emails, check if sender/recipients have existing notes: -\`\`\` -workspace-grep({ pattern: "{sender email}", searchPath: "{knowledge_folder}" }) -workspace-grep({ pattern: "{sender name}", searchPath: "{knowledge_folder}/People" }) -\`\`\` - -**If no existing note found:** -- Check if this is a warm intro from someone in memory (see below) -- If not a warm intro → SKIP with reason "No existing relationship" - -**If existing note found:** -- Continue processing -- Will update existing note only - -### Detecting Warm Intros - -A warm intro is when someone already in your memory introduces you to someone new. - -**Indicators:** -- Subject contains "Intro:" or "Introduction:" -- Body contains "want to introduce" or "meet [Name]" -- Sender has an existing note in memory -- New person is CC'd or mentioned - -**If warm intro detected:** -- This is the ONE exception where email can create notes -- Create note for the introduced person -- Create org note for their company if needed - -## Filter Decision Output - -If skipping: -\`\`\` -SKIP -Reason: {reason} -\`\`\` - -If processing, continue to Step 2. - ---- - -# Step 2: Read and Parse Source File -\`\`\` -workspace-readFile({ path: "{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" -- Pronouns with clear antecedents: "she" (referring to Sarah in same paragraph) - -**Organization variants:** -- Full names: "Acme Corporation" -- Short names: "Acme" -- Abbreviations: "AC" -- Email domains: "@acme.com" -- References: "your company", "their team" - -**Project variants:** -- Explicit names: "Project Atlas" -- Descriptive references: "the integration", "the pilot", "the deal" -- Combined references: "Acme integration", "the Series A" - -Create a list of all variants found: -\`\`\` -Variants found: -- People: "Sarah Chen", "Sarah", "sarah@acme.com", "David", "their CTO" -- Organizations: "Acme Corp", "Acme", "@acme.com" -- Projects: "the pilot", "Q2 integration" -\`\`\` - ---- - -# 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 -workspace-readFile({ path: "{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: -\`\`\` -Resolution Map: -- "Sarah Chen" → "Sarah Chen" (exact match found) -- "Sarah" → "Sarah Chen" (matched via Acme context) -- "sarah@acme.com" → "Sarah Chen" (email match in note) -- "David" → "David Kim" (matched via Acme context) -- "their CTO" → "Jennifer Lee" (role match at Acme) OR "Unknown CTO at Acme Corp" (if not found) -- "Acme" → "Acme Corp" (existing note) -- "Acme Corporation" → "Acme Corp" (alias match) -- "@acme.com" → "Acme Corp" (domain match) -- "the pilot" → "Acme Integration" (project with Acme) -- "the integration" → "Acme Integration" (same project) -\`\`\` - -## 4b: Apply Source Type Rules - -**If source_type == "meeting":** -- Resolved entities → Update existing notes -- New entities that pass filters → Create new notes - -**If source_type == "email":** -- Resolved entities → Update existing notes -- New entities → Do NOT create notes (skip them) -- Exception: Warm intro → Create note for introduced person - -## 4c: Disambiguation Rules - -When multiple candidates match a variant, disambiguate: - -**By organization (strongest signal):** -\`\`\` -# "David" could be David Kim or David Chen -workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" }) -# Output: **Organization:** [[Acme Corp]] - -workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" }) -# Output: **Organization:** [[Other Corp]] - -# Source is from Acme context → "David" = "David Kim" -\`\`\` - -**By email (definitive):** -\`\`\` -workspace-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" }) -# Exact email match is definitive -\`\`\` - -**By role:** -\`\`\` -# Source mentions "their CTO" -workspace-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" }) -# Filter results by organization context -\`\`\` - -**By recency (weakest signal):** -If still ambiguous, prefer the person with more recent activity in notes. - -**If still ambiguous:** -- Flag in resolution map: "David" → "David (ambiguous - could be David Kim or David Chen)" -- Will handle in Step 5 - -## 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]] -- "David" → [[People/David Kim]] -- "Acme", "Acme Corp", "@acme.com" → [[Organizations/Acme Corp]] -- "the pilot", "the integration" → [[Projects/Acme Integration]] - -NEW ENTITIES (meetings only — create notes): -- "Jennifer" (CTO, Acme Corp) → Create [[People/Jennifer]] or [[People/Jennifer (Acme Corp)]] -- "SOC 2" → Create [[Topics/Security Compliance]] - -NEW ENTITIES (emails — do not create): -- "Random Person" → Skip, no existing relationship - -AMBIGUOUS (flag or skip): -- "Mike" (no context) → Mention in activity only, don't create note - -SKIP (doesn't warrant note): -- "their assistant" → Transactional contact -\`\`\` - ---- - -# Step 5: Identify New Entities (Meetings Only) - -**This step only applies to meetings. For emails, skip to Step 6.** - -For entities not resolved to existing notes, determine if they warrant new notes. - -## People (Meetings Only) - -### Who Gets a Note - -**CREATE a note for meeting attendees who are:** -- External (not @user.domain) -- Decision makers or key contacts at customers, prospects, or partners -- Investors or potential investors -- Candidates you are interviewing -- Advisors or mentors with ongoing relationships -- Key collaborators on important matters -- Introducers who connect you to valuable contacts - -**DO NOT create notes for:** -- Transactional service providers (bank employees, support reps) -- One-time administrative contacts -- Large group meeting attendees you didn't interact with -- Internal colleagues (@user.domain) -- Assistants handling only logistics -- Generic role-based contacts - -### The "Would I Prep for This Person?" Test - -Ask: If I had a call with this person next week, would I want notes beforehand? - -- Sarah Chen, VP Engineering evaluating your product → **Yes, create note** -- James from HSBC who set up your account → **No, skip** -- Investor you're pitching → **Yes, create note** -- Recruiter scheduling interviews → **No, skip** - -### Role Inference - -If role is not explicitly stated, infer from context: - -**From email signatures:** -- Often contains title - -**From meeting context:** -- Organizer of cross-company meeting → likely senior or partnerships -- Technical questions → likely engineering -- Pricing questions → likely procurement or finance -- Product feedback → likely product - -**From email patterns:** -- firstname@company.com → often founder or senior -- firstname.lastname@company.com → often larger company employee - -**From conversation content:** -- "I'll need to check with my team" → manager -- "Let me run this by leadership" → IC or mid-level -- "I can make that call" → decision maker - -**Format in note:** -\`\`\`markdown -**Role:** Product Lead (inferred from evaluation discussions) -**Role:** Senior (inferred — organized cross-company meeting) -**Role:** Engineering (inferred — asked technical integration questions) -\`\`\` - -**Never write just "Unknown" if you can make a reasonable inference.** - -### Relationship Type Guide - -| Relationship Type | Create People Notes? | Create Org Note? | -|-------------------|----------------------|------------------| -| Customer (active deal) | Yes — key contacts | Yes | -| Customer (support ticket) | No | Maybe update existing | -| Prospect | Yes — decision makers | Yes | -| Investor | Yes | Yes | -| Strategic partner | Yes — key contacts | Yes | -| Vendor (strategic) | Yes — main contact only | Yes | -| Vendor (transactional) | No | Optional | -| Bank/Financial services | No | Yes (one note) | -| Candidate | Yes | No | -| Service provider (one-time) | No | No | - -### Handling Non-Note-Worthy People - -For people who don't warrant their own note, add to Organization note's Contacts section: -\`\`\`markdown -## Contacts -- James Wong — Relationship Manager, helped with account setup -- Sarah Lee — Support, handled wire transfer issue -\`\`\` - -## Organizations (Meetings Only) - -**CREATE a note if:** -- Someone from that org attended the meeting -- It's a customer, prospect, investor, or partner - -**DO NOT create for:** -- Tool/service providers mentioned in passing -- One-time transactional vendors - -## Projects (Meetings Only) - -**CREATE a note if:** -- Discussed substantively in the meeting -- Has a goal and timeline -- Involves multiple interactions - -## Topics (Meetings Only) - -**CREATE a note if:** -- Recurring theme discussed -- Will come up again across conversations - ---- - -# Step 6: Extract Content - -For each entity that has or will have a note, extract relevant content. - -## Decisions - -**Indicators:** -- "We decided..." / "We agreed..." / "Let's go with..." -- "The plan is..." / "Going forward..." -- "Approved" / "Confirmed" / "Chose X over Y" - -**Extract:** What, when (source date), who, rationale. - -## Commitments - -**Indicators:** -- "I'll..." / "We'll..." / "Let me..." -- "Can you..." / "Please send..." -- "By Friday" / "Next week" / "Before the call" - -**Extract:** Owner, action, deadline, status (open). - -## Key Facts - -Key facts should be **substantive information about the entity** — not commentary about missing data. - -**Extract if:** -- Specific numbers (budget: $50K, team size: 12, timeline: Q2) -- Preferences or working style ("prefers async communication") -- Background information ("previously at Google") -- Authority or decision process ("needs CEO sign-off") -- Concerns or constraints ("security is top priority") -- What they're evaluating or interested in -- What was discussed or proposed -- Technical requirements or specifications - -**Never include:** -- Meta-commentary about missing data ("Name only provided", "Role not mentioned") -- Obvious facts ("Works at Acme" — that's in the Info section) -- Placeholder text ("Unknown", "TBD") -- Data quality observations ("Full name not in email") - -**If there are no substantive key facts, leave the section empty.** An empty section is better than filler. - -**Good key facts:** -\`\`\`markdown -## Key facts -- Evaluating AI copilot for in-app experience -- Three use cases discussed: pre-purchase sales, onboarding, coaching -- Budget approved for Q2 pilot -- Needs SOC 2 compliance before proceeding -\`\`\` - -**Bad key facts:** -\`\`\`markdown -## Key facts -- Name only provided; full name/role not in email. -- Email address not available. -- Meeting was 50 minutes. -\`\`\` - -## Open Items - -Open items are **commitments and next steps from the conversation** — not tasks to fill in missing data. - -**Include:** -- Commitments made: "I'll send the documentation by Friday" -- Requests received: "Can you share pricing?" -- Next steps discussed: "Let's schedule a technical deep-dive" -- Follow-ups agreed: "Will loop in their CTO" - -**Format:** -\`\`\`markdown -- [ ] {Action} — {owner if not you}, {due date if known} -\`\`\` - -**Never include:** -- Data gaps: "Find their full name", "Get their email", "Add role" -- Wishes: "Would be good to know their budget" -- Agent tasks: "Research their company" - -**If there are no actual commitments or next steps, leave the section empty.** - -**Good open items:** -\`\`\`markdown -## Open items -- [ ] Send API documentation — by Friday -- [ ] Schedule follow-up call with CTO -- [ ] Share pricing proposal — after technical review -\`\`\` - -**Bad open items:** -\`\`\`markdown -## Open items -- [ ] Find Matteo's full name, role, and email at [[Eight Sleep]] -- [ ] Add Anurag's role/title at Groww -- [ ] Research Eight Sleep company background -\`\`\` - -## Summary - -The summary should answer: **"Who is this person and why do I know them?"** - -**Write 2-3 sentences covering:** -- Their role/function (even if inferred) -- The context of your relationship -- What you're discussing or working on together - -**Focus on the relationship, not the communication method.** - -**Good summaries:** -\`\`\`markdown -## Summary -Product contact at [[Organizations/Eight Sleep]] exploring an AI copilot for their app. -Initial discussions covered sales assistance, onboarding, and coaching use cases. -Currently evaluating fit with their product roadmap. -\`\`\` -\`\`\`markdown -## Summary -VP Engineering at [[Organizations/Acme Corp]] leading their integration project. -Key technical decision-maker. Working toward Q2 pilot launch. -\`\`\` - -**Bad summaries:** -\`\`\`markdown -## Summary -Contact at [[Organizations/Eight Sleep]]; received an outbound pitch from [[People/Arjun Maheswaran]] -about an in-app AI copilot concept. -\`\`\` -\`\`\`markdown -## Summary -Attendee on the scheduled "Groww <> RowBoat" meeting (Aug 12, 2024). -\`\`\` - -**Why these are bad:** -- "Received an outbound pitch" — describes the email, not the relationship -- "Attendee on scheduled meeting" — describes attendance, not who they are - -**Infer when needed:** -If role is unknown but context suggests it, say so: -- "Likely product or partnerships (evaluating AI integration)" -- "Senior contact (organized cross-company meeting)" - -## Activity Summary - -One line summarizing this source's relevance to the entity: -\`\`\` -**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]} -\`\`\` - -**For voice memos:** Include a link to the voice memo file using the Path field: -\`\`\` -**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]] -\`\`\` - -**Important:** Use canonical names with absolute paths from resolution map in all summaries: -\`\`\` -# Correct (uses absolute paths): -**2025-01-15** (meeting): [[People/Sarah Chen]] confirmed timeline with [[People/David Kim]]. Blocked on [[Topics/Security Compliance]]. - -# Incorrect (uses variants or relative links): -**2025-01-15** (meeting): Sarah confirmed timeline with David. Blocked on SOC 2. -**2025-01-15** (meeting): [[Sarah Chen]] confirmed timeline with [[David Kim]]. Blocked on [[Security Compliance]]. -\`\`\` - ---- - -# Step 7: Detect State Changes - -Review the extracted content for signals that existing note fields should be updated. - -## 7a: Project Status Changes - -**Look for these signals:** - -| Signal | New Status | -|--------|------------| -| "Moving forward" / "approved" / "signed" / "green light" | active | -| "On hold" / "pausing" / "delayed" / "pushed back" | on hold | -| "Cancelled" / "not proceeding" / "killed" / "passed" | cancelled | -| "Launched" / "completed" / "done" / "shipped" | completed | -| "Exploring" / "considering" / "evaluating" / "might" | planning | - -**Action:** If a related project note exists and the signal is clear, update the \`**Status:**\` field. - -**Example:** -\`\`\` -Source: "Great news — leadership approved the pilot!" -Current: **Status:** planning -Update to: **Status:** active -\`\`\` - -**Be conservative:** Only update status when the signal is unambiguous. If unclear, add to activity log but don't change status. - -## 7b: Open Item Resolution - -**Look for signals that a previously tracked open item is now complete:** - -| Signal | Action | -|--------|--------| -| "Here's the [X] you requested" | Mark [X] complete | -| "I've sent the [X]" | Mark [X] complete | -| "The [X] is ready" | Mark [X] complete | -| "[X] is done" | Mark [X] complete | -| "Attached is the [X]" | Mark [X] complete | - -**How to match:** -1. Read existing open items from the note -2. Look for items that match what was delivered/completed -3. Change \`- [ ]\` to \`- [x]\` with completion date - -**Example:** -\`\`\` -Source: "Here's the API documentation you requested." -Current: - [ ] Send API documentation — by Friday -Update to: - [x] Send API documentation — completed 2025-01-16 -\`\`\` - -**Be conservative:** Only mark complete if there's a clear match. If unsure, add to activity log but don't mark complete. - -## 7c: Role/Title Changes - -**Look for signals:** -- New title in email signature -- "I've been promoted to..." -- "I'm now the..." -- "I've moved to the [X] team" -- Different role mentioned than what's in the note - -**Action:** Update the \`**Role:**\` field in person note. - -**Example:** -\`\`\` -Source: Email signature shows "VP Engineering" -Current: **Role:** Engineering Lead -Update to: **Role:** VP Engineering (updated 2025-01-16) -\`\`\` - -## 7d: Organization/Relationship Changes - -**Look for signals:** -- "I've joined [New Company]" -- "We're now a customer" / "We signed the contract" -- "We've partnered with..." -- "They acquired us" -- New email domain for known person - -**Action:** Update relevant fields: -- Person's \`**Organization:**\` field -- Org's \`**Relationship:**\` field (prospect → customer, etc.) - -**Example:** -\`\`\` -Source: "Excited to announce we've signed the contract!" -Current: **Relationship:** prospect -Update to: **Relationship:** customer -\`\`\` - -## 7e: Build State Change List - -Before writing, compile all detected state changes: -\`\`\` -STATE CHANGES: -- [[Projects/Acme Integration]]: Status planning → active (leadership approved) -- [[People/Sarah Chen]]: Role "Engineering Lead" → "VP Engineering" (signature) -- [[People/Sarah Chen]]: Open item "Send API documentation" → completed -- [[Organizations/Acme Corp]]: Relationship prospect → customer (contract signed) -\`\`\` - ---- - -# Step 8: Check for Duplicates and Conflicts - -Before writing, compare extracted content against existing notes. - -## Check Activity Log -\`\`\` -workspace-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" }) -\`\`\` - -If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction. - -## Check Key Facts - -Review key facts against existing. Skip duplicates. - -## Check Open Items - -Review open items for: -- Duplicates (don't add same item twice) -- Items that should be marked complete (from Step 7b) - -## Check for Conflicts - -If new info contradicts existing: -- Note both versions -- Add "(needs clarification)" -- Don't silently overwrite - ---- - -# Step 9: Write Updates - -## 9a: Meetings — Create and Update Notes - -**IMPORTANT: Write sequentially, one file at a time.** -- Generate content for exactly one note. -- Issue exactly one write/edit command. -- Wait for the tool to return before generating the next note. -- Do NOT batch multiple write commands in a single response. - -**For NEW entities (use workspace-writeFile):** -\`\`\` -workspace-writeFile({ - path: "{knowledge_folder}/People/Jennifer.md", - data: "# Jennifer\\n\\n## Summary\\n..." -}) -\`\`\` - -**For EXISTING entities (use workspace-edit):** -- Read current content first with workspace-readFile -- Use workspace-edit to add activity entry at TOP (reverse chronological) -- Update fields using targeted edits -\`\`\` -workspace-edit({ - path: "{knowledge_folder}/People/Sarah Chen.md", - oldString: "## Activity\\n", - newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n" -}) -\`\`\` - -## 9b: Emails — Update Existing Notes Only - -**Only update notes that already exist.** - -Do NOT create new notes from emails (except warm intros). - -For existing notes: -- Add activity entry -- Update "Last seen" date -- Add new key facts -- Add new commitments -- Update open items if resolved - -## 9c: Apply State Changes - -For each state change identified in Step 7: - -### Update Project Status -\`\`\`bash -# Read current project note -workspace-readFile({ path: "{knowledge_folder}/Projects/Acme Integration.md" }) - -# Update the Status field -# Change: **Status:** planning -# To: **Status:** active -\`\`\` - -### Mark Open Items Complete -\`\`\`bash -# Read current note -workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" }) - -# Find matching open item and update -# Change: - [ ] Send API documentation — by Friday -# To: - [x] Send API documentation — completed 2025-01-16 -\`\`\` - -### Update Role -\`\`\`bash -# Read current person note -workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" }) - -# Update role field -# Change: **Role:** Engineering Lead -# To: **Role:** VP Engineering -\`\`\` - -### Update Relationship -\`\`\`bash -# Read current org note -workspace-readFile({ path: "{knowledge_folder}/Organizations/Acme Corp.md" }) - -# Update relationship field -# Change: **Relationship:** prospect -# To: **Relationship:** customer -\`\`\` - -### Log State Changes in Activity - -When applying a state change, also note it in the activity log: -\`\`\`markdown -- **2025-01-16** (email): Leadership approved pilot. [Status → active] Contract being drafted. -\`\`\` - -Use \`[Field → new value]\` notation to make state changes visible in the activity log. - -## 9d: Update Aliases - -If you discovered new name variants during resolution, add them to Aliases field: -\`\`\`markdown -# Before -**Aliases:** Sarah, S. Chen - -# Source used "Sarah C." (new variant) - -# After -**Aliases:** Sarah, S. Chen, Sarah C. -\`\`\` - -## 9e: 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 -- Note state changes with \`[Field → value]\` in activity -- Escape quotes properly in shell commands -- Write only one file per response (no multi-file write batches) - ---- - -# Step 10: Ensure Bidirectional Links - -After writing, verify links go both ways. - -## Absolute Link Format - -**IMPORTANT:** Always use absolute links with the folder path to avoid ambiguity: - -\`\`\`markdown -[[People/Sarah Chen]] -[[Organizations/Acme Corp]] -[[Projects/Acme Integration]] -[[Topics/Security Compliance]] -\`\`\` - -Format: \`[[Folder/Note Name]]\` - -This ensures: -- No ambiguity when names overlap across folders -- Clear navigation in any Obsidian-compatible tool -- Consistent linking throughout the vault - -## Check Each New Link - -If you added \`[[People/Jennifer]]\` to \`Organizations/Acme Corp.md\`: -\`\`\` -workspace-grep({ pattern: "Acme Corp", searchPath: "{knowledge_folder}/People/Jennifer.md" }) -\`\`\` - -If not found, update Jennifer.md to add the link. - -## Bidirectional Link Rules - -| If you add... | Then also add... | -|---------------|------------------| -| Person → Organization | Organization → Person (in People section) | -| Person → Project | Project → Person (in People section) | -| Project → Organization | Organization → Project (in Projects section) | -| Project → Topic | Topic → Project (in Related section) | -| Person → Person | Person → Person (reverse link) | - ---- - -# Note Templates - -## People -\`\`\`markdown -# {Full Name} - -## Info -**Role:** {role, or inferred role with qualifier, or leave blank if truly 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, what you're working on together. Focus on relationship and context, not communication method.} - -## Connected to -- [[Organizations/{Organization}]] — works at -- [[People/{Person}]] — {colleague, introduced by, reports to} -- [[Projects/{Project}]] — {role} - -## Activity -- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links} {[State changes if any]} - -## Key facts -{Substantive facts only. Leave empty if none. Never include data gap commentary.} - -## Open items -{Commitments and next steps only. Leave empty if none. Never include "find their email" type items.} -{Mark completed items with [x] and completion date.} -\`\`\` - -## 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:** {comma-separated: 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, what you're working on together.} - -## People -- [[People/{Person}]] — {role} - -## Contacts -{For transactional contacts who don't get their own notes} -- {Name} — {role}, {context} - -## Projects -- [[Projects/{Project}]] — {relationship} - -## Activity -- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links} {[State changes if any]} - -## 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}]] — {customer|partner|etc.} - -## Related -- [[Topics/{Topic}]] — {relationship} -- [[Projects/{Project}]] — {relationship} - -## Timeline -**{YYYY-MM-DD}** ({meeting|email}) -{What happened. Key points with [[Folder/Name]] links.} {[Status → new status] if changed} - -## Decisions -- **{YYYY-MM-DD}**: {Decision}. {Rationale}. - -## Open items -{Commitments and next steps only. Leave empty if none.} - -## Key facts -{Substantive facts only. Leave empty if none.} -\`\`\` - -## Topics -\`\`\`markdown -# {Topic Name} - -## About -{1-2 sentences: What this topic covers.} - -**Keywords:** {comma-separated} -**Aliases:** {other ways this topic is referenced} -**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 with [[Folder/Name]] links} - -## Decisions -- **{YYYY-MM-DD}**: {Decision} - -## Open items -{Commitments and next steps only. Leave empty if none.} - -## Key facts -{Substantive facts only. Leave empty if none.} -\`\`\` - ---- - -# Named Entity Resolution Reference - -## Quick Algorithm - -1. Extract all name variants from source -2. Search notes folder for each variant (including Aliases fields) -3. Read candidate notes, check org/role/email context -4. Disambiguate: org context > email match > role match > recency -5. Build resolution map -6. Apply source type rules (meetings create, emails only update) -7. Use canonical names in ALL output -8. Update Aliases with newly discovered variants - -## Common Patterns - -| Pattern | Resolution | -|---------|------------| -| First name + same org in context | Full name at that org | -| Email exact match | Definitive match | -| Email domain | Resolves to organization | -| "their CTO" + org context | Person with CTO role at org | -| "the pilot" + org context | Project involving that org | -| Name in Aliases field | Canonical name from that note | - -## Disambiguation Priority - -1. **Email match** — Definitive -2. **Organization context** — Strong signal -3. **Role match** — Good signal if org also matches -4. **Aliases field** — Explicit match -5. **Recency** — Weak signal, use as tiebreaker - -## Handling Failures - -| Situation | Source Type | Action | -|-----------|-------------|--------| -| No match + passes "Would I prep?" | Meeting | Create new note | -| No match + passes "Would I prep?" | Email | Do NOT create (skip) | -| No match + fails "Would I prep?" | Both | Mention in org note only | -| Multiple matches + can disambiguate | Both | Use disambiguation rules | -| Multiple matches + cannot disambiguate | Meeting | Create note with "(possibly same as [[X]])" | -| Multiple matches + cannot disambiguate | Email | Skip, don't update either | -| Conflicting information | Both | Note both versions, flag for review | - ---- - -# Examples - -## Example 1: Meeting — Creates Notes - -**source_file:** \`2025-01-15-meeting.md\` -\`\`\` -Meeting: Acme Integration Kickoff -Date: 2025-01-15 -Attendees: Sarah Chen (sarah@acme.com), David Kim (david@acme.com), Arj (arj@rowboat.com) - -Transcript: -Sarah: Thanks for meeting. We're excited about the pilot. -David: From a technical side, we need API access first. -Sarah: Our CTO Jennifer wants to join the next call. -... -\`\`\` - -### Step 0: Determine Source Type - -Has \`Meeting:\` and \`Attendees:\` → \`source_type = "meeting"\` → Can create notes - -### Step 1: Filter - -Not mass email, not automated. Continue. - -### Step 2: Parse - -- Date: 2025-01-15 -- Attendees: Sarah Chen, David Kim, Arj (self — exclude) -- Variants: "Sarah Chen", "sarah@acme.com", "David Kim", "David", "Jennifer", "CTO", "Acme", "the pilot" - -### Step 3: Search Existing Notes -\`\`\` -workspace-grep({ pattern: "Sarah Chen", searchPath: "knowledge" }) -# Output: (none) - -workspace-grep({ pattern: "acme", searchPath: "knowledge" }) -# Output: (none) -\`\`\` - -No existing notes. This is a new relationship. - -### Step 4: Resolve Entities - -**Resolution Map:** -\`\`\` -NEW ENTITIES (meeting — create): -- "Sarah Chen" → Create [[People/Sarah Chen]] -- "David Kim" → Create [[People/David Kim]] -- "Jennifer" (CTO) → Create [[People/Jennifer]] -- "Acme" → Create [[Organizations/Acme Corp]] -- "the pilot" → Create [[Projects/Acme Integration]] -\`\`\` - -### Step 5: Identify New Entities - -All attendees are external and pass "Would I prep?" test: -- Sarah Chen (key contact) → Create -- David Kim (technical contact) → Create -- Jennifer (CTO, mentioned) → Create -- Acme Corp (prospect company) → Create -- Acme Integration (project) → Create - -### Step 6: Extract Content - -- Decisions: None yet -- Commitments: Provide API access, schedule call with Jennifer -- Key facts: Excited about pilot, need API access first, CTO involved - -### Step 7: Detect State Changes - -No existing notes → No state changes to detect. - -### Steps 8-10: Check, Write, Link - -Create all notes with extracted content, ensure bidirectional links. - -**Example output for Sarah Chen:** -\`\`\`markdown -# Sarah Chen - -## Info -**Role:** Engineering (led technical discussion in kickoff meeting) -**Organization:** [[Organizations/Acme Corp]] -**Email:** sarah@acme.com -**Aliases:** Sarah, sarah@acme.com -**First met:** 2025-01-15 -**Last seen:** 2025-01-15 - -## Summary -Key contact at [[Organizations/Acme Corp]] for the [[Projects/Acme Integration]] pilot. -Leading the technical evaluation. Reports to [[People/Jennifer]] (CTO). - -## Connected to -- [[Organizations/Acme Corp]] — works at -- [[People/David Kim]] — colleague -- [[People/Jennifer]] — reports to (CTO) -- [[Projects/Acme Integration]] — key contact - -## Activity -- **2025-01-15** (meeting): Kickoff meeting for [[Projects/Acme Integration]]. Excited about pilot. [[People/David Kim]] needs API access first. [[People/Jennifer]] (CTO) joining next call. - -## Key facts -- Leading technical evaluation for pilot -- Needs API access to proceed -- CTO Jennifer involved in next steps - -## Open items -- [ ] Provide API access to [[People/David Kim]] -- [ ] Schedule follow-up call with [[People/Jennifer]] -\`\`\` - -**Example output for Acme Integration:** -\`\`\`markdown -# Acme Integration - -## Info -**Type:** deal -**Status:** planning -**Started:** 2025-01-15 -**Last activity:** 2025-01-15 - -## Summary -Pilot integration project with [[Organizations/Acme Corp]]. -Technical evaluation phase, working toward Q2 launch. - -## People -- [[People/Sarah Chen]] — key contact -- [[People/David Kim]] — technical lead -- [[People/Jennifer]] — CTO sponsor - -## Organizations -- [[Organizations/Acme Corp]] — prospect - -## Timeline -**2025-01-15** (meeting) -Kickoff meeting. Team excited about pilot. API access needed first. CTO [[People/Jennifer]] joining next call. - -## Open items -- [ ] Provide API access to [[People/David Kim]] -- [ ] Schedule follow-up call with [[People/Jennifer]] -\`\`\` - ---- - -## Example 2: Email with State Changes - -**source_file:** \`2025-01-20-email.md\` -\`\`\` -From: sarah@acme.com -To: arj@rowboat.com -Date: 2025-01-20 -Subject: Great news! - -Hi Arj, - -Great news — leadership approved the pilot! Legal is drafting the -contract now. We should be ready to kick off by end of month. - -Here's the API documentation you requested. - -Also, I've been promoted to VP of Engineering as of this month! - -Best, -Sarah Chen -VP Engineering, Acme Corp -\`\`\` - -### Step 0: Determine Source Type - -\`source_type = "email"\` → Can only update existing notes - -### Step 1: Filter - -Check for existing relationship: -\`\`\` -workspace-grep({ pattern: "sarah@acme.com", searchPath: "knowledge" }) -# Output: notes/People/Sarah Chen.md -\`\`\` - -Existing note found. Continue. - -### Steps 2-5: Parse, Search, Resolve, Skip - -**Resolution Map:** -\`\`\` -RESOLVED: -- "Sarah", "sarah@acme.com" → [[People/Sarah Chen]] -- "Acme" → [[Organizations/Acme Corp]] -\`\`\` - -### Step 6: Extract Content - -- Decision: Leadership approved pilot -- Commitment: Contract being drafted, kickoff by end of month -- Key fact: Legal involved, targeting end of month kickoff - -### Step 7: Detect State Changes - -**7a: Project Status:** -- "leadership approved the pilot" → Status: planning → active ✓ - -**7b: Open Item Resolution:** -- "Here's the API documentation you requested" -- Existing open item: \`- [ ] Send API documentation — by Friday\` -- Match found → Mark complete ✓ - -**7c: Role Change:** -- Signature: "VP Engineering" -- Existing: "Engineering" (inferred) -- Change detected → Update role ✓ - -**7d: Relationship Change:** -- "Legal is drafting the contract" → Still prospect (not signed yet) -- No change - -**State Change List:** -\`\`\` -STATE CHANGES: -- [[Projects/Acme Integration]]: Status planning → active -- [[People/Sarah Chen]]: Role "Engineering" → "VP Engineering" -- [[People/Sarah Chen]]: Open item "Provide API access" → completed (they sent docs) -\`\`\` - -### Steps 8-10: Check, Write, Link - -**Update Sarah Chen.md:** -\`\`\`markdown -# Sarah Chen - -## Info -**Role:** VP Engineering -**Organization:** [[Organizations/Acme Corp]] -**Email:** sarah@acme.com -**Aliases:** Sarah, sarah@acme.com -**First met:** 2025-01-15 -**Last seen:** 2025-01-20 - -## Summary -VP Engineering at [[Organizations/Acme Corp]] leading the [[Projects/Acme Integration]] pilot. -Key technical decision-maker. Recently promoted. - -## Connected to -- [[Organizations/Acme Corp]] — works at -- [[People/David Kim]] — colleague -- [[People/Jennifer]] — reports to (CTO) -- [[Projects/Acme Integration]] — key contact - -## Activity -- **2025-01-20** (email): Leadership approved pilot. [Status → active] Legal drafting contract. Kickoff by end of month. Sent API documentation. [Role → VP Engineering] -- **2025-01-15** (meeting): Kickoff meeting for [[Projects/Acme Integration]]. Excited about pilot. [[People/David Kim]] needs API access first. [[People/Jennifer]] (CTO) joining next call. - -## Key facts -- Leading technical evaluation for pilot -- Promoted to VP Engineering (Jan 2025) -- Legal drafting contract - -## Open items -- [x] Provide API access to [[People/David Kim]] — completed 2025-01-20 -- [ ] Schedule follow-up call with [[People/Jennifer]] -\`\`\` - -**Update Acme Integration.md:** -\`\`\`markdown -# Acme Integration - -## Info -**Type:** deal -**Status:** active -**Started:** 2025-01-15 -**Last activity:** 2025-01-20 - -## Summary -Pilot integration project with [[Organizations/Acme Corp]]. -Leadership approved, contract in progress. Targeting end of month kickoff. - -## Timeline -**2025-01-20** (email) -Leadership approved pilot. [Status → active] Legal drafting contract. Targeting end of month kickoff. - -**2025-01-15** (meeting) -Kickoff meeting. Team excited about pilot. API access needed first. CTO [[People/Jennifer]] joining next call. -\`\`\` - ---- - -## Example 3: Email — No Existing Relationship, Skip - -**source_file:** \`2025-01-16-email.md\` -\`\`\` -From: sales@randomvendor.com -To: arj@rowboat.com -Date: 2025-01-16 -Subject: Quick question about your data needs - -Hi, - -I noticed your company is growing fast. Would love to show you -how we can help with your data infrastructure... - -Best, -John Smith -\`\`\` - -### Step 0: Determine Source Type - -\`source_type = "email"\` - -### Step 1: Filter - -Check for existing relationship: -\`\`\` -workspace-grep({ pattern: "randomvendor", searchPath: "knowledge" }) -# Output: (none) - -workspace-grep({ pattern: "John Smith", searchPath: "knowledge" }) -# Output: (none) -\`\`\` - -No existing note. This is an email. Cannot create notes. - -**Output:** -\`\`\` -SKIP -Reason: No existing relationship (email from unknown contact) -\`\`\` - ---- - -## Example 4: Email — Warm Intro (Exception) - -**source_file:** \`2025-01-16-email.md\` -\`\`\` -From: david@friendly.vc -To: arj@rowboat.com -Cc: jennifer@newco.com -Date: 2025-01-16 -Subject: Intro: Jennifer Lee <> Arj - -Hey Arj, - -Want to introduce you to Jennifer Lee, CEO of NewCo. She's building -something interesting in your space and would love to chat. - -Jennifer — Arj is the founder of Rowboat, doing great work on AI agents. - -I'll let you two take it from here! - -David -\`\`\` - -### Step 0: Determine Source Type - -\`source_type = "email"\` - -### Step 1: Filter - -Check for sender: -\`\`\` -workspace-grep({ pattern: "david@friendly.vc", searchPath: "knowledge" }) -# Output: notes/People/David Park.md -\`\`\` - -Sender exists in memory. Check if this is a warm intro: -- Subject contains "Intro:" ✓ -- Body contains "introduce you to" ✓ -- New person (Jennifer Lee) is CC'd ✓ - -**This is a warm intro. Exception applies.** - -### Steps 2-4: Parse, Search, Resolve - -**Resolution Map:** -\`\`\` -RESOLVED: -- "David" → [[People/David Park]] (sender, exists) - -NEW ENTITIES (warm intro exception — create): -- "Jennifer Lee" → Create [[People/Jennifer Lee]] -- "NewCo" → Create [[Organizations/NewCo]] -\`\`\` - -### Step 5: Create Notes (Exception) - -Even though this is an email, create notes for the introduced person. - -### Step 7: Detect State Changes - -No existing notes for Jennifer Lee / NewCo → No state changes. - -### Output - -Creates 2 new notes ([[People/Jennifer Lee]], [[Organizations/NewCo]]). Updates [[People/David Park]] with activity. - ---- - -## Example 5: Meeting — Transactional, Minimal Notes - -**source_file:** \`2025-01-15-meeting.md\` -\`\`\` -Meeting: HSBC Account Setup -Date: 2025-01-15 -Attendees: James Wong (james@hsbc.com), Sarah Lee (sarah.lee@hsbc.com), Arj - -Transcript: -James: Let's go through the account setup process. -Sarah: I'll handle the wire transfer limits after. -... -\`\`\` - -### Step 0: Determine Source Type - -\`source_type = "meeting"\` → Can create notes - -### Step 5: Identify New Entities - -Apply "Would I prep?" test: -- James Wong (bank RM) → No -- Sarah Lee (support) → No -- HSBC (organization) → Yes, worth one org note - -**Action:** Create org note only, list people in Contacts section. - -### Output -\`\`\`markdown -# HSBC - -## Info -**Type:** company -**Industry:** Banking -**Relationship:** vendor (banking) -**Domain:** hsbc.com -**Aliases:** HSBC Bank -**First met:** 2025-01-15 -**Last seen:** 2025-01-15 - -## Summary -Business banking provider. Account setup completed January 2025. - -## People - -## Contacts -- James Wong — Relationship Manager, account setup -- Sarah Lee — Support, wire transfer limits - -## Activity -- **2025-01-15** (meeting): Account setup walkthrough. Wire transfer limits discussed. - -## Key facts -- Account Number: XXXX-1234 -- Daily wire limit: $50,000 - -## Open items -\`\`\` - ---- - -# Summary: The Core Rules - -| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? | -|-------------|---------------|----------------|------------------------| -| Meeting | Yes | Yes | Yes | -| Voice memo | Yes | Yes | Yes | -| Email (known contact) | No | Yes | Yes | -| Email (unknown contact) | No | No (SKIP) | No | -| Email (warm intro) | Yes (exception) | Yes | Yes | - -**Voice memo activity format:** Always include a link to the source voice memo: -\`\`\` -**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]] -\`\`\` - ---- - -# State Change Reference - -## What Changes Automatically - -| Field | Trigger | Example | -|-------|---------|---------| -| Project Status | "approved", "on hold", "cancelled", "completed" | planning → active | -| Open Items | "here's the X you requested", "sent the X" | [ ] → [x] | -| Person Role | New title in signature, "promoted to" | Engineer → VP | -| Org Relationship | "signed contract", "now a customer" | prospect → customer | -| Person Organization | "I've joined X", new email domain | Acme → NewCo | - -## How to Log State Changes - -In activity entries, use \`[Field → value]\` notation: -\`\`\`markdown -- **2025-01-20** (email): Leadership approved. [Status → active] Contract in progress. -\`\`\` - -## When NOT to Change State - -- Signal is ambiguous ("might move forward") -- Contradicts recent information (check activity log) -- Would be a regression (active → planning) -- Based on speculation, not explicit statement - ---- - -# Error Handling - -1. **Missing data:** Leave blank rather than writing "Unknown" -2. **Ambiguous names:** For meetings, create note with "(possibly same as [[X]])". For emails, skip. -3. **Conflicting info:** Note both versions, mark "needs clarification" -4. **grep returns nothing:** For meetings, apply qualifying rules and create if appropriate. For emails, skip. -5. **State change unclear:** Log in activity but don't change the field -6. **Note file malformed:** Log warning, attempt partial update, continue -7. **Shell command fails:** Log error, continue with what you have - ---- - -# Quality Checklist - -Before completing, verify: - -**Source Type:** -- [ ] Correctly identified as meeting or email -- [ ] Applied correct rules (meetings create, emails only update) - -**Resolution:** -- [ ] Extracted all name variants from source -- [ ] Searched notes including Aliases fields -- [ ] Built resolution map before writing -- [ ] Used absolute paths \`[[Folder/Name]]\` in ALL links -- [ ] Updated Aliases fields with new variants discovered - -**Filtering:** -- [ ] Excluded self (user.name, user.email, @user.domain) -- [ ] Applied "Would I prep?" test to each person -- [ ] Transactional contacts in Org Contacts, not People notes -- [ ] Source correctly classified (process vs skip) -- [ ] Emails from unknown contacts skipped (unless warm intro) - -**Content Quality:** -- [ ] Summaries describe relationship, not communication method -- [ ] Roles inferred where possible (with qualifier) -- [ ] Key facts are substantive (no "name only provided" filler) -- [ ] Open items are commitments/next steps (no "find their email" tasks) -- [ ] Empty sections left empty rather than filled with placeholders - -**State Changes:** -- [ ] Detected project status changes -- [ ] Marked completed open items with [x] -- [ ] Updated roles if changed -- [ ] Updated relationships if changed -- [ ] Logged all state changes in activity with [Field → value] notation -- [ ] Only applied clear, unambiguous state changes - -**Structure:** -- [ ] All entity mentions use \`[[Folder/Name]]\` absolute links -- [ ] Activity entries are reverse chronological -- [ ] No duplicate activity entries -- [ ] Dates are YYYY-MM-DD -- [ ] Bidirectional links are consistent -- [ ] New notes in correct folders -`; \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/note_creation_low.ts b/apps/x/packages/core/src/knowledge/note_creation_low.ts deleted file mode 100644 index 29922fce..00000000 --- a/apps/x/packages/core/src/knowledge/note_creation_low.ts +++ /dev/null @@ -1,874 +0,0 @@ -export const raw = `--- -model: gpt-5.2 -tools: - workspace-writeFile: - type: builtin - name: workspace-writeFile - workspace-readFile: - type: builtin - name: workspace-readFile - workspace-edit: - type: builtin - name: workspace-edit - workspace-readdir: - type: builtin - name: workspace-readdir - workspace-mkdir: - type: builtin - name: workspace-mkdir - workspace-grep: - type: builtin - name: workspace-grep - workspace-glob: - type: builtin - name: workspace-glob ---- -# Task - -You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), 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. Meetings, voice memos, 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 these tools: - -**For reading files:** -\`\`\` -workspace-readFile({ path: "knowledge/People/Sarah Chen.md" }) -\`\`\` - -**For creating NEW files:** -\`\`\` -workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." }) -\`\`\` - -**For editing EXISTING files (preferred for updates):** -\`\`\` -workspace-edit({ - path: "knowledge/People/Sarah Chen.md", - oldString: "## Activity\\n", - newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n" -}) -\`\`\` - -**For listing directories:** -\`\`\` -workspace-readdir({ path: "knowledge/People" }) -\`\`\` - -**For creating directories:** -\`\`\` -workspace-mkdir({ path: "knowledge/Projects", recursive: true }) -\`\`\` - -**For searching files:** -\`\`\` -workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" }) -\`\`\` - -**For finding files by pattern:** -\`\`\` -workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" }) -\`\`\` - -**IMPORTANT:** -- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields) -- Use \`workspace-writeFile\` only for creating new notes -- Prefer the knowledge_index for entity resolution (it's faster than grep) - -# 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. -\`\`\` -workspace-readFile({ path: "{source_file}" }) -\`\`\` - -**Meeting indicators:** -- Has \`Attendees:\` field -- Has \`Meeting:\` title -- Transcript format with speaker labels - -**Email indicators:** -- Has \`From:\` and \`To:\` fields -- Has \`Subject:\` field -- Email signature - -**Voice memo indicators:** -- Has \`**Type:** voice memo\` field -- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\` -- Has \`## Transcript\` section - -**Set processing mode:** -- \`source_type = "meeting"\` → Create notes for all external attendees -- \`source_type = "email"\` → Create notes for sender if identifiable human -- \`source_type = "voice_memo"\` → Create notes for all mentioned entities (treat like meetings) - ---- - -## Calendar Invite Emails - -Emails containing calendar invites (\`.ics\` attachments) are **high signal** - a scheduled meeting means this person matters. - -**How to identify:** -- Subject contains "Invitation:", "Accepted:", "Declined:", or "Updated:" -- Has \`.ics\` attachment reference - -**Rules:** -1. **CREATE a note for the primary contact** - the person you're meeting with -2. **Skip automated notifications** - from calendar-no-reply@google.com with no human sender -3. **Skip "Accepted/Declined" responses** - just RSVP confirmations - -Once a note exists, subsequent emails will enrich it. When the meeting happens, the transcript adds more detail. - ---- - -# Step 1: Source Filtering (Minimal) - -## Skip Only These Sources - -### Mass Newsletters - -**Indicators (must have MULTIPLE of these):** -- Unsubscribe link in body or footer -- From a marketing address (noreply@, newsletter@, marketing@) -- Sent to multiple recipients or undisclosed-recipients -- Sent via marketing platforms (via sendgrid, via mailchimp, etc.) - -**Action:** SKIP with reason "Mass newsletter" - -### Purely Automated (No Human Sender) - -**Indicators:** -- From automated systems with no human behind them (alerts@, notifications@) -- Password resets, login alerts -- System notifications (GitHub automated, CI/CD alerts) -- Receipt confirmations with no human contact info - -**Action:** SKIP with reason "Automated system message" - -### Truly Low-Signal - -**Indicators (must be clearly content-free):** -- Body is ONLY "Thanks!", "Got it", "OK" with nothing else -- Auto-replies ("I'm out of office") with no human context - -**Action:** SKIP with reason "No substantive content" - -## Process Everything Else - -**Important:** When in doubt, PROCESS. In low strictness mode, we err on the side of capturing more. - -If skipping: -\`\`\` -SKIP -Reason: {reason} -\`\`\` - -If processing, continue to Step 2. - ---- - -# Step 2: Read and Parse Source File -\`\`\` -workspace-readFile({ path: "{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): -\`\`\` -workspace-readFile({ path: "{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|voice memo}): {Summary with [[links]]} -\`\`\` - -**For voice memos:** Include a link to the voice memo file using the Path field: -\`\`\` -**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]] -\`\`\` - ---- - -# 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 - -**IMPORTANT: Write sequentially, one file at a time.** -- Generate content for exactly one note. -- Issue exactly one write/edit command. -- Wait for the tool to return before generating the next note. -- Do NOT batch multiple write commands in a single response. - -**For NEW entities (use workspace-writeFile):** -\`\`\` -workspace-writeFile({ - path: "{knowledge_folder}/People/Jennifer.md", - data: "# Jennifer\\n\\n## Summary\\n..." -}) -\`\`\` - -**For EXISTING entities (use workspace-edit):** -- Read current content first with workspace-readFile -- Use workspace-edit to add activity entry at TOP (reverse chronological) -- Update fields using targeted edits -\`\`\` -workspace-edit({ - path: "{knowledge_folder}/People/Sarah Chen.md", - oldString: "## Activity\\n", - newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n" -}) -\`\`\` - -## 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 -- Write only one file per response (no multi-file write batches) - ---- - -# 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|voice memo}): {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|voice memo}): {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|voice memo}) -{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 | -| Voice memo | Yes — all mentioned entities | Yes | Yes | -| Email (any human sender) | Yes | Yes | Yes | -| Email (automated/newsletter) | No (SKIP) | No | No | - -**Voice memo activity format:** Always include a link to the source voice memo: -\`\`\` -**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]] -\`\`\` - -**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 -`; \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/note_system.ts b/apps/x/packages/core/src/knowledge/note_system.ts new file mode 100644 index 00000000..210d3501 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/note_system.ts @@ -0,0 +1,202 @@ +import path from "path"; +import fs from "fs"; +import { WorkDir } from "../config/config.js"; + +export interface NoteTypeDefinition { + type: string; + folder: string; + template: string; + extractionGuide: string; +} + +// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ────────── + +const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [ + { + type: "People", + folder: "People", + template: `# {Full Name} + +## Info +**Role:** {role, or inferred role with qualifier, or leave blank if truly 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, what you're working on together.} + +## Connected to +- [[Organizations/{Organization}]] — works at +- [[People/{Person}]] — {colleague, introduced by, reports to} +- [[Projects/{Project}]] — {role} + +## Activity +- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {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.}`, + extractionGuide: + "Look for: name, role, organization, email, aliases, relationship context", + }, + { + type: "Organizations", + folder: "Organizations", + template: `# {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:** {comma-separated: 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 transactional contacts who don't get their own notes} + +## Projects +- [[Projects/{Project}]] — {relationship} + +## Activity +- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {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.}`, + extractionGuide: + "Look for: organization name, type, industry, relationship, domain, key people, projects", + }, + { + type: "Projects", + folder: "Projects", + template: `# {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}]] — {customer|partner|etc.} + +## Related +- [[Topics/{Topic}]] — {relationship} +- [[Projects/{Project}]] — {relationship} + +## Timeline +**{YYYY-MM-DD}** ({meeting|email}) +{What happened.} + +## Decisions +- **{YYYY-MM-DD}**: {Decision}. {Rationale}. + +## Open items +{Commitments and next steps only. Leave empty if none.} + +## Key facts +{Substantive facts only. Leave empty if none.}`, + extractionGuide: + "Look for: project name, type, status, people involved, organizations, timeline, decisions", + }, + { + type: "Topics", + folder: "Topics", + template: `# {Topic Name} + +## About +{1-2 sentences: What this topic covers.} + +**Keywords:** {comma-separated} +**Aliases:** {other ways this topic is referenced} +**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 with [[Folder/Name]] links} + +## Decisions +- **{YYYY-MM-DD}**: {Decision} + +## Open items +{Commitments and next steps only. Leave empty if none.} + +## Key facts +{Substantive facts only. Leave empty if none.}`, + extractionGuide: + "Look for: topic name, keywords, related people/orgs/projects, decisions, key facts", + }, +]; + +// ── Disk-backed config with mtime caching ────────────────────────────────── + +export const NOTES_CONFIG_PATH = path.join(WorkDir, "config", "notes.json"); + +let cachedNoteTypeDefinitions: NoteTypeDefinition[] | null = null; +let cachedMtimeMs: number | null = null; + +function ensureNotesConfigSync(): void { + if (!fs.existsSync(NOTES_CONFIG_PATH)) { + fs.writeFileSync( + NOTES_CONFIG_PATH, + JSON.stringify(DEFAULT_NOTE_TYPE_DEFINITIONS, null, 2) + "\n", + "utf8", + ); + } +} + +export function getNoteTypeDefinitions(): NoteTypeDefinition[] { + ensureNotesConfigSync(); + try { + const stats = fs.statSync(NOTES_CONFIG_PATH); + if (cachedNoteTypeDefinitions && cachedMtimeMs === stats.mtimeMs) { + return cachedNoteTypeDefinitions; + } + const content = fs.readFileSync(NOTES_CONFIG_PATH, "utf8"); + cachedNoteTypeDefinitions = JSON.parse(content); + cachedMtimeMs = stats.mtimeMs; + return cachedNoteTypeDefinitions!; + } catch { + cachedNoteTypeDefinitions = null; + cachedMtimeMs = null; + return DEFAULT_NOTE_TYPE_DEFINITIONS; + } +} + +// ── Render helper ──────────────────────────────────────────────────────── + +export function renderNoteTypesBlock(): string { + const defs = getNoteTypeDefinitions(); + const sections = defs.map( + (d) => + `## ${d.type}\n\`\`\`markdown\n${d.template}\n\`\`\``, + ); + return `# Note Templates\n\n${sections.join("\n\n")}`; +} diff --git a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts new file mode 100644 index 00000000..94cd5016 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts @@ -0,0 +1,132 @@ +import { renderTagSystemForNotes } from './tag_system.js'; + +export function getRaw(): string { + return `--- +model: gpt-5.2 +tools: + workspace-readFile: + type: builtin + name: workspace-readFile + workspace-edit: + type: builtin + name: workspace-edit + workspace-readdir: + type: builtin + name: workspace-readdir +--- +# Task + +You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics), you will classify each note and prepend YAML frontmatter with categorized tags and Info attributes. + +# Instructions + +1. For each note file provided in the message, read its content carefully. +2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/). +3. Classify the note using the Rowboat Tag System (Note Tags section) appended below. +4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). +5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line. +6. If the note already has frontmatter (starts with \`---\`), skip it. + +# Frontmatter Format + +Tags are organized by **category** (not a flat list). Each tag category is a top-level YAML key. Use a plain string for single values, or a YAML list for multiple values. + +Info attributes from the \`## Info\` section are also included as top-level keys. + +\`\`\`yaml +--- +relationship: customer +relationship_sub: primary +topic: + - sales + - fundraising +source: email +status: active +action: action-required +role: VP Engineering +organization: Acme Corp +email: sarah@acme.com +first_met: "2024-06-15" +last_seen: "2025-01-20" +--- +\`\`\` + +## Tag category keys + +Use these exact keys for each tag category: + +| Category | Key | Single or multi | Example | +|----------|-----|-----------------|---------| +| Relationship | \`relationship\` | single | \`relationship: customer\` | +| Relationship sub | \`relationship_sub\` | single or multi | \`relationship_sub: primary\` | +| Topic | \`topic\` | single or multi | \`topic: sales\` or list | +| Email type | \`email_type\` | single or multi | \`email_type: followup\` | +| Action | \`action\` | single or multi | \`action: action-required\` | +| Status | \`status\` | single | \`status: active\` | +| Source | \`source\` | single or multi | \`source: email\` or list | + +**Rules:** +- Use a plain string when there's only one value: \`topic: sales\` +- Use a YAML list when there are multiple values: + \`\`\`yaml + topic: + - sales + - fundraising + \`\`\` +- **Omit a category entirely** if no tags apply for it. Do not include empty keys. +- Only use tag values from the Rowboat Tag System — do not invent new tags. + +# Info Attribute Extraction Rules + +Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) section into YAML frontmatter keys: + +1. **Convert keys to snake_case**: e.g. \`**First met:**\` → \`first_met\`, \`**Last activity:**\` → \`last_activity\`, \`**Last seen:**\` → \`last_seen\`. +2. **Strip wiki-link syntax**: \`[[Organizations/Acme Corp]]\` → \`Acme Corp\`. Extract just the display name (last path segment). +3. **Skip blank/placeholder values**: If a field says "leave blank", is empty, or contains only template placeholders like \`{role}\`, omit it from the frontmatter. +4. **Quote dates**: Wrap date values in quotes, e.g. \`first_met: "2024-06-15"\`. +5. **Aliases as list**: If the value is comma-separated (like Aliases), store as a YAML list: + \`\`\`yaml + aliases: + - Sarah + - sarah@acme.com + \`\`\` + +**Per note type, extract these fields:** + +- **People**: role, organization, email, aliases, first_met, last_seen +- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen +- **Projects**: type, status, started, last_activity +- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned + +Note: For Organizations, the Info \`**Relationship:**\` field is separate from the \`relationship\` tag category. Include both — the Info field as \`info_relationship\` and the tag as \`relationship\`. + +# Tag Selection Rules + +1. **Always include at least one relationship or topic tag** — every note must be classifiable. +2. **Always include a source tag** — \`email\` or \`meeting\` based on what the note's Activity section shows. +3. **Default status is \`active\`** for all new tags. +4. **For People notes**, include: + - One primary relationship tag (e.g. \`customer\`, \`investor\`, \`prospect\`) + - Relationship sub-tags if applicable (e.g. \`primary\`, \`champion\`, \`former\`) + - Topic tags based on what you're working on together + - Source tags based on the Activity section + - Action tags if there are open items +5. **For Organization notes**, include: + - One primary relationship tag + - Topic tags based on the relationship context + - Source tags +6. **For Project notes**, include: + - Topic tags based on project type + - Source tags + - Action tags if there are open items +7. **For Topic notes**, include: + - The relevant topic tag + - Source tags +8. **Only use tags from the Rowboat Tag System** — do not invent new tags. +9. Process all files in the batch. Do not skip any unless they already have frontmatter. + +--- + +${renderTagSystemForNotes()} +`; +} diff --git a/apps/x/packages/core/src/knowledge/note_tagging_state.ts b/apps/x/packages/core/src/knowledge/note_tagging_state.ts new file mode 100644 index 00000000..ecfff8ea --- /dev/null +++ b/apps/x/packages/core/src/knowledge/note_tagging_state.ts @@ -0,0 +1,48 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; + +const STATE_FILE = path.join(WorkDir, 'note_tagging_state.json'); + +export interface NoteTaggingState { + processedFiles: Record; + lastRunTime: string; +} + +export function loadNoteTaggingState(): NoteTaggingState { + if (fs.existsSync(STATE_FILE)) { + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + } catch (error) { + console.error('Error loading note tagging state:', error); + } + } + + return { + processedFiles: {}, + lastRunTime: new Date(0).toISOString(), + }; +} + +export function saveNoteTaggingState(state: NoteTaggingState): void { + try { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + } catch (error) { + console.error('Error saving note tagging state:', error); + throw error; + } +} + +export function markNoteAsTagged(filePath: string, state: NoteTaggingState): void { + state.processedFiles[filePath] = { + taggedAt: new Date().toISOString(), + }; +} + +export function resetNoteTaggingState(): void { + const emptyState: NoteTaggingState = { + processedFiles: {}, + lastRunTime: new Date().toISOString(), + }; + saveNoteTaggingState(emptyState); +} diff --git a/apps/x/packages/core/src/knowledge/tag_notes.ts b/apps/x/packages/core/src/knowledge/tag_notes.ts new file mode 100644 index 00000000..95934b03 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/tag_notes.ts @@ -0,0 +1,274 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import { createRun, createMessage } from '../runs/runs.js'; +import { bus } from '../runs/bus.js'; +import { serviceLogger } from '../services/service_logger.js'; +import { limitEventItems } from './limit_event_items.js'; +import { + loadNoteTaggingState, + saveNoteTaggingState, + markNoteAsTagged, + type NoteTaggingState, +} from './note_tagging_state.js'; +import { getNoteTypeDefinitions } from './note_system.js'; + +const SYNC_INTERVAL_MS = 30 * 1000; // 30 seconds +const BATCH_SIZE = 15; +const NOTE_TAGGING_AGENT = 'note_tagging_agent'; +const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); +const MAX_CONTENT_LENGTH = 8000; + +/** + * Find knowledge notes that haven't been tagged yet + */ +function getUntaggedNotes(state: NoteTaggingState): string[] { + if (!fs.existsSync(KNOWLEDGE_DIR)) { + return []; + } + + const untagged: string[] = []; + const noteFolders = getNoteTypeDefinitions().map(d => d.folder); + + for (const folder of noteFolders) { + const folderPath = path.join(KNOWLEDGE_DIR, folder); + if (!fs.existsSync(folderPath)) { + continue; + } + + const entries = fs.readdirSync(folderPath); + for (const entry of entries) { + const fullPath = path.join(folderPath, entry); + const stat = fs.statSync(fullPath); + + if (!stat.isFile() || !entry.endsWith('.md')) { + continue; + } + + // Skip if already tracked in state + if (state.processedFiles[fullPath]) { + continue; + } + + // Skip if file already has frontmatter + try { + const content = fs.readFileSync(fullPath, 'utf-8'); + if (content.startsWith('---')) { + continue; + } + } catch { + continue; + } + + untagged.push(fullPath); + } + } + + return untagged; +} + +/** + * Wait for a run to complete by listening for run-processing-end event + */ +async function waitForRunCompletion(runId: string): Promise { + return new Promise(async (resolve) => { + const unsubscribe = await bus.subscribe('*', async (event) => { + if (event.type === 'run-processing-end' && event.runId === runId) { + unsubscribe(); + resolve(); + } + }); + }); +} + +/** + * Tag a batch of note files using the tagging agent + */ +async function tagNoteBatch( + files: { path: string; content: string }[] +): Promise<{ runId: string; filesEdited: Set }> { + const run = await createRun({ + agentId: NOTE_TAGGING_AGENT, + }); + + let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`; + message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const relativePath = path.relative(WorkDir, file.path); + const truncated = file.content.length > MAX_CONTENT_LENGTH + ? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]' + : file.content; + + message += `## File ${i + 1}: ${relativePath}\n\n`; + message += truncated; + message += `\n\n---\n\n`; + } + + const filesEdited = new Set(); + + const unsubscribe = await bus.subscribe(run.id, async (event) => { + if (event.type !== 'tool-invocation') { + return; + } + if (event.toolName !== 'workspace-edit') { + return; + } + try { + const parsed = JSON.parse(event.input) as { path?: string }; + if (typeof parsed.path === 'string') { + filesEdited.add(parsed.path); + } + } catch { + // ignore parse errors + } + }); + + await createMessage(run.id, message); + await waitForRunCompletion(run.id); + unsubscribe(); + + return { runId: run.id, filesEdited }; +} + +/** + * Process all untagged notes in batches + */ +async function processUntaggedNotes(): Promise { + console.log('[NoteTagging] Checking for untagged notes...'); + + const state = loadNoteTaggingState(); + const untagged = getUntaggedNotes(state); + + if (untagged.length === 0) { + console.log('[NoteTagging] No untagged notes found'); + return; + } + + console.log(`[NoteTagging] Found ${untagged.length} untagged notes`); + + const run = await serviceLogger.startRun({ + service: 'note_tagging', + message: `Tagging ${untagged.length} note${untagged.length === 1 ? '' : 's'}`, + trigger: 'timer', + }); + + const relativeFiles = untagged.map(f => path.relative(WorkDir, f)); + const limitedFiles = limitEventItems(relativeFiles); + await serviceLogger.log({ + type: 'changes_identified', + service: run.service, + runId: run.runId, + level: 'info', + message: `Found ${untagged.length} untagged note${untagged.length === 1 ? '' : 's'}`, + counts: { notes: untagged.length }, + items: limitedFiles.items, + truncated: limitedFiles.truncated, + }); + + const totalBatches = Math.ceil(untagged.length / BATCH_SIZE); + let totalEdited = 0; + let hadError = false; + + for (let i = 0; i < untagged.length; i += BATCH_SIZE) { + const batchPaths = untagged.slice(i, i + BATCH_SIZE); + const batchNumber = Math.floor(i / BATCH_SIZE) + 1; + + try { + const files: { path: string; content: string }[] = []; + for (const filePath of batchPaths) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + files.push({ path: filePath, content }); + } catch (error) { + console.error(`[NoteTagging] Error reading ${filePath}:`, error); + } + } + + if (files.length === 0) { + continue; + } + + console.log(`[NoteTagging] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`); + await serviceLogger.log({ + type: 'progress', + service: run.service, + runId: run.runId, + level: 'info', + message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`, + step: 'batch', + current: batchNumber, + total: totalBatches, + details: { filesInBatch: files.length }, + }); + + const result = await tagNoteBatch(files); + totalEdited += result.filesEdited.size; + + // Only mark files that were actually edited by the agent + for (const file of files) { + const relativePath = path.relative(WorkDir, file.path); + if (result.filesEdited.has(relativePath)) { + markNoteAsTagged(file.path, state); + } + } + + saveNoteTaggingState(state); + console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`); + } catch (error) { + hadError = true; + console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error); + await serviceLogger.log({ + type: 'error', + service: run.service, + runId: run.runId, + level: 'error', + message: `Error processing batch ${batchNumber}`, + error: error instanceof Error ? error.message : String(error), + context: { batchNumber }, + }); + } + } + + state.lastRunTime = new Date().toISOString(); + saveNoteTaggingState(state); + + await serviceLogger.log({ + type: 'run_complete', + service: run.service, + runId: run.runId, + level: hadError ? 'error' : 'info', + message: `Note tagging complete: ${totalEdited} notes tagged`, + durationMs: Date.now() - run.startedAt, + outcome: hadError ? 'error' : 'ok', + summary: { + totalNotes: untagged.length, + notesTagged: totalEdited, + }, + }); + + console.log(`[NoteTagging] Done. ${totalEdited} notes tagged.`); +} + +/** + * Main entry point - runs as independent polling service + */ +export async function init() { + console.log('[NoteTagging] Starting Note Tagging Service...'); + console.log(`[NoteTagging] Will check for untagged notes every ${SYNC_INTERVAL_MS / 1000} seconds`); + + // Initial run + await processUntaggedNotes(); + + // Periodic polling + while (true) { + await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); + + try { + await processUntaggedNotes(); + } catch (error) { + console.error('[NoteTagging] Error in main loop:', error); + } + } +} diff --git a/apps/x/packages/core/src/knowledge/tag_system.ts b/apps/x/packages/core/src/knowledge/tag_system.ts new file mode 100644 index 00000000..01ac4c0e --- /dev/null +++ b/apps/x/packages/core/src/knowledge/tag_system.ts @@ -0,0 +1,197 @@ +import path from "path"; +import fs from "fs"; +import { WorkDir } from "../config/config.js"; + +export type TagApplicability = 'email' | 'notes' | 'both'; + +export type TagType = + | 'relationship' + | 'relationship-sub' + | 'topic' + | 'email-type' + | 'filter' + | 'action' + | 'status' + | 'source'; + +export interface TagDefinition { + tag: string; + type: TagType; + applicability: TagApplicability; + description: string; + example?: string; +} + +// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ────────── + +const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [ + // ── Relationship (both) ────────────────────────────────────────────── + { tag: 'investor', type: 'relationship', applicability: 'both', description: 'Investors, VCs, or angels', example: 'Following up on our meeting — we\'d like to move forward with the Series A term sheet.' }, + { tag: 'customer', type: 'relationship', applicability: 'both', description: 'Paying customers', example: 'We\'re seeing great results with Rowboat. Can we discuss expanding to more teams?' }, + { tag: 'prospect', type: 'relationship', applicability: 'both', description: 'Potential customers', example: 'Thanks for the demo yesterday. We\'re interested in starting a pilot.' }, + { tag: 'partner', type: 'relationship', applicability: 'both', description: 'Business partners', example: 'Let\'s discuss how we can promote the integration to both our user bases.' }, + { tag: 'vendor', type: 'relationship', applicability: 'both', description: 'Service providers you work with', example: 'Here are the updated employment agreements you requested.' }, + { tag: 'product', type: 'relationship', applicability: 'both', description: 'Products or services you use (automated)', example: 'Your AWS bill for January 2025 is now available.' }, + { tag: 'candidate', type: 'relationship', applicability: 'both', description: 'Job applicants', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' }, + { tag: 'team', type: 'relationship', applicability: 'both', description: 'Internal team members', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' }, + { tag: 'advisor', type: 'relationship', applicability: 'both', description: 'Advisors, mentors, or board members', example: 'I\'ve reviewed the deck. Here are my thoughts on the GTM strategy.' }, + { tag: 'personal', type: 'relationship', applicability: 'both', description: 'Family or friends', example: 'Are you coming to Thanksgiving this year? Let me know your travel dates.' }, + { tag: 'press', type: 'relationship', applicability: 'both', description: 'Journalists or media', example: 'I\'m writing a piece on AI agents. Would you be available for an interview?' }, + { tag: 'community', type: 'relationship', applicability: 'both', description: 'Users, peers, or open source contributors', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' }, + { tag: 'government', type: 'relationship', applicability: 'both', description: 'Government agencies', example: 'Your Delaware franchise tax is due by March 1, 2025.' }, + + // ── Relationship Sub-Tags (notes only) ─────────────────────────────── + { tag: 'primary', type: 'relationship-sub', applicability: 'notes', description: 'Main contact or decision maker', example: 'Sarah Chen — VP Engineering, your main point of contact at Acme.' }, + { tag: 'secondary', type: 'relationship-sub', applicability: 'notes', description: 'Supporting contact, involved but not the lead', example: 'David Kim — Engineer CC\'d on customer emails.' }, + { tag: 'executive-assistant', type: 'relationship-sub', applicability: 'notes', description: 'EA or admin handling scheduling and logistics', example: 'Lisa — Sarah\'s EA who schedules all her meetings.' }, + { tag: 'cc', type: 'relationship-sub', applicability: 'notes', description: 'Person who\'s CC\'d but not actively engaged', example: 'Manager looped in for visibility on deal.' }, + { tag: 'referred-by', type: 'relationship-sub', applicability: 'notes', description: 'Person who made an introduction or referral', example: 'David Park — Investor who intro\'d you to Sarah.' }, + { tag: 'former', type: 'relationship-sub', applicability: 'notes', description: 'Previously held this relationship, no longer active', example: 'John — Former customer who churned last year.' }, + { tag: 'champion', type: 'relationship-sub', applicability: 'notes', description: 'Internal advocate pushing for you', example: 'Engineer who loves your product and is selling internally.' }, + { tag: 'blocker', type: 'relationship-sub', applicability: 'notes', description: 'Person opposing or blocking progress', example: 'CFO resistant to spending on new tools.' }, + + // ── Topic (both) ───────────────────────────────────────────────────── + { tag: 'sales', type: 'topic', applicability: 'both', description: 'Sales conversations, deals, and revenue', example: 'Here\'s the pricing proposal we discussed. Let me know if you have questions.' }, + { tag: 'support', type: 'topic', applicability: 'both', description: 'Help requests, issues, and customer support', example: 'We\'re seeing an error when trying to export. Can you help?' }, + { tag: 'legal', type: 'topic', applicability: 'both', description: 'Contracts, terms, compliance, and legal matters', example: 'Legal has reviewed the MSA. Attached are our requested changes.' }, + { tag: 'finance', type: 'topic', applicability: 'both', description: 'Money, invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' }, + { tag: 'hiring', type: 'topic', applicability: 'both', description: 'Recruiting, interviews, and employment', example: 'We\'d like to move forward with a final round interview. Are you available Thursday?' }, + { tag: 'fundraising', type: 'topic', applicability: 'both', description: 'Raising money and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' }, + { tag: 'travel', type: 'topic', applicability: 'both', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' }, + { tag: 'event', type: 'topic', applicability: 'both', description: 'Conferences, meetups, and gatherings', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' }, + { tag: 'shopping', type: 'topic', applicability: 'both', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' }, + { tag: 'health', type: 'topic', applicability: 'both', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' }, + { tag: 'learning', type: 'topic', applicability: 'both', description: 'Courses, education, and skill-building', example: 'Welcome to the Advanced Python course. Here\'s your access link.' }, + { tag: 'research', type: 'topic', applicability: 'both', description: 'Research requests and information gathering', example: 'Here\'s the market analysis you requested on the AI agent space.' }, + + // ── Email Type ─────────────────────────────────────────────────────── + { tag: 'intro', type: 'email-type', applicability: 'both', description: 'Warm introduction from someone you know', example: 'I\'d like to introduce you to Sarah Chen, VP Engineering at Acme.' }, + { tag: 'followup', type: 'email-type', applicability: 'both', description: 'Following up on a previous conversation', example: 'Following up on our call last week. Have you had a chance to review the proposal?' }, + { tag: 'scheduling', type: 'email-type', applicability: 'email', description: 'Meeting and calendar scheduling', example: 'Are you available for a call next Tuesday at 2pm?' }, + { tag: 'cold-outreach', type: 'email-type', applicability: 'email', description: 'Unsolicited contact from someone you don\'t know', example: 'Hi, I noticed your company is growing fast. I\'d love to show you how we can help with...' }, + { tag: 'newsletter', type: 'email-type', applicability: 'email', description: 'Newsletters, marketing emails, and subscriptions', example: 'This week in AI: The latest developments in agent frameworks...' }, + { tag: 'notification', type: 'email-type', applicability: 'email', description: 'Automated alerts, receipts, and system notifications', example: 'Your password was changed successfully. If this wasn\'t you, contact support.' }, + + // ── Filter (email only) ────────────────────────────────────────────── + { tag: 'spam', type: 'filter', applicability: 'email', description: 'Junk and unwanted email', example: 'Congratulations! You\'ve won $1,000,000...' }, + { tag: 'promotion', type: 'filter', applicability: 'email', description: 'Marketing offers and sales pitches', example: '50% off all items this weekend only!' }, + { tag: 'social', type: 'filter', applicability: 'email', description: 'Social media notifications', example: 'John Smith commented on your post.' }, + { tag: 'forums', type: 'filter', applicability: 'email', description: 'Mailing lists and group discussions', example: 'Re: [dev-list] Question about API design' }, + + // ── Action ─────────────────────────────────────────────────────────── + { tag: 'action-required', type: 'action', applicability: 'both', description: 'Needs a response or action from you', example: 'Can you send me the pricing by Friday?' }, + { tag: 'fyi', type: 'action', applicability: 'email', description: 'Informational only, no action needed', example: 'Just wanted to let you know the deal closed. Thanks for your help!' }, + { tag: 'urgent', type: 'action', applicability: 'both', description: 'Time-sensitive, needs immediate attention', example: 'We need your signature on the contract by EOD today or we lose the deal.' }, + { tag: 'waiting', type: 'action', applicability: 'both', description: 'Waiting on a response from them' }, + + // ── Status (email) ─────────────────────────────────────────────────── + { tag: 'unread', type: 'status', applicability: 'email', description: 'Not yet processed' }, + { tag: 'to-reply', type: 'status', applicability: 'email', description: 'Need to respond' }, + { tag: 'done', type: 'status', applicability: 'email', description: 'Handled, can be archived' }, + + // ── Source (notes only) ────────────────────────────────────────────── + { tag: 'email', type: 'source', applicability: 'notes', description: 'Created or updated from email' }, + { tag: 'meeting', type: 'source', applicability: 'notes', description: 'Created or updated from meeting transcript' }, + { tag: 'browser', type: 'source', applicability: 'notes', description: 'Content captured from web browsing' }, + { tag: 'web-search', type: 'source', applicability: 'notes', description: 'Information from web search' }, + { tag: 'manual', type: 'source', applicability: 'notes', description: 'Manually entered by user' }, + { tag: 'import', type: 'source', applicability: 'notes', description: 'Imported from another system' }, + + // ── Status (notes) ────────────────────────────────────────────────── + { tag: 'active', type: 'status', applicability: 'notes', description: 'Currently relevant, recent activity' }, + { tag: 'archived', type: 'status', applicability: 'notes', description: 'No longer active, kept for reference' }, + { tag: 'stale', type: 'status', applicability: 'notes', description: 'No activity in 60+ days, needs attention or archive' }, +]; + +// ── Disk-backed config with mtime caching ────────────────────────────────── + +export const TAGS_CONFIG_PATH = path.join(WorkDir, "config", "tags.json"); + +let cachedTagDefinitions: TagDefinition[] | null = null; +let cachedMtimeMs: number | null = null; + +function ensureTagsConfigSync(): void { + if (!fs.existsSync(TAGS_CONFIG_PATH)) { + fs.writeFileSync( + TAGS_CONFIG_PATH, + JSON.stringify(DEFAULT_TAG_DEFINITIONS, null, 2) + "\n", + "utf8", + ); + } +} + +export function getTagDefinitions(): TagDefinition[] { + ensureTagsConfigSync(); + try { + const stats = fs.statSync(TAGS_CONFIG_PATH); + if (cachedTagDefinitions && cachedMtimeMs === stats.mtimeMs) { + return cachedTagDefinitions; + } + const content = fs.readFileSync(TAGS_CONFIG_PATH, "utf8"); + cachedTagDefinitions = JSON.parse(content); + cachedMtimeMs = stats.mtimeMs; + return cachedTagDefinitions!; + } catch { + cachedTagDefinitions = null; + cachedMtimeMs = null; + return DEFAULT_TAG_DEFINITIONS; + } +} + +// ── Render helpers ─────────────────────────────────────────────────────── + +const TYPE_ORDER: TagType[] = [ + 'relationship', 'relationship-sub', 'topic', 'email-type', + 'filter', 'action', 'status', 'source', +]; + +const TYPE_LABELS: Record = { + 'relationship': 'Relationship', + 'relationship-sub': 'Relationship Sub-Tags', + 'topic': 'Topic', + 'email-type': 'Email Type', + 'filter': 'Filter', + 'action': 'Action', + 'status': 'Status', + 'source': 'Source', +}; + +function renderTagGroups(tags: TagDefinition[]): string { + const groups = new Map(); + for (const tag of tags) { + const list = groups.get(tag.type) ?? []; + list.push(tag); + groups.set(tag.type, list); + } + + const sections: string[] = []; + for (const type of TYPE_ORDER) { + const group = groups.get(type); + if (!group || group.length === 0) continue; + + const label = TYPE_LABELS[type]; + const rows = group.map(t => { + const example = t.example ?? ''; + return `| ${t.tag} | ${t.description} | ${example} |`; + }); + + sections.push( + `## ${label}\n\n` + + `| Tag | Description | Example |\n` + + `|-----|-------------|---------|\n` + + rows.join('\n'), + ); + } + + return `# Tag System Reference\n\n${sections.join('\n\n')}`; +} + +export function renderTagSystemForNotes(): string { + const tags = getTagDefinitions().filter(t => t.applicability !== 'email'); + return renderTagGroups(tags); +} + +export function renderTagSystemForEmails(): string { + const tags = getTagDefinitions().filter(t => t.applicability !== 'notes'); + return renderTagGroups(tags); +} diff --git a/apps/x/packages/shared/src/service-events.ts b/apps/x/packages/shared/src/service-events.ts index d214472c..807bc063 100644 --- a/apps/x/packages/shared/src/service-events.ts +++ b/apps/x/packages/shared/src/service-events.ts @@ -7,6 +7,8 @@ export const ServiceName = z.enum([ 'fireflies', 'granola', 'voice_memo', + 'email_labeling', + 'note_tagging', ]); const ServiceEventBase = z.object({ From 5aba6025dcb582ee4382cfcad3f0c0a0b7fd389f Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:22:03 +0530 Subject: [PATCH 10/13] bases --- apps/x/apps/renderer/src/App.tsx | 236 ++++- .../renderer/src/components/bases-view.tsx | 914 ++++++++++++++---- .../src/components/frontmatter-properties.tsx | 252 +++++ .../src/components/markdown-editor.tsx | 16 +- .../src/components/sidebar-content.tsx | 3 + .../renderer/src/components/tag-pills.tsx | 17 - apps/x/apps/renderer/src/lib/frontmatter.ts | 203 ++++ apps/x/apps/renderer/src/styles/editor.css | 196 +++- 8 files changed, 1547 insertions(+), 290 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/frontmatter-properties.tsx delete mode 100644 apps/x/apps/renderer/src/components/tag-pills.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fe2f5dd1..1a0cd396 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -12,6 +12,7 @@ import { ChatSidebar } from './components/chat-sidebar'; import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; +import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; @@ -46,7 +47,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' -import { splitFrontmatter, joinFrontmatter, extractTags } from '@/lib/frontmatter' +import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { OnboardingModal } from '@/components/onboarding-modal' import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' @@ -106,6 +107,7 @@ const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 const TITLEBAR_BUTTONS_COLLAPSED = 5 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4 const GRAPH_TAB_PATH = '__rowboat_graph_view__' +const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -233,6 +235,7 @@ const getAncestorDirectoryPaths = (path: string): string[] => { } const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH +const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null @@ -470,6 +473,7 @@ function App() { const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) + const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [], @@ -510,9 +514,8 @@ function App() { const initialContentRef = useRef('') const renameInProgressRef = useRef(false) - // Frontmatter state: store raw frontmatter per file path, tags for active file + // Frontmatter state: store raw frontmatter per file path const frontmatterByPathRef = useRef>(new Map()) - const [activeFileTags, setActiveFileTags] = useState([]) // Version history state const [versionHistoryPath, setVersionHistoryPath] = useState(null) @@ -621,6 +624,8 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' + if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' + if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path }, []) @@ -818,20 +823,45 @@ function App() { } }, [runId, processingRunIds]) - // Load directory tree + // Load directory tree (knowledge + bases) const loadDirectory = useCallback(async () => { try { - const result = await window.ipc.invoke('workspace:readdir', { - path: 'knowledge', - opts: { recursive: true, includeHidden: false } - }) - return buildTree(result) + const [knowledgeResult, basesResult] = await Promise.all([ + window.ipc.invoke('workspace:readdir', { + path: 'knowledge', + opts: { recursive: true, includeHidden: false, includeStats: true } + }), + window.ipc.invoke('workspace:readdir', { + path: 'bases', + opts: { recursive: false, includeHidden: false, includeStats: true } + }).catch(() => [] as DirEntry[]), + ]) + const knowledgeTree = buildTree(knowledgeResult) + const basesChildren: TreeNode[] = (basesResult as DirEntry[]) + .filter((e) => e.name.endsWith('.base')) + .map((e) => ({ ...e, kind: 'file' as const })) + if (basesChildren.length > 0) { + const basesFolder: TreeNode = { + name: 'Bases', + path: 'bases', + kind: 'dir', + children: basesChildren, + } + return [...knowledgeTree, basesFolder] + } + return knowledgeTree } catch (err) { console.error('Failed to load directory:', err) return [] } }, []) + // Ensure bases/ directory exists on startup + useEffect(() => { + window.ipc.invoke('workspace:mkdir', { path: 'bases', recursive: true }) + .catch((err: unknown) => console.error('Failed to ensure bases directory:', err)) + }, []) + // Load initial tree useEffect(() => { loadDirectory().then(setTree) @@ -905,7 +935,6 @@ function App() { editorPathRef.current = pathToReload initialContentByPathRef.current.set(pathToReload, body) initialContentRef.current = body - setActiveFileTags(extractTags(fm)) } } }) @@ -923,6 +952,31 @@ function App() { setLastSaved(null) return } + if (selectedPath === BASES_DEFAULT_TAB_PATH) { + // Virtual default base — no file to load, use DEFAULT_BASE_CONFIG + if (!baseConfigByPath[selectedPath]) { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + return + } + if (selectedPath.endsWith('.base')) { + // Load base config from file only if not already cached + if (!baseConfigByPath[selectedPath]) { + window.ipc.invoke('workspace:readFile', { path: selectedPath, encoding: 'utf8' }) + .then((result: { data: string }) => { + try { + const parsed = JSON.parse(result.data) as BaseConfig + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: parsed })) + } catch { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + } + }) + .catch(() => { + setBaseConfigByPath((prev) => ({ ...prev, [selectedPath]: { ...DEFAULT_BASE_CONFIG } })) + }) + } + return + } if (selectedPath.endsWith('.md')) { const cachedContent = editorContentByPathRef.current.get(selectedPath) const hasBaseline = initialContentByPathRef.current.has(selectedPath) @@ -934,7 +988,6 @@ function App() { editorContentRef.current = cachedContent editorPathRef.current = selectedPath initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent - setActiveFileTags(extractTags(frontmatterByPathRef.current.get(selectedPath) ?? null)) return } } @@ -943,42 +996,43 @@ function App() { let cancelled = false ;(async () => { try { - const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) - if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - if (stat.kind === 'file') { - const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + // For .md files (from the knowledge tree), skip stat and read directly. + // For other file types, stat first to check if it's a file vs directory. + const isKnownFile = pathToLoad.endsWith('.md') + if (!isKnownFile) { + const stat = await window.ipc.invoke('workspace:stat', { path: pathToLoad }) if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return - setFileContent(result.data) - const { raw: fm, body } = splitFrontmatter(result.data) - frontmatterByPathRef.current.set(pathToLoad, fm) - const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() - const isSameEditorFile = editorPathRef.current === pathToLoad - const knownBaseline = initialContentByPathRef.current.get(pathToLoad) - const hasKnownBaseline = knownBaseline !== undefined - const hasUnsavedEdits = - hasKnownBaseline - && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline) - const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits - if (!shouldPreserveActiveDraft) { - setEditorContent(body) - if (pathToLoad.endsWith('.md')) { - setEditorCacheForPath(pathToLoad, body) - } - editorContentRef.current = body - editorPathRef.current = pathToLoad - initialContentByPathRef.current.set(pathToLoad, body) - initialContentRef.current = body - setLastSaved(null) - setActiveFileTags(extractTags(fm)) - } else { - // Still update the editor's path so subsequent autosaves write to the correct file. - editorPathRef.current = pathToLoad + if (stat.kind !== 'file') { + setFileContent('') + setEditorContent('') + editorContentRef.current = '' + initialContentRef.current = '' + return } + } + const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) + if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return + setFileContent(result.data) + const { raw: fm, body } = splitFrontmatter(result.data) + frontmatterByPathRef.current.set(pathToLoad, fm) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + const isSameEditorFile = editorPathRef.current === pathToLoad + const wouldClobberActiveEdits = + isSameEditorFile + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(body) + if (!wouldClobberActiveEdits) { + setEditorContent(body) + if (pathToLoad.endsWith('.md')) { + setEditorCacheForPath(pathToLoad, body) + } + editorContentRef.current = body + editorPathRef.current = pathToLoad + initialContentByPathRef.current.set(pathToLoad, body) + initialContentRef.current = body + setLastSaved(null) } else { - setFileContent('') - setEditorContent('') - editorContentRef.current = '' - initialContentRef.current = '' + // Still update the editor's path so subsequent autosaves write to the correct file. + editorPathRef.current = pathToLoad } } catch (err) { console.error('Failed to load file:', err) @@ -2177,7 +2231,7 @@ function App() { const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -2186,13 +2240,20 @@ function App() { editorPathRef.current = null } } + if (closingTab && isBaseFilePath(closingTab.path)) { + setBaseConfigByPath((prev) => { + const next = { ...prev } + delete next[closingTab.path] + return next + }) + } setFileTabs(prev => { if (prev.length <= 1) { // Last file tab - close it and go back to chat setActiveFileTabId(null) setSelectedPath(null) setIsGraphOpen(false) - return [] + return [] } const idx = prev.findIndex(t => t.id === tabId) if (idx === -1) return prev @@ -2206,7 +2267,7 @@ function App() { setIsGraphOpen(true) } else { setIsGraphOpen(false) - setSelectedPath(newActiveTab.path) + setSelectedPath(newActiveTab.path) } } return next @@ -2314,7 +2375,7 @@ function App() { if (activeFileTabId) { const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) - if (activeTab && !isGraphTabPath(activeTab.path)) { + if (activeTab && !isGraphTabPath(activeTab.path) && !isBaseFilePath(activeTab.path)) { setFileTabs((prev) => prev.map((tab) => ( tab.id === activeFileTabId ? { ...tab, path } : tab ))) @@ -2459,6 +2520,46 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) + const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { + setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) + }, []) + + const handleBaseSave = useCallback(async (name: string | null) => { + if (!selectedPath) return + const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH + const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG + + if (isDefault && name) { + // Save as new base file + const safeName = name.replace(/[\\/]/g, '-').trim() + const newPath = `bases/${safeName}.base` + const fileConfig = { ...config, name: safeName } + try { + await window.ipc.invoke('workspace:writeFile', { + path: newPath, + data: JSON.stringify(fileConfig, null, 2), + }) + setBaseConfigByPath((prev) => ({ ...prev, [newPath]: fileConfig })) + // Refresh tree then navigate to the new file + const newTree = await loadDirectory() + setTree(newTree) + void navigateToView({ type: 'file', path: newPath }) + } catch (err) { + console.error('Failed to save base:', err) + } + } else if (!isDefault) { + // Save in place + try { + await window.ipc.invoke('workspace:writeFile', { + path: selectedPath, + data: JSON.stringify(config, null, 2), + }) + } catch (err) { + console.error('Failed to save base:', err) + } + } + }, [selectedPath, baseConfigByPath, loadDirectory, navigateToView]) + const navigateToFullScreenChat = useCallback(() => { // Only treat this as navigation when coming from another view if (currentViewState.type !== 'chat') { @@ -2771,6 +2872,13 @@ function App() { } void navigateToView({ type: 'graph' }) }, + openBases: () => { + if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) { + setIsChatSidebarOpen(false) + setIsRightPaneMaximized(false) + } + void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) + }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), rename: async (oldPath: string, newName: string, isDir: boolean) => { @@ -3270,7 +3378,7 @@ function App() { getTabId={(t) => t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && isGraphOpen} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( )} - {selectedPath && ( + {selectedPath && selectedPath.endsWith('.md') && (
{isSaving ? ( <> @@ -3372,7 +3480,18 @@ function App() { )} - {isGraphOpen ? ( + {selectedPath && isBaseFilePath(selectedPath) ? ( +
+ navigateToFile(path)} + config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG} + onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)} + isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH} + onSave={(name) => void handleBaseSave(name)} + /> +
+ ) : isGraphOpen ? (
{ + frontmatterByPathRef.current.set(tab.path, newRaw) + // Write updated frontmatter to disk immediately + const currentBody = editorContentRef.current + const fullContent = joinFrontmatter(newRaw, currentBody) + initialContentByPathRef.current.set(tab.path, splitFrontmatter(fullContent).body) + initialContentRef.current = splitFrontmatter(fullContent).body + void window.ipc.invoke('workspace:writeFile', { + path: tab.path, + data: fullContent, + opts: { encoding: 'utf8' }, + }) + }} onHistoryHandlersChange={(handlers) => { if (handlers) { fileHistoryHandlersRef.current.set(tab.id, handlers) diff --git a/apps/x/apps/renderer/src/components/bases-view.tsx b/apps/x/apps/renderer/src/components/bases-view.tsx index 7403435e..83fc07c0 100644 --- a/apps/x/apps/renderer/src/components/bases-view.tsx +++ b/apps/x/apps/renderer/src/components/bases-view.tsx @@ -1,9 +1,20 @@ import * as React from 'react' import { useEffect, useState, useMemo, useCallback, useRef } from 'react' -import { ArrowDown, ArrowUp, X } from 'lucide-react' +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react' import { Badge } from '@/components/ui/badge' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { cn } from '@/lib/utils' -import { splitFrontmatter, extractTags } from '@/lib/frontmatter' +import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter' +import { useDebounce } from '@/hooks/use-debounce' interface TreeNode { path: string @@ -17,34 +28,77 @@ type NoteEntry = { path: string name: string folder: string - tags: string[] + fields: Record mtimeMs: number } -type SortField = 'name' | 'folder' | 'mtimeMs' type SortDir = 'asc' | 'desc' +type ActiveFilter = { category: string; value: string } + +export type BaseConfig = { + name: string + visibleColumns: string[] + columnWidths: Record + sort: { field: string; dir: SortDir } + filters: ActiveFilter[] +} + +export const DEFAULT_BASE_CONFIG: BaseConfig = { + name: 'All Notes', + visibleColumns: ['name', 'folder', 'relationship', 'topic', 'status', 'mtimeMs'], + columnWidths: {}, + sort: { field: 'mtimeMs', dir: 'desc' }, + filters: [], +} + +const PAGE_SIZE = 25 + +/** Built-in columns that don't come from frontmatter */ +const BUILTIN_COLUMNS = ['name', 'folder', 'mtimeMs'] as const +type BuiltinColumn = (typeof BUILTIN_COLUMNS)[number] + +const BUILTIN_LABELS: Record = { + name: 'Name', + folder: 'Folder', + mtimeMs: 'Last Modified', +} + +/** Default pixel widths for columns */ +const DEFAULT_WIDTHS: Record = { + name: 200, + folder: 140, + mtimeMs: 140, +} +const DEFAULT_FRONTMATTER_WIDTH = 150 + +/** Convert key to title case: `first_met` → `First Met` */ +function toTitleCase(key: string): string { + if (key in BUILTIN_LABELS) return BUILTIN_LABELS[key as BuiltinColumn] + return key + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} type BasesViewProps = { tree: TreeNode[] onSelectNote: (path: string) => void + config: BaseConfig + onConfigChange: (config: BaseConfig) => void + isDefaultBase: boolean + onSave: (name: string | null) => void } -function collectFilePaths(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] { +function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] { return nodes.flatMap((n) => n.kind === 'file' && n.name.endsWith('.md') ? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }] : n.children - ? collectFilePaths(n.children) + ? collectFiles(n.children) : [], ) } -/** Build a stable fingerprint from the tree's file paths + mtimes so we only reload when files actually change. */ -function treeFingerprint(nodes: TreeNode[]): string { - const files = collectFilePaths(nodes) - return files.map((f) => `${f.path}:${f.mtimeMs}`).join('\n') -} - function getFolder(path: string): string { const parts = path.split('/') if (parts.length >= 3) return parts[1] @@ -57,247 +111,557 @@ function formatDate(ms: number): string { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) } -export function BasesView({ tree, onSelectNote }: BasesViewProps) { - const [notes, setNotes] = useState([]) - const [initialLoading, setInitialLoading] = useState(true) - const [selectedTags, setSelectedTags] = useState>(new Set()) - const [sortField, setSortField] = useState('mtimeMs') - const [sortDir, setSortDir] = useState('desc') - const lastFingerprintRef = useRef('') +function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean { + return a.category === b.category && a.value === b.value +} - // Stable fingerprint — only changes when actual file paths/mtimes differ - const fingerprint = useMemo(() => treeFingerprint(tree), [tree]) +function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean { + return filters.some((x) => filtersEqual(x, f)) +} - // Load notes data when fingerprint changes +/** Get the string values for a column from a note */ +function getColumnValues(note: NoteEntry, column: string): string[] { + if (column === 'name') return [note.name] + if (column === 'folder') return [note.folder] + if (column === 'mtimeMs') return [] + const v = note.fields[column] + if (!v) return [] + return Array.isArray(v) ? v : [v] +} + +/** Get a single sortable string for a column */ +function getSortValue(note: NoteEntry, column: string): string | number { + if (column === 'name') return note.name + if (column === 'folder') return note.folder + if (column === 'mtimeMs') return note.mtimeMs + const v = note.fields[column] + if (!v) return '' + return Array.isArray(v) ? v[0] ?? '' : v +} + +const isBuiltin = (col: string): col is BuiltinColumn => + (BUILTIN_COLUMNS as readonly string[]).includes(col) + +export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave }: BasesViewProps) { + // Build notes instantly from tree + const notes = useMemo(() => { + return collectFiles(tree).map((f) => ({ + path: f.path, + name: f.name, + folder: getFolder(f.path), + fields: {}, + mtimeMs: f.mtimeMs, + })) + }, [tree]) + + // Frontmatter fields loaded async, keyed by path + const [fieldsByPath, setFieldsByPath] = useState>>(new Map()) + const loadGenRef = useRef(0) + + // Load frontmatter in background batches useEffect(() => { - if (fingerprint === lastFingerprintRef.current) return - lastFingerprintRef.current = fingerprint - + const gen = ++loadGenRef.current let cancelled = false - const files = collectFilePaths(tree) + const paths = notes.map((n) => n.path) - async function loadNotes() { - const entries: NoteEntry[] = [] - - for (const file of files) { - try { - const result = await window.ipc.invoke('workspace:readFile', { - path: file.path, - encoding: 'utf8', - }) - const { raw } = splitFrontmatter(result.data) - const tags = extractTags(raw) - entries.push({ - path: file.path, - name: file.name, - folder: getFolder(file.path), - tags, - mtimeMs: file.mtimeMs, - }) - } catch { - entries.push({ - path: file.path, - name: file.name, - folder: getFolder(file.path), - tags: [], - mtimeMs: file.mtimeMs, - }) - } - } - - if (!cancelled) { - setNotes(entries) - setInitialLoading(false) + async function load() { + const BATCH = 30 + for (let i = 0; i < paths.length; i += BATCH) { + if (cancelled) return + const batch = paths.slice(i, i + BATCH) + const results = await Promise.all( + batch.map(async (p) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' }) + const { raw } = splitFrontmatter(result.data) + return { path: p, fields: extractAllFrontmatterValues(raw) } + } catch { + return { path: p, fields: {} as Record } + } + }), + ) + if (cancelled || gen !== loadGenRef.current) return + setFieldsByPath((prev) => { + const next = new Map(prev) + for (const r of results) next.set(r.path, r.fields) + return next + }) } } - loadNotes() + load() return () => { cancelled = true } - }, [fingerprint, tree]) - - // Collect all unique tags - const allTags = useMemo(() => { - const tagSet = new Set() - for (const note of notes) { - for (const tag of note.tags) { - tagSet.add(tag) - } - } - return [...tagSet].sort((a, b) => a.localeCompare(b)) }, [notes]) - // Filter and sort - const filteredNotes = useMemo(() => { - let result = notes - if (selectedTags.size > 0) { - const tagsArray = [...selectedTags] - result = result.filter((note) => - tagsArray.every((tag) => note.tags.includes(tag)), - ) + // Merge tree-derived notes with async-loaded fields + const enrichedNotes = useMemo(() => { + if (fieldsByPath.size === 0) return notes + return notes.map((n) => { + const f = fieldsByPath.get(n.path) + return f ? { ...n, fields: f } : n + }) + }, [notes, fieldsByPath]) + + // Collect all unique frontmatter property keys across all notes + const allPropertyKeys = useMemo(() => { + const keys = new Set() + for (const fields of fieldsByPath.values()) { + for (const k of Object.keys(fields)) keys.add(k) } - result = [...result].sort((a, b) => { - let cmp = 0 - if (sortField === 'name') { - cmp = a.name.localeCompare(b.name) - } else if (sortField === 'folder') { - cmp = a.folder.localeCompare(b.folder) + return Array.from(keys).sort() + }, [fieldsByPath]) + + // Filterable categories: "folder" + all frontmatter keys + const filterCategories = useMemo(() => { + return ['folder', ...allPropertyKeys] + }, [allPropertyKeys]) + + // All unique values per category, across all enriched notes + const valuesByCategory = useMemo>(() => { + const result: Record> = {} + for (const cat of filterCategories) result[cat] = new Set() + for (const note of enrichedNotes) { + for (const cat of filterCategories) { + for (const v of getColumnValues(note, cat)) { + if (v) result[cat]?.add(v) + } + } + } + const out: Record = {} + for (const [cat, set] of Object.entries(result)) { + out[cat] = Array.from(set).sort((a, b) => a.localeCompare(b)) + } + return out + }, [filterCategories, enrichedNotes]) + + const visibleColumns = config.visibleColumns + const columnWidths = config.columnWidths + const filters = config.filters + const sortField = config.sort.field + const sortDir = config.sort.dir + const [page, setPage] = useState(0) + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const saveInputRef = useRef(null) + const [filterCategory, setFilterCategory] = useState(null) + + const handleSaveClick = useCallback(() => { + if (isDefaultBase) { + setSaveName('') + setSaveDialogOpen(true) + } else { + onSave(null) + } + }, [isDefaultBase, onSave]) + + const handleSaveConfirm = useCallback(() => { + const name = saveName.trim() + if (!name) return + setSaveDialogOpen(false) + onSave(name) + }, [saveName, onSave]) + + const getColWidth = useCallback((col: string) => { + return columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + }, [columnWidths]) + + // Column resize via drag + const resizingRef = useRef<{ col: string; startX: number; startW: number } | null>(null) + + const configRef = useRef(config) + configRef.current = config + + const onResizeStart = useCallback((col: string, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startW = configRef.current.columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH + resizingRef.current = { col, startX, startW } + + const onMouseMove = (ev: MouseEvent) => { + if (!resizingRef.current) return + const delta = ev.clientX - resizingRef.current.startX + const newW = Math.max(60, resizingRef.current.startW + delta) + const c = configRef.current + const updated = { ...c, columnWidths: { ...c.columnWidths, [resizingRef.current!.col]: newW } } + onConfigChange(updated) + } + + const onMouseUp = () => { + resizingRef.current = null + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, [onConfigChange]) + + // Search + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const debouncedSearch = useDebounce(searchQuery, 250) + const [searchMatchPaths, setSearchMatchPaths] = useState | null>(null) + const searchInputRef = useRef(null) + + useEffect(() => { + if (!debouncedSearch.trim()) { + setSearchMatchPaths(null) + return + } + let cancelled = false + window.ipc.invoke('search:query', { query: debouncedSearch, limit: 200, types: ['knowledge'] }) + .then((res: { results: { path: string }[] }) => { + if (!cancelled) { + setSearchMatchPaths(new Set(res.results.map((r) => r.path))) + } + }) + .catch(() => { + if (!cancelled) setSearchMatchPaths(new Set()) + }) + return () => { cancelled = true } + }, [debouncedSearch]) + + const toggleSearch = useCallback(() => { + setSearchOpen((prev) => { + if (prev) { + setSearchQuery('') + setSearchMatchPaths(null) + } + return !prev + }) + }, []) + + // Focus input when search opens + useEffect(() => { + if (searchOpen) searchInputRef.current?.focus() + }, [searchOpen]) + + // Reset page when filters or search change + useEffect(() => { setPage(0) }, [filters, searchMatchPaths]) + + // Filter (search + badge filters) + const filteredNotes = useMemo(() => { + let result = enrichedNotes + // Apply search filter + if (searchMatchPaths) { + result = result.filter((note) => searchMatchPaths.has(note.path)) + } + // Apply badge filters + if (filters.length > 0) { + const byCategory = new Map() + for (const f of filters) { + const vals = byCategory.get(f.category) ?? [] + vals.push(f.value) + byCategory.set(f.category, vals) + } + result = result.filter((note) => { + for (const [category, requiredValues] of byCategory) { + const noteValues = getColumnValues(note, category) + if (!requiredValues.some((v) => noteValues.includes(v))) return false + } + return true + }) + } + return result + }, [enrichedNotes, filters, searchMatchPaths]) + + // Sort + const sortedNotes = useMemo(() => { + return [...filteredNotes].sort((a, b) => { + const va = getSortValue(a, sortField) + const vb = getSortValue(b, sortField) + let cmp: number + if (typeof va === 'number' && typeof vb === 'number') { + cmp = va - vb } else { - cmp = a.mtimeMs - b.mtimeMs + cmp = String(va).localeCompare(String(vb)) } return sortDir === 'asc' ? cmp : -cmp }) - return result - }, [notes, selectedTags, sortField, sortDir]) + }, [filteredNotes, sortField, sortDir]) - const toggleTag = useCallback((tag: string) => { - setSelectedTags((prev) => { - const next = new Set(prev) - if (next.has(tag)) { - next.delete(tag) - } else { - next.add(tag) - } - return next - }) - }, []) + // Paginate + const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE)) + const clampedPage = Math.min(page, totalPages - 1) + const pageNotes = useMemo( + () => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE), + [sortedNotes, clampedPage], + ) + + const toggleFilter = useCallback((category: string, value: string) => { + const c = configRef.current + const f: ActiveFilter = { category, value } + const next = hasFilter(c.filters, f) + ? c.filters.filter((x) => !filtersEqual(x, f)) + : [...c.filters, f] + onConfigChange({ ...c, filters: next }) + }, [onConfigChange]) const clearFilters = useCallback(() => { - setSelectedTags(new Set()) - }, []) + onConfigChange({ ...configRef.current, filters: [] }) + }, [onConfigChange]) - const handleSort = useCallback((field: SortField) => { - setSortField((prev) => { - if (prev === field) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) - return prev - } - setSortDir(field === 'mtimeMs' ? 'desc' : 'asc') - return field - }) - }, []) + const handleSort = useCallback((field: string) => { + const c = configRef.current + if (field === c.sort.field) { + onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } }) + } else { + onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } }) + } + }, [onConfigChange]) - const SortIcon = ({ field }: { field: SortField }) => { + const toggleColumn = useCallback((key: string) => { + const c = configRef.current + const next = c.visibleColumns.includes(key) + ? c.visibleColumns.filter((col) => col !== key) + : [...c.visibleColumns, key] + onConfigChange({ ...c, visibleColumns: next }) + }, [onConfigChange]) + + const SortIcon = ({ field }: { field: string }) => { if (sortField !== field) return null - return sortDir === 'asc' ? ( - - ) : ( - - ) - } - - if (initialLoading) { - return ( -
-
- {Array.from({ length: 8 }).map((_, i) => ( -
- ))} -
-
- ) + return sortDir === 'asc' + ? + : } return (
- {/* Filter bar */} -
-
- - Showing {filteredNotes.length} of {notes.length} notes - - {selectedTags.size > 0 && ( + {/* Toolbar */} +
+ + + + + + + + + No properties found. + + {BUILTIN_COLUMNS.map((col) => ( + toggleColumn(col)}> + + {BUILTIN_LABELS[col]} + + ))} + + + {allPropertyKeys.map((key) => ( + toggleColumn(key)}> + + {toTitleCase(key)} + + ))} + + + + + + + { if (!open) setFilterCategory(null) }}> + + + + +
+ {/* Left: categories */} +
+
+ Attributes + {filters.length > 0 && ( + + )} +
+ {filterCategories.map((cat) => { + const activeCount = filters.filter((f) => f.category === cat).length + const isSelected = filterCategory === cat + return ( + + ) + })} +
+ {/* Right: values for selected category */} + {filterCategory && ( +
+ + + + No values found. + + {(valuesByCategory[filterCategory] ?? []).map((val) => { + const active = hasFilter(filters, { category: filterCategory, value: val }) + return ( + toggleFilter(filterCategory, val)}> + + {val} + + ) + })} + + + +
+ )} +
+
+
+ + + + {searchOpen && ( +
+ setSearchQuery(e.target.value)} + placeholder="Search notes..." + className="flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + {searchQuery && ( + + {searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'} + + )} - )} -
- {allTags.map((tag) => ( +
+ )} + +
+ + +
+ + {/* Filter bar */} + {filters.length > 0 && ( +
+
+ + {sortedNotes.length} of {enrichedNotes.length} notes + + {filters.map((f) => ( ))} +
-
+ )} {/* Table */}
- - +
+ + {visibleColumns.map((col) => ( + + ))} + + - - - - + {visibleColumns.map((col) => ( + + ))} - {filteredNotes.map((note) => ( + {pageNotes.map((note) => ( onSelectNote(note.path)} > - - - - + {visibleColumns.map((col) => ( + + ))} ))} - {filteredNotes.length === 0 && ( + {pageNotes.length === 0 && ( - @@ -305,6 +669,152 @@ export function BasesView({ tree, onSelectNote }: BasesViewProps) {
handleSort('name')} - > - Name - - handleSort('folder')} - > - Folder - - - Tags - handleSort('mtimeMs')} - > - Last Modified - - handleSort(col)} + > + {toTitleCase(col)} + {/* Resize handle */} +
onResizeStart(col, e)} + onClick={(e) => e.stopPropagation()} + /> +
{note.name}{note.folder} -
- {note.tags.map((tag) => ( - { - e.stopPropagation() - toggleTag(tag) - }} - > - {tag} - - ))} -
-
- {formatDate(note.mtimeMs)} - + +
+ No notes found
+ + {/* Pagination */} +
+ + {sortedNotes.length === 0 + ? '0 notes' + : `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`} + + {totalPages > 1 && ( +
+ + + Page {clampedPage + 1} of {totalPages} + + +
+ )} +
+ + {/* Save As dialog */} + + + + Save Base + Choose a name for this base view. + + setSaveName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveConfirm() }} + placeholder="e.g. Contacts, Projects..." + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring" + autoFocus + /> + + + + + +
) } + +/** Renders a single table cell based on the column type */ +function CellRenderer({ + note, + column, + filters, + toggleFilter, +}: { + note: NoteEntry + column: string + filters: ActiveFilter[] + toggleFilter: (category: string, value: string) => void +}) { + if (column === 'name') { + return {note.name} + } + if (column === 'folder') { + return {note.folder} + } + if (column === 'mtimeMs') { + return {formatDate(note.mtimeMs)} + } + + // Frontmatter column + const value = note.fields[column] + if (!value) return null + + if (Array.isArray(value)) { + return ( +
+ {value.map((v) => ( + + ))} +
+ ) + } + + // Single string value — render as badge for filterability + return ( + + ) +} + +function CategoryBadge({ + category, + value, + active, + onClick, +}: { + category: string + value: string + active: boolean + onClick: (category: string, value: string) => void +}) { + return ( + { + e.stopPropagation() + onClick(category, value) + }} + > + {value} + + ) +} diff --git a/apps/x/apps/renderer/src/components/frontmatter-properties.tsx b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx new file mode 100644 index 00000000..280d45f1 --- /dev/null +++ b/apps/x/apps/renderer/src/components/frontmatter-properties.tsx @@ -0,0 +1,252 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { ChevronRight, X, Plus } from 'lucide-react' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' + +interface FrontmatterPropertiesProps { + raw: string | null + onRawChange: (raw: string | null) => void + editable?: boolean +} + +type FieldEntry = { key: string; value: string | string[] } + +function fieldsFromRaw(raw: string | null): FieldEntry[] { + const record = extractAllFrontmatterValues(raw) + return Object.entries(record).map(([key, value]) => ({ key, value })) +} + +function fieldsToRaw(fields: FieldEntry[]): string | null { + const record: Record = {} + for (const { key, value } of fields) { + if (key.trim()) record[key.trim()] = value + } + return buildFrontmatter(record) +} + +export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) { + const [expanded, setExpanded] = useState(false) + const [fields, setFields] = useState(() => fieldsFromRaw(raw)) + const [editingNewKey, setEditingNewKey] = useState(false) + const newKeyRef = useRef(null) + const lastCommittedRaw = useRef(raw) + + // Sync local fields when raw changes externally (e.g. tab switch) + useEffect(() => { + if (raw !== lastCommittedRaw.current) { + setFields(fieldsFromRaw(raw)) + lastCommittedRaw.current = raw + } + }, [raw]) + + useEffect(() => { + if (editingNewKey && newKeyRef.current) { + newKeyRef.current.focus() + } + }, [editingNewKey]) + + const commit = useCallback((updated: FieldEntry[]) => { + const newRaw = fieldsToRaw(updated) + lastCommittedRaw.current = newRaw + onRawChange(newRaw) + }, [onRawChange]) + + // For scalar fields: update local state immediately, commit on blur + const updateLocalValue = useCallback((index: number, newValue: string) => { + setFields(prev => { + const next = [...prev] + next[index] = { ...next[index], value: newValue } + return next + }) + }, []) + + const commitField = useCallback((index: number) => { + setFields(prev => { + commit(prev) + return prev + }) + }, [commit]) + + // For array fields and structural changes: update + commit immediately + const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => { + setFields(prev => { + const next = updater(prev) + commit(next) + return next + }) + }, [commit]) + + const removeField = useCallback((index: number) => { + updateAndCommit(prev => prev.filter((_, i) => i !== index)) + }, [updateAndCommit]) + + const addField = useCallback((key: string) => { + const trimmed = key.trim() + if (!trimmed) return + if (fields.some(f => f.key === trimmed)) return + updateAndCommit(prev => [...prev, { key: trimmed, value: '' }]) + setEditingNewKey(false) + }, [fields, updateAndCommit]) + + const count = fields.length + + return ( +
+ + + {expanded && ( +
+ {fields.map((field, index) => ( +
+ + {field.key} + +
+ {Array.isArray(field.value) ? ( + updateAndCommit(prev => { + const next = [...prev] + next[index] = { ...next[index], value: v } + return next + })} + /> + ) : ( + updateLocalValue(index, e.target.value)} + onBlur={() => commitField(index)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + /> + )} +
+ {editable && ( + + )} +
+ ))} + + {editable && ( + editingNewKey ? ( +
+ { + if (e.key === 'Enter') { + addField(e.currentTarget.value) + } else if (e.key === 'Escape') { + setEditingNewKey(false) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addField(e.currentTarget.value) + } else { + setEditingNewKey(false) + } + }} + /> +
+ ) : ( + + ) + )} +
+ )} +
+ ) +} + +function ArrayField({ + value, + editable, + onChange, +}: { + value: string[] + editable: boolean + onChange: (v: string[]) => void +}) { + const removeItem = (index: number) => { + onChange(value.filter((_, i) => i !== index)) + } + + const addItem = (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + onChange([...value, trimmed]) + } + + return ( +
+ {value.map((item, i) => ( + + {item} + {editable && ( + + )} + + ))} + {editable && ( + { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) { + removeItem(value.length - 1) + } + }} + onBlur={(e) => { + if (e.currentTarget.value.trim()) { + addItem(e.currentTarget.value) + e.currentTarget.value = '' + } + }} + /> + )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 35cab547..7858d2df 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -176,7 +176,7 @@ function getMarkdownWithBlankLines(editor: Editor): string { return result } import { EditorToolbar } from './editor-toolbar' -import { TagPills } from './tag-pills' +import { FrontmatterProperties } from './frontmatter-properties' import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' @@ -201,7 +201,8 @@ interface MarkdownEditorProps { editorSessionKey?: number onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void editable?: boolean - tags?: string[] + frontmatter?: string | null + onFrontmatterChange?: (raw: string | null) => void } type WikiLinkMatch = { @@ -290,7 +291,8 @@ export function MarkdownEditor({ editorSessionKey = 0, onHistoryHandlersChange, editable = true, - tags, + frontmatter, + onFrontmatterChange, }: MarkdownEditorProps) { const isInternalUpdate = useRef(false) const wrapperRef = useRef(null) @@ -724,7 +726,13 @@ export function MarkdownEditor({ onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} /> - {tags && } + {(frontmatter !== undefined) && onFrontmatterChange && ( + + )}
{wikiLinks ? ( diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index fb890ecb..2ae699a9 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -16,6 +16,7 @@ import { Mic, Network, Pencil, + Table2, Plug, LoaderIcon, Settings, @@ -101,6 +102,7 @@ type KnowledgeActions = { createNote: (parentPath?: string) => void createFolder: (parentPath?: string) => void openGraph: () => void + openBases: () => void expandAll: () => void collapseAll: () => void rename: (path: string, newName: string, isDir: boolean) => Promise @@ -855,6 +857,7 @@ function KnowledgeSection({ { icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, { icon: Network, label: "Graph View", action: () => actions.openGraph() }, + { icon: Table2, label: "Bases", action: () => actions.openBases() }, ] return ( diff --git a/apps/x/apps/renderer/src/components/tag-pills.tsx b/apps/x/apps/renderer/src/components/tag-pills.tsx deleted file mode 100644 index eead6558..00000000 --- a/apps/x/apps/renderer/src/components/tag-pills.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface TagPillsProps { - tags: string[] -} - -export function TagPills({ tags }: TagPillsProps) { - if (tags.length === 0) return null - - return ( -
- {tags.map((tag, i) => ( - - {tag} - - ))} -
- ) -} diff --git a/apps/x/apps/renderer/src/lib/frontmatter.ts b/apps/x/apps/renderer/src/lib/frontmatter.ts index a9b6b2ff..85a74dcf 100644 --- a/apps/x/apps/renderer/src/lib/frontmatter.ts +++ b/apps/x/apps/renderer/src/lib/frontmatter.ts @@ -29,6 +29,209 @@ export function joinFrontmatter(raw: string | null, body: string): string { return raw + '\n' + body } +/** Structured frontmatter fields extracted from categorized YAML. */ +export type FrontmatterFields = { + relationship: string | null + relationship_sub: string[] + topic: string[] + email_type: string[] + action: string[] + status: string | null + source: string[] +} + +/** + * Extract structured tag categories from raw frontmatter YAML. + * + * Handles both the new categorized format (top-level keys) and the legacy + * flat `tags:` list. For legacy notes the flat tags are mapped into + * categories using known tag values. + */ +export function extractFrontmatterFields(raw: string | null): FrontmatterFields { + const fields: FrontmatterFields = { + relationship: null, + relationship_sub: [], + topic: [], + email_type: [], + action: [], + status: null, + source: [], + } + if (!raw) return fields + + const lines = raw.split('\n') + let currentKey: string | null = null + + for (const line of lines) { + // Top-level key detection + const topMatch = line.match(/^(\w+):\s*(.*)$/) + if (topMatch || line === '---') { + currentKey = null + } + + if (topMatch) { + const key = topMatch[1] + const value = topMatch[2].trim() + + if (key in fields) { + currentKey = key + if (value) { + const field = fields[key as keyof FrontmatterFields] + if (Array.isArray(field)) { + (field as string[]).push(value) + } else { + // single-value field + ;(fields as Record)[key] = value + } + currentKey = null // inline value, no list follows + } + continue + } + + // Legacy flat tags: — parse and distribute into categories + if (key === 'tags') { + currentKey = '__legacy_tags' + continue + } + } + + // List items under a categorized key + if (currentKey && currentKey !== '__legacy_tags') { + const itemMatch = line.match(/^\s+-\s+(.+)$/) + if (itemMatch) { + const value = itemMatch[1].trim() + const field = fields[currentKey as keyof FrontmatterFields] + if (Array.isArray(field)) { + (field as string[]).push(value) + } else { + ;(fields as Record)[currentKey] = value + } + } + continue + } + + // Legacy flat tag items → map into categories + if (currentKey === '__legacy_tags') { + const itemMatch = line.match(/^\s+-\s+(.+)$/) + if (itemMatch) { + const tag = itemMatch[1].trim() + const cat = LEGACY_TAG_TO_CATEGORY[tag] + if (cat) { + const field = fields[cat as keyof FrontmatterFields] + if (Array.isArray(field)) { + (field as string[]).push(tag) + } else if (!(fields as Record)[cat]) { + ;(fields as Record)[cat] = tag + } + } + } + continue + } + } + + return fields +} + +/** + * Extract ALL top-level YAML key/value pairs from raw frontmatter. + * Returns a flat record where scalar values are strings and list values are string[]. + * Skips `---` delimiters and blank lines. + */ +export function extractAllFrontmatterValues(raw: string | null): Record { + const result: Record = {} + if (!raw) return result + + const lines = raw.split('\n') + let currentKey: string | null = null + + for (const line of lines) { + if (line === '---' || line.trim() === '') { + currentKey = null + continue + } + + // Top-level key: value + const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/) + if (topMatch) { + const key = topMatch[1] + const value = topMatch[2].trim() + if (value) { + result[key] = value + currentKey = null + } else { + // List will follow + currentKey = key + result[key] = [] + } + continue + } + + // List item under current key + if (currentKey) { + const itemMatch = line.match(/^\s+-\s+(.+)$/) + if (itemMatch) { + const arr = result[currentKey] + if (Array.isArray(arr)) { + arr.push(itemMatch[1].trim()) + } + } + } + } + + return result +} + +/** + * Convert a Record of frontmatter fields back to a raw YAML frontmatter string. + * Returns null if no non-empty fields remain. + */ +export function buildFrontmatter(fields: Record): string | null { + const lines: string[] = [] + for (const [key, value] of Object.entries(fields)) { + if (Array.isArray(value)) { + if (value.length === 0) continue + lines.push(`${key}:`) + for (const item of value) { + if (item.trim()) lines.push(` - ${item.trim()}`) + } + } else { + const trimmed = (value ?? '').trim() + if (!trimmed) continue + lines.push(`${key}: ${trimmed}`) + } + } + if (lines.length === 0) return null + return `---\n${lines.join('\n')}\n---` +} + +/** Map known tag values → category for legacy flat-list frontmatter. */ +const LEGACY_TAG_TO_CATEGORY: Record = { + // relationship + investor: 'relationship', customer: 'relationship', prospect: 'relationship', + partner: 'relationship', vendor: 'relationship', product: 'relationship', + candidate: 'relationship', team: 'relationship', advisor: 'relationship', + personal: 'relationship', press: 'relationship', community: 'relationship', + government: 'relationship', + // relationship_sub + primary: 'relationship_sub', secondary: 'relationship_sub', + 'executive-assistant': 'relationship_sub', cc: 'relationship_sub', + 'referred-by': 'relationship_sub', former: 'relationship_sub', + champion: 'relationship_sub', blocker: 'relationship_sub', + // topic + sales: 'topic', support: 'topic', legal: 'topic', finance: 'topic', + hiring: 'topic', fundraising: 'topic', travel: 'topic', event: 'topic', + shopping: 'topic', health: 'topic', learning: 'topic', research: 'topic', + // email_type + intro: 'email_type', followup: 'email_type', + // action + 'action-required': 'action', urgent: 'action', waiting: 'action', + // status + active: 'status', archived: 'status', stale: 'status', + // source + email: 'source', meeting: 'source', browser: 'source', + 'web-search': 'source', manual: 'source', import: 'source', +} + /** Tag category keys used in the categorized frontmatter format. */ const TAG_CATEGORY_KEYS = new Set([ 'relationship', diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 6e1c0deb..7bbff762 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -237,34 +237,200 @@ flex-shrink: 0; } -/* Tag pills row shown between toolbar and editor content */ -.tag-pills-row { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding: 4px 12px; +/* Frontmatter properties panel between toolbar and editor content */ +.frontmatter-properties { + flex-shrink: 0; border-bottom: 1px solid var(--border); background-color: var(--background); - flex-shrink: 0; - max-height: 4.5em; - overflow: hidden; + font-size: 13px; + color: var(--foreground); } -.tag-pill { - font-size: 11px; - line-height: 18px; - padding: 0 8px; - border-radius: 9999px; +.frontmatter-toggle { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 4px 12px; + background: none; + border: none; + cursor: pointer; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + font-size: 12px; + user-select: none; +} + +.frontmatter-toggle:hover { + color: var(--foreground); + background-color: color-mix(in srgb, var(--foreground) 4%, transparent); +} + +.frontmatter-chevron { + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.frontmatter-chevron.expanded { + transform: rotate(90deg); +} + +.frontmatter-label { + font-weight: 500; +} + +.frontmatter-fields { + padding: 2px 12px 6px 30px; +} + +.frontmatter-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; +} + +.frontmatter-key { + flex-shrink: 0; + width: 110px; + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 60%, transparent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.frontmatter-value-area { + flex: 1; + min-width: 0; +} + +.frontmatter-input { + width: 100%; + background: none; + border: none; + border-bottom: 1px solid transparent; + padding: 2px 4px; + font-size: 13px; + color: var(--foreground); + outline: none; +} + +.frontmatter-input:focus { + border-bottom-color: var(--primary); +} + +.frontmatter-input:read-only { + cursor: default; +} + +.frontmatter-new-key-input { + width: 110px; + flex-shrink: 0; +} + +.frontmatter-remove { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + color: color-mix(in srgb, var(--foreground) 40%, transparent); + opacity: 0; +} + +.frontmatter-row:hover .frontmatter-remove { + opacity: 1; +} + +.frontmatter-remove:hover { background-color: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); +} + +.frontmatter-add { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 4px; + margin-top: 2px; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + color: color-mix(in srgb, var(--foreground) 50%, transparent); +} + +.frontmatter-add:hover { + color: var(--foreground); +} + +/* Array field chips */ +.frontmatter-array { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-height: 24px; +} + +.frontmatter-chip { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 11px; + line-height: 18px; + padding: 0 6px; + border-radius: 9999px; + background-color: color-mix(in srgb, var(--foreground) 8%, transparent); white-space: nowrap; user-select: none; } -.dark .tag-pill { +.dark .frontmatter-chip { background-color: color-mix(in srgb, var(--foreground) 12%, transparent); } +.frontmatter-chip-text { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +.frontmatter-chip-remove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 0; + color: color-mix(in srgb, var(--foreground) 50%, transparent); + margin-left: 2px; +} + +.frontmatter-chip-remove:hover { + color: var(--foreground); +} + +.frontmatter-chip-input { + background: none; + border: none; + outline: none; + font-size: 12px; + color: var(--foreground); + width: 60px; + padding: 2px 0; +} + +.frontmatter-chip-input::placeholder { + color: color-mix(in srgb, var(--foreground) 30%, transparent); +} + .editor-toolbar .separator { width: 1px; height: 1.5rem; From bd4cc1145d268e1187dab10431508ab807900aed Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:15:15 +0530 Subject: [PATCH 11/13] inline task agent v1 --- apps/x/apps/main/src/ipc.ts | 6 + apps/x/apps/main/src/main.ts | 4 + apps/x/apps/renderer/src/App.tsx | 11 +- .../src/components/markdown-editor.tsx | 190 +++++- .../components/rowboat-mention-popover.tsx | 109 +++ .../renderer/src/extensions/task-block.tsx | 98 +++ apps/x/apps/renderer/src/styles/editor.css | 81 +++ apps/x/packages/core/src/agents/runtime.ts | 26 + .../core/src/knowledge/inline_task_agent.ts | 27 + .../core/src/knowledge/inline_tasks.ts | 626 ++++++++++++++++++ apps/x/packages/shared/src/index.ts | 3 +- apps/x/packages/shared/src/inline-task.ts | 33 + apps/x/packages/shared/src/ipc.ts | 13 + 13 files changed, 1221 insertions(+), 6 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx create mode 100644 apps/x/apps/renderer/src/extensions/task-block.tsx create mode 100644 apps/x/packages/core/src/knowledge/inline_task_agent.ts create mode 100644 apps/x/packages/core/src/knowledge/inline_tasks.ts create mode 100644 apps/x/packages/shared/src/inline-task.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 4d272275..2de2b437 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -32,6 +32,7 @@ import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo. import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { search } from '@x/core/dist/search/search.js'; import { versionHistory } from '@x/core'; +import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -531,5 +532,10 @@ export function setupIpcHandlers() { 'search:query': async (_event, args) => { return search(args.query, args.limit, args.types); }, + // Inline task schedule classification + 'inline-task:classifySchedule': async (_event, args) => { + const schedule = await classifySchedule(args.instruction); + return { schedule }; + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index b3868bc5..08160a23 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -19,6 +19,7 @@ import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js" import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; +import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; @@ -178,6 +179,9 @@ app.whenReady().then(async () => { // start note tagging service initNoteTagging(); + // start inline task service (@rowboat: mentions) + initInlineTasks(); + // start background agent runner (scheduled agents) initAgentRunner(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1a0cd396..768ff02b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1017,10 +1017,13 @@ function App() { frontmatterByPathRef.current.set(pathToLoad, fm) const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() const isSameEditorFile = editorPathRef.current === pathToLoad - const wouldClobberActiveEdits = - isSameEditorFile - && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(body) - if (!wouldClobberActiveEdits) { + const knownBaseline = initialContentByPathRef.current.get(pathToLoad) + const hasKnownBaseline = knownBaseline !== undefined + const hasUnsavedEdits = + hasKnownBaseline + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline) + const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits + if (!shouldPreserveActiveDraft) { setEditorContent(body) if (pathToLoad.endsWith('.md')) { setEditorCacheForPath(pathToLoad, body) diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 7858d2df..09212793 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -8,6 +8,7 @@ import Placeholder from '@tiptap/extension-placeholder' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' +import { TaskBlockExtension } from '@/extensions/task-block' import { Markdown } from 'tiptap-markdown' import { useEffect, useCallback, useMemo, useRef, useState } from 'react' @@ -133,6 +134,8 @@ function getMarkdownWithBlankLines(editor: Editor): string { }) }) blocks.push(listLines.join('\n')) + } else if (node.type === 'taskBlock') { + blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```') } else if (node.type === 'codeBlock') { const lang = (node.attrs?.language as string) || '' blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') @@ -181,8 +184,21 @@ import { WikiLink } from '@/extensions/wiki-link' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' +import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter' +import { RowboatMentionPopover } from './rowboat-mention-popover' import '@/styles/editor.css' +type RowboatMentionMatch = { + range: { from: number; to: number } +} + +type RowboatBlockEdit = { + /** ProseMirror position of the taskBlock node */ + nodePos: number + /** Existing instruction text */ + existingText: string +} + type WikiLinkConfig = { files: string[] recent: string[] @@ -304,6 +320,10 @@ export function MarkdownEditor({ const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit) const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {}) + const [activeRowboatMention, setActiveRowboatMention] = useState(null) + const [rowboatBlockEdit, setRowboatBlockEdit] = useState(null) + const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null) + const rowboatBlockEditRef = useRef(null) // Keep ref in sync with state for the plugin to access selectionHighlightRef.current = selectionHighlight @@ -399,6 +419,7 @@ export function MarkdownEditor({ }, }), ImageUploadPlaceholderExtension, + TaskBlockExtension, WikiLink.configure({ onCreate: wikiLinks?.onCreate ? (path) => { @@ -492,7 +513,7 @@ export function MarkdownEditor({ return false }, - handleClickOn: (_view, _pos, node, _nodePos, event) => { + handleClickOn: (_view, _pos, node, nodePos, event) => { if (node.type.name === 'wikiLink') { event.preventDefault() wikiLinks?.onOpen?.(node.attrs.path) @@ -575,6 +596,55 @@ export function MarkdownEditor({ }) }, [editor, wikiLinks]) + const updateRowboatMentionState = useCallback(() => { + if (!editor) return + const { selection } = editor.state + if (!selection.empty) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const { $from } = selection + if ($from.parent.type.spec.code) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n') + const textBefore = text.slice(0, $from.parentOffset) + + // Match @rowboat at a word boundary (preceded by nothing or whitespace) + const match = textBefore.match(/(^|\s)@rowboat$/) + if (!match) { + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + return + } + + const triggerStart = textBefore.length - '@rowboat'.length + const from = selection.from - (textBefore.length - triggerStart) + const to = selection.from + setActiveRowboatMention({ range: { from, to } }) + + const wrapper = wrapperRef.current + if (!wrapper) { + setRowboatAnchorTop(null) + return + } + + const coords = editor.view.coordsAtPos(selection.from) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor]) + useEffect(() => { if (!editor || !wikiLinks) return editor.on('update', updateWikiLinkState) @@ -585,6 +655,32 @@ export function MarkdownEditor({ } }, [editor, wikiLinks, updateWikiLinkState]) + useEffect(() => { + if (!editor) return + editor.on('update', updateRowboatMentionState) + editor.on('selectionUpdate', updateRowboatMentionState) + return () => { + editor.off('update', updateRowboatMentionState) + editor.off('selectionUpdate', updateRowboatMentionState) + } + }, [editor, updateRowboatMentionState]) + + // When a tell-rowboat block is clicked, compute anchor and open popover + useEffect(() => { + if (!rowboatBlockEdit || !editor) return + const wrapper = wrapperRef.current + if (!wrapper) return + const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos) + const wrapperRect = wrapper.getBoundingClientRect() + const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null + const pmRect = proseMirrorEl?.getBoundingClientRect() + setRowboatAnchorTop({ + top: coords.top - wrapperRect.top + wrapper.scrollTop, + left: pmRect ? pmRect.left - wrapperRect.left : 0, + width: pmRect ? pmRect.width : wrapperRect.width, + }) + }, [editor, rowboatBlockEdit]) + // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { @@ -675,6 +771,85 @@ export function MarkdownEditor({ handleSelectWikiLinkRef.current = handleSelectWikiLink }, [handleSelectWikiLink]) + const handleRowboatAdd = useCallback(async (instruction: string) => { + if (!editor) return + + if (rowboatBlockEdit) { + // Editing existing taskBlock — update its data attribute + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node && node.type.name === 'taskBlock') { + // Preserve existing schedule data + let updated: Record = { instruction } + try { + const existing = JSON.parse(node.attrs.data || '{}') + updated = { ...existing, instruction } + } catch { + // Invalid JSON — just write new + } + const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) }) + editor.view.dispatch(tr) + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + return + } + + if (activeRowboatMention) { + // Classify schedule intent for new blocks + const blockData: Record = { instruction } + try { + const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction }) + if (result.schedule) { + const { label, ...rest } = result.schedule + blockData.schedule = rest + blockData['schedule-label'] = label + } + } catch (error) { + console.error('[RowboatAdd] Schedule classification failed:', error) + } + + editor + .chain() + .focus() + .insertContentAt( + { from: activeRowboatMention.range.from, to: activeRowboatMention.range.to }, + [ + { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } }, + { type: 'paragraph' }, + ], + ) + .run() + + // Mark note as live + if (onFrontmatterChange) { + const fields = extractAllFrontmatterValues(frontmatter ?? null) + fields['live_note'] = 'true' + onFrontmatterChange(buildFrontmatter(fields)) + } + + setActiveRowboatMention(null) + setRowboatAnchorTop(null) + } + }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange]) + + const handleRowboatRemove = useCallback(() => { + if (!editor || !rowboatBlockEdit) return + const { nodePos } = rowboatBlockEdit + const node = editor.state.doc.nodeAt(nodePos) + if (node) { + editor + .chain() + .focus() + .deleteRange({ from: nodePos, to: nodePos + node.nodeSize }) + .run() + } + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }, [editor, rowboatBlockEdit]) + const handleScroll = useCallback(() => { updateWikiLinkState() }, [updateWikiLinkState]) @@ -789,6 +964,19 @@ export function MarkdownEditor({ ) : null} + { + setActiveRowboatMention(null) + setRowboatBlockEdit(null) + rowboatBlockEditRef.current = null + setRowboatAnchorTop(null) + }} + />
) diff --git a/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx new file mode 100644 index 00000000..a5a63bc7 --- /dev/null +++ b/apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx @@ -0,0 +1,109 @@ +import { useState, useRef, useEffect } from 'react' +import { Loader2 } from 'lucide-react' + +interface RowboatMentionPopoverProps { + open: boolean + anchor: { top: number; left: number; width: number } | null + initialText?: string + onAdd: (instruction: string) => void | Promise + onRemove?: () => void + onClose: () => void +} + +export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) { + const [text, setText] = useState('') + const [loading, setLoading] = useState(false) + const textareaRef = useRef(null) + const containerRef = useRef(null) + + useEffect(() => { + if (open) { + setText(initialText) + setLoading(false) + requestAnimationFrame(() => { + textareaRef.current?.focus() + }) + } + }, [open, initialText]) + + // Close on outside click + useEffect(() => { + if (!open) return + const handleMouseDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose() + } + } + document.addEventListener('mousedown', handleMouseDown) + return () => document.removeEventListener('mousedown', handleMouseDown) + }, [open, onClose]) + + if (!open || !anchor) return null + + const handleSubmit = async () => { + const trimmed = text.trim() + if (!trimmed || loading) return + setLoading(true) + try { + await onAdd(trimmed) + } finally { + setLoading(false) + } + setText('') + } + + return ( +
+
+
+ @rowboat +