mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
Merge remote rb-account: resolve conflicts
- env.ts: use renamed API_URL from remote - gateway.ts: use new API_URL paths from remote - runtime.ts: include labeling_agent and note_tagging_agent from remote - onboarding-modal.tsx: keep local OAuth/Composio listeners + remote imports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
63a5af8f9a
45 changed files with 5629 additions and 4074 deletions
|
|
@ -15,7 +15,11 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
import { serviceBus } from '@x/core/dist/services/service_bus.js';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import z from 'zod';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
|
|
@ -27,6 +31,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
|||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||
|
|
@ -34,6 +39,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;
|
||||
|
|
@ -414,6 +420,30 @@ export function setupIpcHandlers() {
|
|||
|
||||
return { success: true };
|
||||
},
|
||||
'slack:getConfig': async () => {
|
||||
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return { enabled: config.enabled, workspaces: config.workspaces };
|
||||
},
|
||||
'slack:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
|
||||
return { success: true };
|
||||
},
|
||||
'slack:listWorkspaces': async () => {
|
||||
try {
|
||||
const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 });
|
||||
const parsed = JSON.parse(stdout);
|
||||
const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({
|
||||
url: w.workspace_url || '',
|
||||
name: w.workspace_name || '',
|
||||
}));
|
||||
return { workspaces };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces';
|
||||
return { workspaces: [], error: message };
|
||||
}
|
||||
},
|
||||
'onboarding:getStatus': async () => {
|
||||
// Show onboarding if it hasn't been completed yet
|
||||
const complete = isOnboardingComplete();
|
||||
|
|
@ -536,5 +566,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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
|
|||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||
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";
|
||||
|
|
@ -170,6 +173,15 @@ app.whenReady().then(async () => {
|
|||
// start knowledge graph builder
|
||||
initGraphBuilder();
|
||||
|
||||
// start email labeling service
|
||||
initEmailLabeling();
|
||||
|
||||
// start note tagging service
|
||||
initNoteTagging();
|
||||
|
||||
// start inline task service (@rowboat: mentions)
|
||||
initInlineTasks();
|
||||
|
||||
// start background agent runner (scheduled agents)
|
||||
initAgentRunner();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +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 } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
|
|
@ -105,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))
|
||||
|
|
@ -232,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<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
if (!usage) return null
|
||||
|
|
@ -469,6 +473,7 @@ function App() {
|
|||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
|
|
@ -509,6 +514,9 @@ function App() {
|
|||
const initialContentRef = useRef<string>('')
|
||||
const renameInProgressRef = useRef(false)
|
||||
|
||||
// Frontmatter state: store raw frontmatter per file path
|
||||
const frontmatterByPathRef = useRef<Map<string, string | null>>(new Map())
|
||||
|
||||
// Version history state
|
||||
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
||||
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
|
||||
|
|
@ -616,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
|
||||
}, [])
|
||||
|
||||
|
|
@ -813,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)
|
||||
|
|
@ -892,12 +927,14 @@ function App() {
|
|||
const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload })
|
||||
if (selectedPathRef.current !== pathToReload) return
|
||||
setFileContent(result.data)
|
||||
setEditorContent(result.data)
|
||||
setEditorCacheForPath(pathToReload, result.data)
|
||||
editorContentRef.current = result.data
|
||||
const { raw: fm, body } = splitFrontmatter(result.data)
|
||||
frontmatterByPathRef.current.set(pathToReload, fm)
|
||||
setEditorContent(body)
|
||||
setEditorCacheForPath(pathToReload, body)
|
||||
editorContentRef.current = body
|
||||
editorPathRef.current = pathToReload
|
||||
initialContentByPathRef.current.set(pathToReload, result.data)
|
||||
initialContentRef.current = result.data
|
||||
initialContentByPathRef.current.set(pathToReload, body)
|
||||
initialContentRef.current = body
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -915,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,39 +996,46 @@ 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 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(result.data)
|
||||
if (pathToLoad.endsWith('.md')) {
|
||||
setEditorCacheForPath(pathToLoad, result.data)
|
||||
}
|
||||
editorContentRef.current = result.data
|
||||
editorPathRef.current = pathToLoad
|
||||
initialContentByPathRef.current.set(pathToLoad, result.data)
|
||||
initialContentRef.current = result.data
|
||||
setLastSaved(null)
|
||||
} 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 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)
|
||||
} 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)
|
||||
|
|
@ -1006,7 +1075,7 @@ function App() {
|
|||
const wasActiveAtStart = selectedPathRef.current === pathAtStart
|
||||
if (wasActiveAtStart) setIsSaving(true)
|
||||
let pathToSave = pathAtStart
|
||||
let contentToSave = debouncedContent
|
||||
let contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, debouncedContent)
|
||||
let renamedFrom: string | null = null
|
||||
let renamedTo: string | null = null
|
||||
try {
|
||||
|
|
@ -1036,16 +1105,21 @@ function App() {
|
|||
renameInProgressRef.current = true
|
||||
await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
|
||||
pathToSave = targetPath
|
||||
contentToSave = rewriteWikiLinksForRenamedFileInMarkdown(
|
||||
const rewrittenBody = rewriteWikiLinksForRenamedFileInMarkdown(
|
||||
debouncedContent,
|
||||
pathAtStart,
|
||||
targetPath
|
||||
)
|
||||
contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, rewrittenBody)
|
||||
renamedFrom = pathAtStart
|
||||
renamedTo = targetPath
|
||||
editorPathRef.current = targetPath
|
||||
untitledRenameReadyPathsRef.current.delete(pathAtStart)
|
||||
setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab)))
|
||||
// Migrate frontmatter entry
|
||||
const fmEntry = frontmatterByPathRef.current.get(pathAtStart)
|
||||
frontmatterByPathRef.current.delete(pathAtStart)
|
||||
frontmatterByPathRef.current.set(targetPath, fmEntry ?? null)
|
||||
initialContentByPathRef.current.delete(pathAtStart)
|
||||
const cachedContent = editorContentByPathRef.current.get(pathAtStart)
|
||||
if (cachedContent !== undefined) {
|
||||
|
|
@ -1070,8 +1144,9 @@ function App() {
|
|||
})
|
||||
}
|
||||
if (selectedPathRef.current === pathAtStart) {
|
||||
editorContentRef.current = contentToSave
|
||||
setEditorContent(contentToSave)
|
||||
const bodyForEditor = splitFrontmatter(contentToSave).body
|
||||
editorContentRef.current = bodyForEditor
|
||||
setEditorContent(bodyForEditor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1083,7 +1158,8 @@ function App() {
|
|||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
markRecentLocalMarkdownWrite(pathToSave)
|
||||
initialContentByPathRef.current.set(pathToSave, contentToSave)
|
||||
// Store body-only baseline (matches what debouncedContent compares against)
|
||||
initialContentByPathRef.current.set(pathToSave, splitFrontmatter(contentToSave).body)
|
||||
|
||||
// If we renamed the active file, update state/history AFTER the write completes so the editor
|
||||
// doesn't reload stale on-disk content mid-typing (which can drop the latest character).
|
||||
|
|
@ -1104,7 +1180,7 @@ function App() {
|
|||
|
||||
// Only update "current file" UI state if we're still on this file
|
||||
if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) {
|
||||
initialContentRef.current = contentToSave
|
||||
initialContentRef.current = splitFrontmatter(contentToSave).body
|
||||
setLastSaved(new Date())
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -2158,21 +2234,29 @@ 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)
|
||||
frontmatterByPathRef.current.delete(closingTab.path)
|
||||
if (editorPathRef.current === closingTab.path) {
|
||||
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
|
||||
|
|
@ -2186,7 +2270,7 @@ function App() {
|
|||
setIsGraphOpen(true)
|
||||
} else {
|
||||
setIsGraphOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
return next
|
||||
|
|
@ -2294,7 +2378,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
|
||||
)))
|
||||
|
|
@ -2439,6 +2523,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') {
|
||||
|
|
@ -2751,6 +2875,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) => {
|
||||
|
|
@ -2768,6 +2899,12 @@ function App() {
|
|||
if (editorPathRef.current === oldPath) {
|
||||
editorPathRef.current = newPath
|
||||
}
|
||||
// Migrate frontmatter entry
|
||||
const fmEntry = frontmatterByPathRef.current.get(oldPath)
|
||||
if (fmEntry !== undefined) {
|
||||
frontmatterByPathRef.current.delete(oldPath)
|
||||
frontmatterByPathRef.current.set(newPath, fmEntry)
|
||||
}
|
||||
const baseline = initialContentByPathRef.current.get(oldPath)
|
||||
if (baseline !== undefined) {
|
||||
initialContentByPathRef.current.delete(oldPath)
|
||||
|
|
@ -2805,6 +2942,7 @@ function App() {
|
|||
removeEditorCacheForPath(path)
|
||||
initialContentByPathRef.current.delete(path)
|
||||
untitledRenameReadyPathsRef.current.delete(path)
|
||||
frontmatterByPathRef.current.delete(path)
|
||||
}
|
||||
// Close any file tab showing the deleted file
|
||||
const tabForFile = fileTabs.find(t => t.path === path)
|
||||
|
|
@ -3243,7 +3381,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)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -3256,7 +3394,7 @@ function App() {
|
|||
onCloseTab={closeChatTab}
|
||||
/>
|
||||
)}
|
||||
{selectedPath && (
|
||||
{selectedPath && selectedPath.endsWith('.md') && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
|
@ -3345,7 +3483,18 @@ function App() {
|
|||
)}
|
||||
</ContentHeader>
|
||||
|
||||
{isGraphOpen ? (
|
||||
{selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
tree={tree}
|
||||
onSelectNote={(path) => navigateToFile(path)}
|
||||
config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG}
|
||||
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
|
||||
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
|
||||
onSave={(name) => void handleBaseSave(name)}
|
||||
/>
|
||||
</div>
|
||||
) : isGraphOpen ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<GraphView
|
||||
nodes={graphData.nodes}
|
||||
|
|
@ -3391,6 +3540,20 @@ function App() {
|
|||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||
frontmatter={frontmatterByPathRef.current.get(tab.path) ?? null}
|
||||
onFrontmatterChange={(newRaw) => {
|
||||
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)
|
||||
|
|
|
|||
820
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
820
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
|
|
@ -0,0 +1,820 @@
|
|||
import * as React from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from '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, extractAllFrontmatterValues } from '@/lib/frontmatter'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
children?: TreeNode[]
|
||||
stat?: { size: number; mtimeMs: number }
|
||||
}
|
||||
|
||||
type NoteEntry = {
|
||||
path: string
|
||||
name: string
|
||||
folder: string
|
||||
fields: Record<string, string | string[]>
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
type SortDir = 'asc' | 'desc'
|
||||
type ActiveFilter = { category: string; value: string }
|
||||
|
||||
export type BaseConfig = {
|
||||
name: string
|
||||
visibleColumns: string[]
|
||||
columnWidths: Record<string, number>
|
||||
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<BuiltinColumn, string> = {
|
||||
name: 'Name',
|
||||
folder: 'Folder',
|
||||
mtimeMs: 'Last Modified',
|
||||
}
|
||||
|
||||
/** Default pixel widths for columns */
|
||||
const DEFAULT_WIDTHS: Record<string, number> = {
|
||||
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 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
|
||||
? collectFiles(n.children)
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function getFolder(path: string): string {
|
||||
const parts = path.split('/')
|
||||
if (parts.length >= 3) return parts[1]
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
if (!ms) return ''
|
||||
const d = new Date(ms)
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean {
|
||||
return a.category === b.category && a.value === b.value
|
||||
}
|
||||
|
||||
function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean {
|
||||
return filters.some((x) => filtersEqual(x, f))
|
||||
}
|
||||
|
||||
/** 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<NoteEntry[]>(() => {
|
||||
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<Map<string, Record<string, string | string[]>>>(new Map())
|
||||
const loadGenRef = useRef(0)
|
||||
|
||||
// Load frontmatter in background batches
|
||||
useEffect(() => {
|
||||
const gen = ++loadGenRef.current
|
||||
let cancelled = false
|
||||
const paths = notes.map((n) => n.path)
|
||||
|
||||
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<string, string | string[]> }
|
||||
}
|
||||
}),
|
||||
)
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [notes])
|
||||
|
||||
// Merge tree-derived notes with async-loaded fields
|
||||
const enrichedNotes = useMemo<NoteEntry[]>(() => {
|
||||
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<string[]>(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const fields of fieldsByPath.values()) {
|
||||
for (const k of Object.keys(fields)) keys.add(k)
|
||||
}
|
||||
return Array.from(keys).sort()
|
||||
}, [fieldsByPath])
|
||||
|
||||
// Filterable categories: "folder" + all frontmatter keys
|
||||
const filterCategories = useMemo<string[]>(() => {
|
||||
return ['folder', ...allPropertyKeys]
|
||||
}, [allPropertyKeys])
|
||||
|
||||
// All unique values per category, across all enriched notes
|
||||
const valuesByCategory = useMemo<Record<string, string[]>>(() => {
|
||||
const result: Record<string, Set<string>> = {}
|
||||
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<string, string[]> = {}
|
||||
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<HTMLInputElement>(null)
|
||||
const [filterCategory, setFilterCategory] = useState<string | null>(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<Set<string> | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(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<string, string[]>()
|
||||
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 = String(va).localeCompare(String(vb))
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [filteredNotes, sortField, sortDir])
|
||||
|
||||
// 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(() => {
|
||||
onConfigChange({ ...configRef.current, filters: [] })
|
||||
}, [onConfigChange])
|
||||
|
||||
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 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'
|
||||
? <ArrowUp className="size-3 inline ml-1" />
|
||||
: <ArrowDown className="size-3 inline ml-1" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
|
||||
<ListFilter className="size-3.5" />
|
||||
Properties
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search properties..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No properties found.</CommandEmpty>
|
||||
<CommandGroup heading="Built-in">
|
||||
{BUILTIN_COLUMNS.map((col) => (
|
||||
<CommandItem key={col} onSelect={() => toggleColumn(col)}>
|
||||
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(col) ? 'opacity-100' : 'opacity-0')} />
|
||||
{BUILTIN_LABELS[col]}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Frontmatter">
|
||||
{allPropertyKeys.map((key) => (
|
||||
<CommandItem key={key} onSelect={() => toggleColumn(key)}>
|
||||
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(key) ? 'opacity-100' : 'opacity-0')} />
|
||||
{toTitleCase(key)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover onOpenChange={(open) => { if (!open) setFilterCategory(null) }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground',
|
||||
filters.length > 0 && 'text-foreground',
|
||||
)}>
|
||||
<Filter className="size-3.5" />
|
||||
Filter
|
||||
{filters.length > 0 && (
|
||||
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight">
|
||||
{filters.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className={cn('p-0', filterCategory ? 'w-[420px]' : 'w-[200px]')}>
|
||||
<div className="flex h-[300px]">
|
||||
{/* Left: categories */}
|
||||
<div className={cn('overflow-auto', filterCategory ? 'w-[160px] border-r border-border' : 'flex-1')}>
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Attributes</span>
|
||||
{filters.length > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filterCategories.map((cat) => {
|
||||
const activeCount = filters.filter((f) => f.category === cat).length
|
||||
const isSelected = filterCategory === cat
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setFilterCategory(cat)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-1.5 px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors',
|
||||
isSelected && 'bg-accent text-foreground',
|
||||
!isSelected && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 truncate">{toTitleCase(cat)}</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight shrink-0">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Right: values for selected category */}
|
||||
{filterCategory && (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<Command className="flex-1 flex flex-col">
|
||||
<CommandInput placeholder={`Search ${toTitleCase(filterCategory).toLowerCase()}...`} />
|
||||
<CommandList className="flex-1 overflow-auto max-h-none">
|
||||
<CommandEmpty>No values found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(valuesByCategory[filterCategory] ?? []).map((val) => {
|
||||
const active = hasFilter(filters, { category: filterCategory, value: val })
|
||||
return (
|
||||
<CommandItem key={val} onSelect={() => toggleFilter(filterCategory, val)}>
|
||||
<Check className={cn('size-3.5 mr-2 shrink-0', active ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{val}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0',
|
||||
searchOpen && 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
Search
|
||||
</button>
|
||||
|
||||
{searchOpen && (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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 && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSearch}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={handleSaveClick}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
{isDefaultBase ? 'Save As' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{filters.length > 0 && (
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{sortedNotes.length} of {enrichedNotes.length} notes
|
||||
</span>
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={`${f.category}:${f.value}`}
|
||||
onClick={() => toggleFilter(f.category, f.value)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-[11px] font-medium"
|
||||
>
|
||||
<span className="text-primary-foreground/60">{f.category}:</span>
|
||||
{f.value}
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
<button onClick={clearFilters} className="text-xs text-muted-foreground hover:text-foreground">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
{visibleColumns.map((col) => (
|
||||
<col key={col} style={{ width: getColWidth(col) }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 bg-background border-b border-border z-10">
|
||||
<tr>
|
||||
{visibleColumns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="relative text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none group"
|
||||
onClick={() => handleSort(col)}
|
||||
>
|
||||
<span className="truncate block">{toTitleCase(col)}<SortIcon field={col} /></span>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover:opacity-100 hover:!opacity-100 bg-border/60"
|
||||
onMouseDown={(e) => onResizeStart(col, e)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageNotes.map((note) => (
|
||||
<tr
|
||||
key={note.path}
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2 overflow-hidden">
|
||||
<CellRenderer
|
||||
note={note}
|
||||
column={col}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{pageNotes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={visibleColumns.length} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No notes found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="shrink-0 border-t border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sortedNotes.length === 0
|
||||
? '0 notes'
|
||||
: `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`}
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
Page {clampedPage + 1} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
disabled={clampedPage >= totalPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save As dialog */}
|
||||
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[360px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Base</DialogTitle>
|
||||
<DialogDescription>Choose a name for this base view.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<input
|
||||
ref={saveInputRef}
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={() => setSaveDialogOpen(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveConfirm}
|
||||
disabled={!saveName.trim()}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 <span className="font-medium truncate block">{note.name}</span>
|
||||
}
|
||||
if (column === 'folder') {
|
||||
return <span className="text-muted-foreground truncate block">{note.folder}</span>
|
||||
}
|
||||
if (column === 'mtimeMs') {
|
||||
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
|
||||
}
|
||||
|
||||
// Frontmatter column
|
||||
const value = note.fields[column]
|
||||
if (!value) return null
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{value.map((v) => (
|
||||
<CategoryBadge
|
||||
key={v}
|
||||
category={column}
|
||||
value={v}
|
||||
active={hasFilter(filters, { category: column, value: v })}
|
||||
onClick={toggleFilter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Single string value — render as badge for filterability
|
||||
return (
|
||||
<CategoryBadge
|
||||
category={column}
|
||||
value={value}
|
||||
active={hasFilter(filters, { category: column, value })}
|
||||
onClick={toggleFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBadge({
|
||||
category,
|
||||
value,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
category: string
|
||||
value: string
|
||||
active: boolean
|
||||
onClick: (category: string, value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant={active ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0 cursor-pointer',
|
||||
!active && 'hover:bg-primary hover:text-primary-foreground',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick(category, value)
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -55,11 +54,15 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
|
||||
// Composio/Slack state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [slackConnected, setSlackConnected] = useState(false)
|
||||
// Slack state (agent-slack CLI)
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
|
||||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Load available providers on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -107,76 +110,76 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack connection status
|
||||
const refreshSlackStatus = useCallback(async () => {
|
||||
// Load Slack config
|
||||
const refreshSlackConfig = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
|
||||
setSlackConnected(result.isConnected)
|
||||
const result = await window.ipc.invoke('slack:getConfig', null)
|
||||
setSlackEnabled(result.enabled)
|
||||
setSlackWorkspaces(result.workspaces || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack status:', error)
|
||||
setSlackConnected(false)
|
||||
console.error('Failed to load Slack config:', error)
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Slack via Composio
|
||||
const startSlackConnect = useCallback(async () => {
|
||||
// Enable Slack: discover workspaces
|
||||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
setSlackConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Slack:', error)
|
||||
toast.error('Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle Slack connect button click
|
||||
const handleConnectSlack = useCallback(async () => {
|
||||
// Check if Composio is configured
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startSlackConnect()
|
||||
}, [startSlackConnect])
|
||||
|
||||
// Handle Composio API key submission
|
||||
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||
try {
|
||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||
setComposioApiKeyOpen(false)
|
||||
toast.success('Composio API key saved')
|
||||
// Now start the Slack connection
|
||||
await startSlackConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startSlackConnect])
|
||||
|
||||
// Disconnect from Slack
|
||||
const handleDisconnectSlack = useCallback(async () => {
|
||||
// Save selected Slack workspaces
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' })
|
||||
if (result.success) {
|
||||
setSlackConnected(false)
|
||||
toast.success('Disconnected from Slack')
|
||||
} else {
|
||||
toast.error('Failed to disconnect from Slack')
|
||||
}
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
|
||||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect from Slack:', error)
|
||||
toast.error('Failed to disconnect from Slack')
|
||||
console.error('Failed to save Slack config:', error)
|
||||
toast.error('Failed to save Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [slackAvailableWorkspaces, slackSelectedUrls])
|
||||
|
||||
// Disable Slack
|
||||
const handleSlackDisable = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Slack config:', error)
|
||||
toast.error('Failed to update Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
|
|
@ -187,8 +190,8 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
// Refresh Granola
|
||||
refreshGranolaConfig()
|
||||
|
||||
// Refresh Slack status
|
||||
refreshSlackStatus()
|
||||
// Refresh Slack config
|
||||
refreshSlackConfig()
|
||||
|
||||
// Refresh OAuth providers
|
||||
if (providers.length === 0) return
|
||||
|
|
@ -226,7 +229,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig])
|
||||
|
||||
// Refresh statuses when popover opens or providers list changes
|
||||
useEffect(() => {
|
||||
|
|
@ -270,26 +273,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
return cleanup
|
||||
}, [refreshAllStatuses])
|
||||
|
||||
// Listen for Composio connection events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
|
||||
if (toolkitSlug === 'slack') {
|
||||
setSlackConnected(success)
|
||||
setSlackConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Slack')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Slack')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -585,62 +568,90 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
|
|||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
<div className="rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackEnabled && slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{slackWorkspaces.map(w => w.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{(slackLoading || slackDiscovering) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => handleSlackDisable()}
|
||||
disabled={slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSlackEnable}
|
||||
disabled={slackLoading || slackDiscovering}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisconnectSlack}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{slackConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{slackPickerOpen && (
|
||||
<div className="mt-2 ml-11 space-y-2">
|
||||
{slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackSaveWorkspaces}
|
||||
disabled={slackSelectedUrls.size === 0 || slackLoading}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
252
apps/x/apps/renderer/src/components/frontmatter-properties.tsx
Normal file
|
|
@ -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<string, string | string[]> = {}
|
||||
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<FieldEntry[]>(() => fieldsFromRaw(raw))
|
||||
const [editingNewKey, setEditingNewKey] = useState(false)
|
||||
const newKeyRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="frontmatter-properties">
|
||||
<button
|
||||
className="frontmatter-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`frontmatter-chevron ${expanded ? 'expanded' : ''}`}
|
||||
/>
|
||||
<span className="frontmatter-label">
|
||||
Properties{count > 0 ? ` (${count})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="frontmatter-fields">
|
||||
{fields.map((field, index) => (
|
||||
<div key={`${field.key}-${index}`} className="frontmatter-row">
|
||||
<span className="frontmatter-key" title={field.key}>
|
||||
{field.key}
|
||||
</span>
|
||||
<div className="frontmatter-value-area">
|
||||
{Array.isArray(field.value) ? (
|
||||
<ArrayField
|
||||
value={field.value}
|
||||
editable={editable}
|
||||
onChange={(v) => updateAndCommit(prev => {
|
||||
const next = [...prev]
|
||||
next[index] = { ...next[index], value: v }
|
||||
return next
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="frontmatter-input"
|
||||
value={field.value}
|
||||
readOnly={!editable}
|
||||
onChange={(e) => updateLocalValue(index, e.target.value)}
|
||||
onBlur={() => commitField(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editable && (
|
||||
<button
|
||||
className="frontmatter-remove"
|
||||
onClick={() => removeField(index)}
|
||||
type="button"
|
||||
title="Remove property"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editable && (
|
||||
editingNewKey ? (
|
||||
<div className="frontmatter-row frontmatter-new-row">
|
||||
<input
|
||||
ref={newKeyRef}
|
||||
className="frontmatter-input frontmatter-new-key-input"
|
||||
placeholder="Property name"
|
||||
onKeyDown={(e) => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="frontmatter-add"
|
||||
onClick={() => setEditingNewKey(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>Add property</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="frontmatter-array">
|
||||
{value.map((item, i) => (
|
||||
<span key={i} className="frontmatter-chip">
|
||||
<span className="frontmatter-chip-text">{item}</span>
|
||||
{editable && (
|
||||
<button
|
||||
className="frontmatter-chip-remove"
|
||||
onClick={() => removeItem(i)}
|
||||
type="button"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{editable && (
|
||||
<input
|
||||
className="frontmatter-chip-input"
|
||||
placeholder="Add..."
|
||||
onKeyDown={(e) => {
|
||||
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 = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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```')
|
||||
|
|
@ -176,12 +179,26 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
return result
|
||||
}
|
||||
import { EditorToolbar } from './editor-toolbar'
|
||||
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'
|
||||
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[]
|
||||
|
|
@ -200,6 +217,8 @@ interface MarkdownEditorProps {
|
|||
editorSessionKey?: number
|
||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||
editable?: boolean
|
||||
frontmatter?: string | null
|
||||
onFrontmatterChange?: (raw: string | null) => void
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -288,6 +307,8 @@ export function MarkdownEditor({
|
|||
editorSessionKey = 0,
|
||||
onHistoryHandlersChange,
|
||||
editable = true,
|
||||
frontmatter,
|
||||
onFrontmatterChange,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -299,6 +320,17 @@ 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<RowboatMentionMatch | null>(null)
|
||||
const [rowboatBlockEdit, setRowboatBlockEdit] = useState<RowboatBlockEdit | null>(null)
|
||||
const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
|
||||
const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(null)
|
||||
|
||||
// @ mention autocomplete state (analogous to wiki-link state)
|
||||
const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null)
|
||||
const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
const [atCommandValue, setAtCommandValue] = useState<string>('')
|
||||
const atKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
|
||||
const handleSelectAtMentionRef = useRef<(value: string) => void>(() => {})
|
||||
|
||||
// Keep ref in sync with state for the plugin to access
|
||||
selectionHighlightRef.current = selectionHighlight
|
||||
|
|
@ -394,6 +426,7 @@ export function MarkdownEditor({
|
|||
},
|
||||
}),
|
||||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
WikiLink.configure({
|
||||
onCreate: wikiLinks?.onCreate
|
||||
? (path) => {
|
||||
|
|
@ -466,6 +499,39 @@ export function MarkdownEditor({
|
|||
}
|
||||
}
|
||||
|
||||
// @ mention autocomplete keyboard handling
|
||||
const atState = atKeyStateRef.current
|
||||
if (atState.open) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
setAtCommandValue('')
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (atState.options.length === 0) return true
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const currentIndex = Math.max(0, atState.options.indexOf(atState.value))
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
const nextIndex = (currentIndex + delta + atState.options.length) % atState.options.length
|
||||
setAtCommandValue(atState.options[nextIndex])
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (atState.options.length === 0) return true
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const selected = atState.options.includes(atState.value) ? atState.value : atState.options[0]
|
||||
handleSelectAtMentionRef.current(selected)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (preventTitleHeadingDemotion(view, event)) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -487,7 +553,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)
|
||||
|
|
@ -570,6 +636,118 @@ 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])
|
||||
|
||||
// Detect @ trigger for autocomplete popover (similar to [[ detection)
|
||||
const updateAtMentionState = useCallback(() => {
|
||||
if (!editor) return
|
||||
const { selection } = editor.state
|
||||
if (!selection.empty) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { $from } = selection
|
||||
// Skip code blocks
|
||||
if ($from.parent.type.spec.code) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
// Skip inline code marks
|
||||
if ($from.marks().some((mark) => mark.type.spec.code)) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
|
||||
const textBefore = text.slice(0, $from.parentOffset)
|
||||
|
||||
// Find @ at a word boundary (start of line or preceded by whitespace)
|
||||
const atMatch = textBefore.match(/(^|[\s])@([a-zA-Z0-9]*)$/)
|
||||
if (!atMatch) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const query = atMatch[2] // text after @
|
||||
|
||||
// If the full "@rowboat" is already typed, let updateRowboatMentionState handle it
|
||||
if (query === 'rowboat') {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const atSymbolOffset = textBefore.lastIndexOf('@')
|
||||
const matchText = textBefore.slice(atSymbolOffset)
|
||||
const range = { from: selection.from - matchText.length, to: selection.from }
|
||||
setActiveAtMention({ range, query })
|
||||
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) {
|
||||
setAtAnchorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const coords = editor.view.coordsAtPos(selection.from)
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
setAtAnchorPosition({
|
||||
left: coords.left - wrapperRect.left,
|
||||
top: coords.bottom - wrapperRect.top,
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !wikiLinks) return
|
||||
editor.on('update', updateWikiLinkState)
|
||||
|
|
@ -580,6 +758,42 @@ 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.on('update', updateAtMentionState)
|
||||
editor.on('selectionUpdate', updateAtMentionState)
|
||||
return () => {
|
||||
editor.off('update', updateAtMentionState)
|
||||
editor.off('selectionUpdate', updateAtMentionState)
|
||||
}
|
||||
}, [editor, updateAtMentionState])
|
||||
|
||||
// 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) {
|
||||
|
|
@ -670,9 +884,89 @@ 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<string, unknown> = { 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<string, unknown> = { 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])
|
||||
updateAtMentionState()
|
||||
}, [updateWikiLinkState, updateAtMentionState])
|
||||
|
||||
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
|
||||
const wikiOptions = useMemo(() => {
|
||||
|
|
@ -700,6 +994,63 @@ export function MarkdownEditor({
|
|||
setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0]))
|
||||
}, [showWikiPopover, wikiOptions])
|
||||
|
||||
// @ mention autocomplete options
|
||||
const atMentionOptions = useMemo(() => [
|
||||
{ value: 'rowboat', label: '@rowboat', description: 'Research, schedule, or run tasks with AI' },
|
||||
], [])
|
||||
|
||||
const filteredAtOptions = useMemo(() => {
|
||||
if (!activeAtMention) return []
|
||||
const q = activeAtMention.query.toLowerCase()
|
||||
if (!q) return atMentionOptions
|
||||
return atMentionOptions.filter((opt) => opt.value.toLowerCase().startsWith(q))
|
||||
}, [activeAtMention, atMentionOptions])
|
||||
|
||||
const atOptionValues = useMemo(() => filteredAtOptions.map((o) => o.value), [filteredAtOptions])
|
||||
const showAtPopover = Boolean(activeAtMention && atAnchorPosition && filteredAtOptions.length > 0)
|
||||
|
||||
useEffect(() => {
|
||||
atKeyStateRef.current = { open: showAtPopover, options: atOptionValues, value: atCommandValue }
|
||||
}, [showAtPopover, atOptionValues, atCommandValue])
|
||||
|
||||
// Keep @ cmdk selection in sync
|
||||
useEffect(() => {
|
||||
if (!showAtPopover) {
|
||||
setAtCommandValue('')
|
||||
return
|
||||
}
|
||||
if (atOptionValues.length === 0) {
|
||||
setAtCommandValue('')
|
||||
return
|
||||
}
|
||||
setAtCommandValue((prev) => (atOptionValues.includes(prev) ? prev : atOptionValues[0]))
|
||||
}, [showAtPopover, atOptionValues])
|
||||
|
||||
// @ mention selection handler
|
||||
const handleSelectAtMention = useCallback((value: string) => {
|
||||
if (!editor || !activeAtMention) return
|
||||
|
||||
if (value === 'rowboat') {
|
||||
// Replace "@<partial>" with "@rowboat" — this triggers updateRowboatMentionState
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{ from: activeAtMention.range.from, to: activeAtMention.range.to },
|
||||
'@rowboat'
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
setAtCommandValue('')
|
||||
}, [editor, activeAtMention])
|
||||
|
||||
useEffect(() => {
|
||||
handleSelectAtMentionRef.current = handleSelectAtMention
|
||||
}, [handleSelectAtMention])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
||||
|
|
@ -721,6 +1072,13 @@ export function MarkdownEditor({
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
/>
|
||||
{(frontmatter !== undefined) && onFrontmatterChange && (
|
||||
<FrontmatterProperties
|
||||
raw={frontmatter}
|
||||
onRawChange={onFrontmatterChange}
|
||||
editable={editable}
|
||||
/>
|
||||
)}
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{wikiLinks ? (
|
||||
|
|
@ -777,6 +1135,64 @@ export function MarkdownEditor({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
{/* @ mention autocomplete popover */}
|
||||
<Popover
|
||||
open={showAtPopover}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setActiveAtMention(null)
|
||||
setAtAnchorPosition(null)
|
||||
setAtCommandValue('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
className="wiki-link-anchor"
|
||||
style={
|
||||
atAnchorPosition
|
||||
? { left: atAnchorPosition.left, top: atAnchorPosition.top }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-72 p-1"
|
||||
align="start"
|
||||
side="bottom"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false} value={atCommandValue} onValueChange={setAtCommandValue}>
|
||||
<CommandList>
|
||||
{filteredAtOptions.map((opt) => (
|
||||
<CommandItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
onSelect={() => handleSelectAtMention(opt.value)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.description}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<RowboatMentionPopover
|
||||
open={Boolean((activeRowboatMention || rowboatBlockEdit) && rowboatAnchorTop)}
|
||||
anchor={rowboatAnchorTop}
|
||||
initialText={rowboatBlockEdit?.existingText ?? ''}
|
||||
onAdd={handleRowboatAdd}
|
||||
onRemove={rowboatBlockEdit ? handleRowboatRemove : undefined}
|
||||
onClose={() => {
|
||||
setActiveRowboatMention(null)
|
||||
setRowboatBlockEdit(null)
|
||||
rowboatBlockEditRef.current = null
|
||||
setRowboatAnchorTop(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, Mic, Mail, CheckCircle2, ArrowLeft } from "lucide-react"
|
||||
// import { MessageSquare } from "lucide-react"
|
||||
import { Loader2, Mic, Mail, CheckCircle2, ArrowLeft, MessageSquare } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -23,7 +22,6 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -83,11 +81,15 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// Composio/Slack state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [slackConnected, setSlackConnected] = useState(false)
|
||||
// const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||
// Slack state (agent-slack CLI)
|
||||
const [slackEnabled, setSlackEnabled] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
|
||||
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
|
||||
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
|
||||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
|
|
@ -215,63 +217,80 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack connection status
|
||||
const refreshSlackStatus = useCallback(async () => {
|
||||
// Load Slack config
|
||||
const refreshSlackConfig = useCallback(async () => {
|
||||
try {
|
||||
// setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
|
||||
setSlackConnected(result.isConnected)
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('slack:getConfig', null)
|
||||
setSlackEnabled(result.enabled)
|
||||
setSlackWorkspaces(result.workspaces || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack status:', error)
|
||||
setSlackConnected(false)
|
||||
console.error('Failed to load Slack config:', error)
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
} finally {
|
||||
// setSlackLoading(false)
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start Slack connection
|
||||
const startSlackConnect = useCallback(async () => {
|
||||
// Enable Slack: discover workspaces
|
||||
const handleSlackEnable = useCallback(async () => {
|
||||
setSlackDiscovering(true)
|
||||
setSlackDiscoverError(null)
|
||||
try {
|
||||
setSlackConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
const result = await window.ipc.invoke('slack:listWorkspaces', null)
|
||||
if (result.error || result.workspaces.length === 0) {
|
||||
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
|
||||
setSlackAvailableWorkspaces([])
|
||||
setSlackPickerOpen(true)
|
||||
} else {
|
||||
setSlackAvailableWorkspaces(result.workspaces)
|
||||
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
|
||||
setSlackPickerOpen(true)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Slack:', error)
|
||||
toast.error('Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
console.error('Failed to discover Slack workspaces:', error)
|
||||
setSlackDiscoverError('Failed to discover Slack workspaces')
|
||||
setSlackPickerOpen(true)
|
||||
} finally {
|
||||
setSlackDiscovering(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Slack via Composio (checks if configured first)
|
||||
/*
|
||||
const handleConnectSlack = useCallback(async () => {
|
||||
// Check if Composio is configured
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startSlackConnect()
|
||||
}, [startSlackConnect])
|
||||
*/
|
||||
|
||||
// Handle Composio API key submission
|
||||
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||
// Save selected Slack workspaces
|
||||
const handleSlackSaveWorkspaces = useCallback(async () => {
|
||||
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
|
||||
try {
|
||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||
setComposioApiKeyOpen(false)
|
||||
toast.success('Composio API key saved')
|
||||
// Now start the Slack connection
|
||||
await startSlackConnect()
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
|
||||
setSlackEnabled(true)
|
||||
setSlackWorkspaces(selected)
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack enabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
console.error('Failed to save Slack config:', error)
|
||||
toast.error('Failed to save Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [startSlackConnect])
|
||||
}, [slackAvailableWorkspaces, slackSelectedUrls])
|
||||
|
||||
// Disable Slack
|
||||
const handleSlackDisable = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
|
||||
setSlackEnabled(false)
|
||||
setSlackWorkspaces([])
|
||||
setSlackPickerOpen(false)
|
||||
toast.success('Slack disabled')
|
||||
} catch (error) {
|
||||
console.error('Failed to update Slack config:', error)
|
||||
toast.error('Failed to update Slack settings')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 4) {
|
||||
|
|
@ -340,8 +359,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
// Refresh Granola
|
||||
refreshGranolaConfig()
|
||||
|
||||
// Refresh Slack status
|
||||
refreshSlackStatus()
|
||||
// Refresh Slack config
|
||||
refreshSlackConfig()
|
||||
|
||||
// Refresh OAuth providers
|
||||
if (providers.length === 0) return
|
||||
|
|
@ -370,7 +389,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackConfig])
|
||||
|
||||
// Refresh statuses when modal opens or providers list changes
|
||||
useEffect(() => {
|
||||
|
|
@ -437,6 +456,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
|
|
@ -590,50 +610,85 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
)
|
||||
|
||||
// Render Slack row
|
||||
/*
|
||||
const renderSlackRow = () => (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-5" />
|
||||
<div className="rounded-md px-3 py-3 hover:bg-accent">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackEnabled && slackWorkspaces.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{slackWorkspaces.map(w => w.name).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{(slackLoading || slackDiscovering) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{slackEnabled ? (
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => handleSlackDisable()}
|
||||
disabled={slackLoading}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSlackEnable}
|
||||
disabled={slackLoading || slackDiscovering}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
>
|
||||
{slackConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{slackPickerOpen && (
|
||||
<div className="mt-2 ml-13 space-y-2">
|
||||
{slackDiscoverError ? (
|
||||
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
|
||||
) : (
|
||||
<>
|
||||
{slackAvailableWorkspaces.map(w => (
|
||||
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slackSelectedUrls.has(w.url)}
|
||||
onChange={(e) => {
|
||||
setSlackSelectedUrls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (e.target.checked) next.add(w.url)
|
||||
else next.delete(w.url)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSlackSaveWorkspaces}
|
||||
disabled={slackSelectedUrls.size === 0 || slackLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
*/
|
||||
|
||||
// Step 0: Sign in to Rowboat (with BYOK option)
|
||||
const renderSignInStep = () => {
|
||||
|
|
@ -983,6 +1038,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
|
||||
</div>
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
|
||||
</div>
|
||||
{renderSlackRow()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1006,7 +1068,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
|
||||
// Step 4: Completion
|
||||
const renderCompletionStep = () => {
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
|
|
@ -1047,7 +1109,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
<span>Granola (Local meeting notes)</span>
|
||||
</div>
|
||||
)}
|
||||
{slackConnected && (
|
||||
{slackEnabled && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
<span>Slack (Team communication)</span>
|
||||
|
|
@ -1073,12 +1135,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
onSubmit={handleGoogleClientIdSubmit}
|
||||
isSubmitting={providerStates.google?.isConnecting ?? false}
|
||||
/>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
|
||||
|
|
|
|||
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal file
109
apps/x/apps/renderer/src/components/rowboat-mention-popover.tsx
Normal file
|
|
@ -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<void>
|
||||
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<HTMLTextAreaElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-50"
|
||||
style={{
|
||||
top: anchor.top,
|
||||
left: anchor.left,
|
||||
width: anchor.width,
|
||||
}}
|
||||
>
|
||||
<div className="relative border border-input rounded-md bg-popover shadow-sm">
|
||||
<div className="flex items-start gap-1.5 px-3 pt-2 pb-8">
|
||||
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">@rowboat</span>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none resize-none leading-[1.5]"
|
||||
placeholder=""
|
||||
rows={2}
|
||||
value={text}
|
||||
disabled={loading}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1.5">
|
||||
{onRemove && (
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onRemove}
|
||||
disabled={loading}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={!text.trim() || loading}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X } from "lucide-react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -18,11 +18,12 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTheme } from "@/contexts/theme-context"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type ConfigTab = "models" | "mcp" | "security" | "appearance"
|
||||
type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -60,6 +61,13 @@ const tabs: TabConfig[] = [
|
|||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
icon: Tags,
|
||||
path: "config/tags.json",
|
||||
description: "Configure tags for notes and emails",
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsDialogProps {
|
||||
|
|
@ -685,6 +693,415 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Note Tagging Settings ---
|
||||
|
||||
interface TagDef {
|
||||
tag: string
|
||||
type: string
|
||||
applicability: "email" | "notes" | "both"
|
||||
description: string
|
||||
example?: string
|
||||
noteEffect?: "create" | "skip" | "none"
|
||||
}
|
||||
|
||||
const NOTE_TAG_TYPE_ORDER = [
|
||||
"relationship", "relationship-sub", "topic", "action", "status", "source",
|
||||
]
|
||||
|
||||
const EMAIL_TAG_TYPE_ORDER = [
|
||||
"relationship", "topic", "email-type", "filter", "action", "status",
|
||||
]
|
||||
|
||||
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||
"relationship": "Relationship",
|
||||
"relationship-sub": "Relationship Sub-Tags",
|
||||
"topic": "Topic",
|
||||
"email-type": "Email Type",
|
||||
"filter": "Filter",
|
||||
"action": "Action",
|
||||
"status": "Status",
|
||||
"source": "Source",
|
||||
}
|
||||
|
||||
const DEFAULT_TAGS: TagDef[] = [
|
||||
{ tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", 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", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
|
||||
{ tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
|
||||
{ tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
|
||||
{ tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
|
||||
{ tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
|
||||
{ tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
|
||||
{ tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
|
||||
{ tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", 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", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
|
||||
{ tag: "press", type: "relationship", applicability: "both", noteEffect: "create", 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", noteEffect: "create", 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", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
|
||||
{ tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", 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", noteEffect: "none", 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", noteEffect: "none", 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", noteEffect: "none", 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", noteEffect: "none", 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", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
|
||||
{ tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
|
||||
{ tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
|
||||
{ tag: "sales", type: "topic", applicability: "both", noteEffect: "create", 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", noteEffect: "create", 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", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
|
||||
{ tag: "finance", type: "topic", applicability: "both", noteEffect: "create", 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", noteEffect: "create", 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", noteEffect: "create", 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", noteEffect: "skip", 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", noteEffect: "create", 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", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
|
||||
{ tag: "health", type: "topic", applicability: "both", noteEffect: "skip", 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", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
|
||||
{ tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
|
||||
{ tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", 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", noteEffect: "create", 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", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
|
||||
{ tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", 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", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
|
||||
{ tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
|
||||
{ tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
|
||||
{ tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
|
||||
{ tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." },
|
||||
{ tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
|
||||
{ tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
|
||||
{ tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", 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", noteEffect: "create", 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", noteEffect: "create", description: "Waiting on a response from them" },
|
||||
{ tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" },
|
||||
{ tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" },
|
||||
{ tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" },
|
||||
{ tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" },
|
||||
{ tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" },
|
||||
{ tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" },
|
||||
{ tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" },
|
||||
{ tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" },
|
||||
{ tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" },
|
||||
{ tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" },
|
||||
{ tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" },
|
||||
{ tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" },
|
||||
]
|
||||
|
||||
function TagGroupTable({
|
||||
group,
|
||||
tags,
|
||||
collapsed,
|
||||
onToggle,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
getGlobalIndex,
|
||||
isEmail,
|
||||
}: {
|
||||
group: { type: string; label: string; tags: TagDef[] }
|
||||
tags: TagDef[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
onAdd: () => void
|
||||
onUpdate: (index: number, field: keyof TagDef, value: string | boolean) => void
|
||||
onRemove: (index: number) => void
|
||||
getGlobalIndex: (type: string, localIndex: number) => number
|
||||
isEmail: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className={cn("size-3.5 transition-transform", !collapsed && "rotate-90")} />
|
||||
{group.label}
|
||||
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{!collapsed && group.tags.length > 0 && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className={cn(
|
||||
"gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider grid",
|
||||
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
||||
)}>
|
||||
<div>Label</div>
|
||||
<div>Description</div>
|
||||
<div>Example</div>
|
||||
{isEmail && <div className="text-center" title="Emails with this label will be excluded from creating notes">Skip notes</div>}
|
||||
<div />
|
||||
</div>
|
||||
{group.tags.map((tag, localIdx) => {
|
||||
const globalIdx = getGlobalIndex(group.type, localIdx)
|
||||
return (
|
||||
<div key={globalIdx} className={cn(
|
||||
"gap-1 border-t px-2 py-0.5 items-center grid",
|
||||
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
|
||||
)}>
|
||||
<Input
|
||||
value={tag.tag}
|
||||
onChange={e => onUpdate(globalIdx, "tag", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="tag-name"
|
||||
title={tag.tag}
|
||||
/>
|
||||
<Input
|
||||
value={tag.description}
|
||||
onChange={e => onUpdate(globalIdx, "description", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Description"
|
||||
title={tag.description}
|
||||
/>
|
||||
<Input
|
||||
value={tag.example || ""}
|
||||
onChange={e => onUpdate(globalIdx, "example", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Example"
|
||||
title={tag.example || ""}
|
||||
/>
|
||||
{isEmail && (
|
||||
<div className="flex justify-center">
|
||||
<Switch
|
||||
checked={tag.noteEffect === "skip"}
|
||||
onCheckedChange={checked => onUpdate(globalIdx, "noteEffect", checked ? "skip" : "create")}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemove(globalIdx)}
|
||||
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && group.tags.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [tags, setTags] = useState<TagDef[]>([])
|
||||
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
const [activeSection, setActiveSection] = useState<"notes" | "email">("notes")
|
||||
|
||||
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
|
||||
const parsed = JSON.parse(result.data)
|
||||
setTags(parsed)
|
||||
setOriginalTags(parsed)
|
||||
} catch {
|
||||
setTags([...DEFAULT_TAGS])
|
||||
setOriginalTags([...DEFAULT_TAGS])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [dialogOpen])
|
||||
|
||||
const noteGroups = useMemo(() => {
|
||||
const map = new Map<string, TagDef[]>()
|
||||
for (const tag of tags) {
|
||||
if (tag.applicability === "email") continue
|
||||
const list = map.get(tag.type) ?? []
|
||||
list.push(tag)
|
||||
map.set(tag.type, list)
|
||||
}
|
||||
return NOTE_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
||||
type,
|
||||
label: TAG_TYPE_LABELS[type],
|
||||
tags: map.get(type) ?? [],
|
||||
}))
|
||||
}, [tags])
|
||||
|
||||
const emailGroups = useMemo(() => {
|
||||
const map = new Map<string, TagDef[]>()
|
||||
for (const tag of tags) {
|
||||
if (tag.applicability === "notes") continue
|
||||
const list = map.get(tag.type) ?? []
|
||||
list.push(tag)
|
||||
map.set(tag.type, list)
|
||||
}
|
||||
return EMAIL_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
|
||||
type,
|
||||
label: TAG_TYPE_LABELS[type],
|
||||
tags: map.get(type) ?? [],
|
||||
}))
|
||||
}, [tags])
|
||||
|
||||
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
|
||||
let count = 0
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (tags[i].type === type) {
|
||||
if (count === localIndex) return i
|
||||
count++
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}, [tags])
|
||||
|
||||
const updateTag = useCallback((index: number, field: keyof TagDef, value: string | boolean) => {
|
||||
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
|
||||
}, [])
|
||||
|
||||
const removeTag = useCallback((index: number) => {
|
||||
setTags(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const addTag = useCallback((type: string) => {
|
||||
const isEmailSection = activeSection === "email"
|
||||
const applicability = isEmailSection ? "email" as const : "notes" as const
|
||||
// For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
|
||||
const emailOnlyTypes = ["email-type", "filter"]
|
||||
const notesOnlyTypes = ["relationship-sub", "source"]
|
||||
let finalApplicability: "email" | "notes" | "both" = "both"
|
||||
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
|
||||
else if (notesOnlyTypes.includes(type)) finalApplicability = "notes"
|
||||
else finalApplicability = isEmailSection ? "email" : applicability
|
||||
|
||||
const newTag: TagDef = {
|
||||
tag: "",
|
||||
type,
|
||||
applicability: finalApplicability === "email" && !isEmailSection ? "both" : finalApplicability === "notes" && isEmailSection ? "both" : finalApplicability,
|
||||
description: "",
|
||||
noteEffect: isEmailSection ? "create" : "none",
|
||||
}
|
||||
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
|
||||
if (lastIndex === -1) {
|
||||
setTags(prev => [...prev, newTag])
|
||||
} else {
|
||||
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
|
||||
}
|
||||
}, [tags, activeSection])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/tags.json",
|
||||
data: JSON.stringify(tags, null, 2),
|
||||
})
|
||||
setOriginalTags([...tags])
|
||||
toast.success("Tag configuration saved")
|
||||
} catch {
|
||||
toast.error("Failed to save tag configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [tags])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
|
||||
setTags([...DEFAULT_TAGS])
|
||||
}, [])
|
||||
|
||||
const toggleGroup = useCallback((type: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(type)) next.delete(type)
|
||||
else next.add(type)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentGroups = activeSection === "notes" ? noteGroups : emailGroups
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-1 mb-3 border-b">
|
||||
<button
|
||||
onClick={() => setActiveSection("notes")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
||||
activeSection === "notes"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
Note Tags
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection("email")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
|
||||
activeSection === "email"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Mail className="size-3.5" />
|
||||
Email Labels
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
|
||||
{currentGroups.map(group => (
|
||||
<TagGroupTable
|
||||
key={group.type}
|
||||
group={group}
|
||||
tags={tags}
|
||||
collapsed={collapsedGroups.has(group.type)}
|
||||
onToggle={() => toggleGroup(group.type)}
|
||||
onAdd={() => addTag(group.type)}
|
||||
onUpdate={updateTag}
|
||||
onRemove={removeTag}
|
||||
getGlobalIndex={getGlobalIndex}
|
||||
isEmail={activeSection === "email"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-3 border-t mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||
|
|
@ -708,7 +1125,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 +1231,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "models" ? (
|
||||
<ModelSettings dialogOpen={open} />
|
||||
) : activeTab === "note-tagging" ? (
|
||||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : loading ? (
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
98
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal file
98
apps/x/apps/renderer/src/extensions/task-block.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { CalendarClock, X } from 'lucide-react'
|
||||
import { inlineTask } from '@x/shared'
|
||||
|
||||
function TaskBlockView({ node, deleteNode }: { node: { attrs: { data: string } }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data
|
||||
let instruction = ''
|
||||
let scheduleLabel = ''
|
||||
|
||||
try {
|
||||
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
|
||||
instruction = parsed.instruction
|
||||
scheduleLabel = parsed['schedule-label'] ?? ''
|
||||
} catch {
|
||||
// Fallback: show raw data
|
||||
instruction = raw
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
|
||||
<div className="task-block-card">
|
||||
<button
|
||||
className="task-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete task block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="task-block-content">
|
||||
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
|
||||
{scheduleLabel && (
|
||||
<span className="task-block-schedule">
|
||||
<CalendarClock size={12} />
|
||||
{scheduleLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskBlockExtension = Node.create({
|
||||
name: 'taskBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-task') || cls.includes('language-tell-rowboat')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'task-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TaskBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```task\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
367
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
367
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** 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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[cat]) {
|
||||
;(fields as Record<string, unknown>)[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<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {}
|
||||
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, string | string[]>): 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<string, string> = {
|
||||
// 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',
|
||||
'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
|
||||
}
|
||||
|
|
@ -237,6 +237,200 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Frontmatter properties panel between toolbar and editor content */
|
||||
.frontmatter-properties {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--background);
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.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 .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;
|
||||
|
|
@ -337,6 +531,83 @@
|
|||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Task block */
|
||||
.tiptap-editor .ProseMirror .task-block-wrapper {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-card {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
cursor: default;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-card:hover .task-block-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-delete:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-card:hover {
|
||||
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-wrapper.ProseMirror-selectednode .task-block-card {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-prefix {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-instruction {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .task-block-schedule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .tiptap-editor .ProseMirror {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
|
@ -358,6 +629,10 @@
|
|||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .ProseMirror pre code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .ProseMirror code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ff7b72;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -27,9 +26,10 @@ 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";
|
||||
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
|
|
@ -318,19 +318,7 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
}
|
||||
|
||||
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<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: raw,
|
||||
|
|
@ -355,6 +343,81 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return agent;
|
||||
}
|
||||
|
||||
if (id === 'labeling_agent') {
|
||||
const labelingAgentRaw = getLabelingAgentRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
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<typeof Agent> = {
|
||||
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;
|
||||
}
|
||||
|
||||
if (id === 'inline_task_agent') {
|
||||
const inlineTaskAgentRaw = getInlineTaskAgentRaw();
|
||||
let agent: z.infer<typeof Agent> = {
|
||||
name: id,
|
||||
instructions: inlineTaskAgentRaw,
|
||||
};
|
||||
|
||||
if (inlineTaskAgentRaw.startsWith("---")) {
|
||||
const end = inlineTaskAgentRaw.indexOf("\n---", 3);
|
||||
if (end !== -1) {
|
||||
const fm = inlineTaskAgentRaw.slice(3, end).trim();
|
||||
const content = inlineTaskAgentRaw.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<IAgentsRepo>('agentsRepo');
|
||||
return await repo.fetch(id);
|
||||
}
|
||||
|
|
@ -710,7 +773,7 @@ export async function* streamAgent({
|
|||
const provider = await isSignedIn()
|
||||
? await getGatewayProvider()
|
||||
: 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;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
|
|||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
|
||||
|
||||
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending.
|
||||
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always show message drafts to the user before sending.
|
||||
|
||||
## Memory That Compounds
|
||||
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
|
||||
|
|
|
|||
|
|
@ -1,121 +1,124 @@
|
|||
import { slackToolCatalogMarkdown } from "./tool-catalog.js";
|
||||
|
||||
const skill = String.raw`
|
||||
# Slack Integration Skill
|
||||
# Slack Integration Skill (agent-slack CLI)
|
||||
|
||||
You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations.
|
||||
You interact with Slack by running **agent-slack** commands through \`executeCommand\`.
|
||||
|
||||
## Prerequisites
|
||||
---
|
||||
|
||||
## 1. Check Connection
|
||||
|
||||
Before any Slack operation, read \`~/.rowboat/config/slack.json\`. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
|
||||
|
||||
If enabled, use the workspace URLs from the config for all commands.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Commands
|
||||
|
||||
### Messages
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| List recent messages | \`agent-slack message list "#channel-name" --limit 25\` |
|
||||
| List thread replies | \`agent-slack message list "#channel" --thread-ts 1234567890.123456\` |
|
||||
| Get a single message | \`agent-slack message get "https://team.slack.com/archives/C.../p..."\` |
|
||||
| Send a message | \`agent-slack message send "#channel-name" "Hello team!"\` |
|
||||
| Reply in thread | \`agent-slack message send "#channel-name" "Reply text" --thread-ts 1234567890.123456\` |
|
||||
| Edit a message | \`agent-slack message edit "#channel-name" --ts 1234567890.123456 "Updated text"\` |
|
||||
| Delete a message | \`agent-slack message delete "#channel-name" --ts 1234567890.123456\` |
|
||||
|
||||
**Targets** can be:
|
||||
- A full Slack URL: \`https://team.slack.com/archives/C01234567/p1234567890123456\`
|
||||
- A channel name: \`"#general"\` or \`"general"\`
|
||||
- A channel ID: \`C01234567\`
|
||||
|
||||
### Reactions
|
||||
|
||||
Before using Slack tools, ALWAYS check if Slack is connected:
|
||||
\`\`\`
|
||||
slack-checkConnection({})
|
||||
agent-slack message react add "<target>" <emoji> --ts <ts>
|
||||
agent-slack message react remove "<target>" <emoji> --ts <ts>
|
||||
\`\`\`
|
||||
|
||||
If not connected, inform the user they need to connect Slack from the settings/onboarding.
|
||||
### Search
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Check Connection
|
||||
\`\`\`
|
||||
slack-checkConnection({})
|
||||
\`\`\`
|
||||
Returns whether Slack is connected and ready to use.
|
||||
|
||||
### List Users
|
||||
\`\`\`
|
||||
slack-listUsers({ limit: 100 })
|
||||
\`\`\`
|
||||
Lists users in the workspace. Use this to resolve a name to a user ID.
|
||||
|
||||
### List DM Conversations
|
||||
\`\`\`
|
||||
slack-getDirectMessages({ limit: 50 })
|
||||
\`\`\`
|
||||
Lists DM channels (type "im"). Each entry includes the DM channel ID and the user ID.
|
||||
|
||||
### List Channels
|
||||
\`\`\`
|
||||
slack-listChannels({ types: "public_channel,private_channel", limit: 100 })
|
||||
\`\`\`
|
||||
Lists channels the user has access to.
|
||||
|
||||
### Get Conversation History
|
||||
\`\`\`
|
||||
slack-getChannelHistory({ channel: "C01234567", limit: 20 })
|
||||
\`\`\`
|
||||
Fetches recent messages for a channel or DM.
|
||||
|
||||
### Search Messages
|
||||
\`\`\`
|
||||
slack-searchMessages({ query: "in:@username", count: 20 })
|
||||
\`\`\`
|
||||
Searches Slack messages using Slack search syntax.
|
||||
|
||||
### Send a Message
|
||||
\`\`\`
|
||||
slack-sendMessage({ channel: "C01234567", text: "Hello team!" })
|
||||
\`\`\`
|
||||
Sends a message to a channel or DM. Always show the draft first.
|
||||
|
||||
### Execute a Slack Action
|
||||
\`\`\`
|
||||
slack-executeAction({
|
||||
toolSlug: "EXACT_TOOL_SLUG_FROM_DISCOVERY",
|
||||
input: { /* tool-specific parameters */ }
|
||||
})
|
||||
\`\`\`
|
||||
Executes any Slack tool using its exact slug discovered from \`slack-listAvailableTools\`.
|
||||
|
||||
### Discover Available Tools (Fallback)
|
||||
\`\`\`
|
||||
slack-listAvailableTools({ search: "conversation" })
|
||||
\`\`\`
|
||||
Lists available Slack tools from Composio. Use this only if a builtin Slack tool fails and you need a specific slug.
|
||||
|
||||
## Composio Slack Tool Catalog (Pinned)
|
||||
Use the exact tool slugs below with \`slack-executeAction\` when needed. Prefer these over \`slack-listAvailableTools\` to avoid redundant discovery.
|
||||
|
||||
${slackToolCatalogMarkdown}
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Check Connection
|
||||
\`\`\`
|
||||
slack-checkConnection({})
|
||||
agent-slack search messages "query text" --limit 20
|
||||
agent-slack search messages "query" --channel "#channel-name" --user "@username"
|
||||
agent-slack search messages "query" --after 2025-01-01 --before 2025-02-01
|
||||
agent-slack search files "query" --limit 10
|
||||
\`\`\`
|
||||
|
||||
### Step 2: Choose the Builtin Tool
|
||||
Use the builtin Slack tools above for common tasks. Only fall back to \`slack-listAvailableTools\` + \`slack-executeAction\` if something is missing.
|
||||
### Channels
|
||||
|
||||
## Common Tasks
|
||||
\`\`\`
|
||||
agent-slack channel new --name "project-x" --workspace https://team.slack.com
|
||||
agent-slack channel new --name "secret-project" --private
|
||||
agent-slack channel invite --channel "#project-x" --users "@alice,@bob"
|
||||
\`\`\`
|
||||
|
||||
### Find the Most Recent DM with Someone
|
||||
1. Search messages first: \`slack-searchMessages({ query: "in:@Name", count: 1 })\`
|
||||
2. If you need exact DM history:
|
||||
- \`slack-listUsers({})\` to find the user ID
|
||||
- \`slack-getDirectMessages({})\` to find the DM channel for that user
|
||||
- \`slack-getChannelHistory({ channel: "D...", limit: 20 })\`
|
||||
### Users
|
||||
|
||||
### Send a Message
|
||||
1. Draft the message and show it to the user
|
||||
2. ONLY after user approval, send using \`slack-sendMessage\`
|
||||
\`\`\`
|
||||
agent-slack user list --limit 200
|
||||
agent-slack user get "@username"
|
||||
agent-slack user get U01234567
|
||||
\`\`\`
|
||||
|
||||
### Search Messages
|
||||
1. Use \`slack-searchMessages({ query: "...", count: 20 })\`
|
||||
### Canvases
|
||||
|
||||
\`\`\`
|
||||
agent-slack canvas get "https://team.slack.com/docs/F01234567"
|
||||
agent-slack canvas get F01234567 --workspace https://team.slack.com
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 3. Multi-Workspace
|
||||
|
||||
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces.
|
||||
|
||||
If the selected workspace list contains multiple entries, use \`--workspace <url>\` to disambiguate:
|
||||
|
||||
\`\`\`
|
||||
agent-slack message list "#general" --workspace https://team.slack.com
|
||||
\`\`\`
|
||||
|
||||
If only one workspace is selected, always use \`--workspace\` with its URL to avoid ambiguity with other authenticated workspaces.
|
||||
|
||||
---
|
||||
|
||||
## 4. Token Budget Control
|
||||
|
||||
Use \`--limit\` to control how many messages/results are returned. Use \`--max-body-chars\` or \`--max-content-chars\` to truncate long message bodies:
|
||||
|
||||
\`\`\`
|
||||
agent-slack message list "#channel" --limit 10
|
||||
agent-slack search messages "query" --limit 5 --max-content-chars 2000
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 5. Discovering More Commands
|
||||
|
||||
For any command you're unsure about:
|
||||
|
||||
\`\`\`
|
||||
agent-slack --help
|
||||
agent-slack message --help
|
||||
agent-slack search --help
|
||||
agent-slack channel --help
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Always show drafts before sending** - Never send Slack messages without user confirmation
|
||||
- **Summarize, don't dump** - When showing channel history, summarize the key points
|
||||
- **Cross-reference with knowledge base** - Check if mentioned people have notes in the knowledge base
|
||||
|
||||
## Error Handling
|
||||
|
||||
If a Slack operation fails:
|
||||
1. Try \`slack-listAvailableTools\` to verify the tool slug is correct
|
||||
2. Check if Slack is still connected with \`slack-checkConnection\`
|
||||
3. Inform the user of the specific error
|
||||
- **Always show drafts before sending** — Never send Slack messages without user confirmation
|
||||
- **Summarize, don't dump** — When showing channel history, summarize the key points rather than pasting everything
|
||||
- **Prefer Slack URLs** — When referring to messages, use Slack URLs over raw channel names when available
|
||||
- **Use --limit** — Always set reasonable limits to keep output concise and token-efficient
|
||||
- **Resolve user IDs** — Messages contain raw user IDs like \`U078AHJP341\`. Resolve them to real names before presenting to the user. Batch all lookups into a single \`executeCommand\` call using \`;\` separators, e.g. \`agent-slack user get U078AHJP341 --workspace ... ; agent-slack user get U090UEZCEQ0 --workspace ...\`
|
||||
- **Cross-reference with knowledge base** — Check if mentioned people have notes in the knowledge base
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
export type SlackToolDefinition = {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const slackToolCatalog: SlackToolDefinition[] = [
|
||||
{ name: "Add Emoji Alias", slug: "SLACK_ADD_AN_EMOJI_ALIAS_IN_SLACK", description: "Adds an alias for an existing custom emoji." },
|
||||
{ name: "Add Remote File", slug: "SLACK_ADD_A_REMOTE_FILE_FROM_A_SERVICE", description: "Adds a reference to an external file (e.g., GDrive, Dropbox) to Slack." },
|
||||
{ name: "Add Star to Item", slug: "SLACK_ADD_A_STAR_TO_AN_ITEM", description: "Stars a channel, file, comment, or message." },
|
||||
{ name: "Add Call Participants", slug: "SLACK_ADD_CALL_PARTICIPANTS", description: "Registers new participants added to a Slack call." },
|
||||
{ name: "Add Emoji", slug: "SLACK_ADD_EMOJI", description: "Adds a custom emoji to a workspace via a unique name and URL." },
|
||||
{ name: "Add Reaction", slug: "SLACK_ADD_REACTION_TO_AN_ITEM", description: "Adds a specified emoji reaction to a message." },
|
||||
{ name: "Archive Channel", slug: "SLACK_ARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Archives a public or private channel." },
|
||||
{ name: "Archive Conversation", slug: "SLACK_ARCHIVE_A_SLACK_CONVERSATION", description: "Archives a conversation by its ID." },
|
||||
{ name: "Close DM/MPDM", slug: "SLACK_CLOSE_DM_OR_MULTI_PERSON_DM", description: "Closes a DM or MPDM sidebar view for the user." },
|
||||
{ name: "Create Reminder", slug: "SLACK_CREATE_A_REMINDER", description: "Creates a reminder with text and time (natural language supported)." },
|
||||
{ name: "Create User Group", slug: "SLACK_CREATE_A_SLACK_USER_GROUP", description: "Creates a new user group (subteam)." },
|
||||
{ name: "Create Channel", slug: "SLACK_CREATE_CHANNEL", description: "Initiates a public or private channel conversation." },
|
||||
{ name: "Create Channel Conversation", slug: "SLACK_CREATE_CHANNEL_BASED_CONVERSATION", description: "Creates a new channel with specific org-wide or team settings." },
|
||||
{ name: "Customize URL Unfurl", slug: "SLACK_CUSTOMIZE_URL_UNFURL", description: "Defines custom content for URL previews in a specific message." },
|
||||
{ name: "Delete File Comment", slug: "SLACK_DELETE_A_COMMENT_ON_A_FILE", description: "Deletes a specific comment from a file." },
|
||||
{ name: "Delete File", slug: "SLACK_DELETE_A_FILE_BY_ID", description: "Permanently deletes a file by its ID." },
|
||||
{ name: "Delete Channel", slug: "SLACK_DELETE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Irreversibly deletes a channel and its history (Enterprise only)." },
|
||||
{ name: "Delete Scheduled Message", slug: "SLACK_DELETE_A_SCHEDULED_MESSAGE_IN_A_CHAT", description: "Deletes a pending scheduled message." },
|
||||
{ name: "Delete Reminder", slug: "SLACK_DELETE_A_SLACK_REMINDER", description: "Deletes an existing reminder." },
|
||||
{ name: "Delete Message", slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", description: "Deletes a message by channel ID and timestamp." },
|
||||
{ name: "Delete Profile Photo", slug: "SLACK_DELETE_USER_PROFILE_PHOTO", description: "Reverts the user's profile photo to the default avatar." },
|
||||
{ name: "Disable User Group", slug: "SLACK_DISABLE_AN_EXISTING_SLACK_USER_GROUP", description: "Disables (archives) a user group." },
|
||||
{ name: "Enable User Group", slug: "SLACK_ENABLE_A_SPECIFIED_USER_GROUP", description: "Reactivates a disabled user group." },
|
||||
{ name: "Share File Publicly", slug: "SLACK_ENABLE_PUBLIC_SHARING_OF_A_FILE", description: "Generates a public URL for a file." },
|
||||
{ name: "End Call", slug: "SLACK_END_A_CALL_WITH_DURATION_AND_ID", description: "Ends an ongoing call." },
|
||||
{ name: "End Snooze", slug: "SLACK_END_SNOOZE", description: "Ends the current user's snooze mode immediately." },
|
||||
{ name: "End DND Session", slug: "SLACK_END_USER_DO_NOT_DISTURB_SESSION", description: "Ends the current DND session." },
|
||||
{ name: "Fetch Bot Info", slug: "SLACK_FETCH_BOT_USER_INFORMATION", description: "Fetches metadata for a specific bot user." },
|
||||
{ name: "Fetch History", slug: "SLACK_FETCH_CONVERSATION_HISTORY", description: "Fetches chronological messages and events from a channel." },
|
||||
{ name: "Fetch Item Reactions", slug: "SLACK_FETCH_ITEM_REACTIONS", description: "Fetches all reactions for a message, file, or comment." },
|
||||
{ name: "Retrieve Replies", slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", description: "Retrieves replies to a specific parent message." },
|
||||
{ name: "Fetch Team Info", slug: "SLACK_FETCH_TEAM_INFO", description: "Fetches comprehensive metadata about the team." },
|
||||
{ name: "Fetch Workspace Settings", slug: "SLACK_FETCH_WORKSPACE_SETTINGS_INFORMATION", description: "Retrieves detailed settings for a specific workspace." },
|
||||
{ name: "Find Channels", slug: "SLACK_FIND_CHANNELS", description: "Searches channels by name, topic, or purpose." },
|
||||
{ name: "Find User by Email", slug: "SLACK_FIND_USER_BY_EMAIL_ADDRESS", description: "Finds a user object using their email address." },
|
||||
{ name: "Find Users", slug: "SLACK_FIND_USERS", description: "Searches users by name, email, or display name." },
|
||||
{ name: "Get Conversation Preferences", slug: "SLACK_GET_CHANNEL_CONVERSATION_PREFERENCES", description: "Retrieves posting/threading preferences for a channel." },
|
||||
{ name: "Get Reminder Info", slug: "SLACK_GET_REMINDER_INFORMATION", description: "Retrieves detailed information for a specific reminder." },
|
||||
{ name: "Get Remote File", slug: "SLACK_GET_REMOTE_FILE", description: "Retrieves info about a previously added remote file." },
|
||||
{ name: "Get Team DND Status", slug: "SLACK_GET_TEAM_DND_STATUS", description: "Retrieves the DND status for specific users." },
|
||||
{ name: "Get User Presence", slug: "SLACK_GET_USER_PRESENCE_INFO", description: "Retrieves real-time presence (active/away)." },
|
||||
{ name: "Invite to Channel", slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", description: "Invites users to a channel by their user IDs." },
|
||||
{ name: "Invite to Workspace", slug: "SLACK_INVITE_USER_TO_WORKSPACE", description: "Invites a user to a workspace and channels via email." },
|
||||
{ name: "Join Conversation", slug: "SLACK_JOIN_AN_EXISTING_CONVERSATION", description: "Joins a conversation by channel ID." },
|
||||
{ name: "Leave Conversation", slug: "SLACK_LEAVE_A_CONVERSATION", description: "Leaves a conversation." },
|
||||
{ name: "List All Channels", slug: "SLACK_LIST_ALL_CHANNELS", description: "Lists all conversations with various filters." },
|
||||
{ name: "List All Users", slug: "SLACK_LIST_ALL_USERS", description: "Retrieves a paginated list of all users in the workspace." },
|
||||
{ name: "List User Group Members", slug: "SLACK_LIST_ALL_USERS_IN_A_USER_GROUP", description: "Lists all user IDs within a group." },
|
||||
{ name: "List Conversations", slug: "SLACK_LIST_CONVERSATIONS", description: "Retrieves conversations accessible to a specific user." },
|
||||
{ name: "List Files", slug: "SLACK_LIST_FILES_WITH_FILTERS_IN_SLACK", description: "Lists files and metadata with filtering options." },
|
||||
{ name: "List Reminders", slug: "SLACK_LIST_REMINDERS", description: "Lists all reminders for the authenticated user." },
|
||||
{ name: "List Remote Files", slug: "SLACK_LIST_REMOTE_FILES", description: "Retrieves info about a team's remote files." },
|
||||
{ name: "List Scheduled Messages", slug: "SLACK_LIST_SCHEDULED_MESSAGES", description: "Lists pending scheduled messages." },
|
||||
{ name: "List Pinned Items", slug: "SLACK_LISTS_PINNED_ITEMS_IN_A_CHANNEL", description: "Retrieves all messages/files pinned to a channel." },
|
||||
{ name: "List Starred Items", slug: "SLACK_LIST_STARRED_ITEMS", description: "Lists items starred by the user." },
|
||||
{ name: "List Custom Emojis", slug: "SLACK_LIST_TEAM_CUSTOM_EMOJIS", description: "Lists all workspace custom emojis and their URLs." },
|
||||
{ name: "List User Groups", slug: "SLACK_LIST_USER_GROUPS_FOR_TEAM_WITH_OPTIONS", description: "Lists user-created and default user groups." },
|
||||
{ name: "List User Reactions", slug: "SLACK_LIST_USER_REACTIONS", description: "Lists all reactions added by a specific user." },
|
||||
{ name: "List Admin Users", slug: "SLACK_LIST_WORKSPACE_USERS", description: "Retrieves a paginated list of workspace administrators." },
|
||||
{ name: "Set User Presence", slug: "SLACK_MANUALLY_SET_USER_PRESENCE", description: "Manually overrides automated presence status." },
|
||||
{ name: "Mark Reminder Complete", slug: "SLACK_MARK_REMINDER_AS_COMPLETE", description: "Marks a reminder as complete (deprecated by Slack in March 2023)." },
|
||||
{ name: "Open DM", slug: "SLACK_OPEN_DM", description: "Opens/resumes a DM or MPDM." },
|
||||
{ name: "Pin Item", slug: "SLACK_PINS_AN_ITEM_TO_A_CHANNEL", description: "Pins a message to a channel." },
|
||||
{ name: "Remove Remote File", slug: "SLACK_REMOVE_A_REMOTE_FILE", description: "Removes a reference to an external file." },
|
||||
{ name: "Remove Star", slug: "SLACK_REMOVE_A_STAR_FROM_AN_ITEM", description: "Unstars an item." },
|
||||
{ name: "Remove from Channel", slug: "SLACK_REMOVE_A_USER_FROM_A_CONVERSATION", description: "Removes a specified user from a conversation." },
|
||||
{ name: "Remove Call Participants", slug: "SLACK_REMOVE_CALL_PARTICIPANTS", description: "Registers the removal of participants from a call." },
|
||||
{ name: "Remove Reaction", slug: "SLACK_REMOVE_REACTION_FROM_ITEM", description: "Removes an emoji reaction from an item." },
|
||||
{ name: "Rename Conversation", slug: "SLACK_RENAME_A_CONVERSATION", description: "Renames a channel ID/Conversation." },
|
||||
{ name: "Rename Emoji", slug: "SLACK_RENAME_AN_EMOJI", description: "Renames a custom emoji." },
|
||||
{ name: "Rename Channel", slug: "SLACK_RENAME_A_SLACK_CHANNEL", description: "Renames a public or private channel." },
|
||||
{ name: "Retrieve Identity", slug: "SLACK_RETRIEVE_A_USER_S_IDENTITY_DETAILS", description: "Retrieves basic user/team identity details." },
|
||||
{ name: "Retrieve Call Info", slug: "SLACK_RETRIEVE_CALL_INFORMATION", description: "Retrieves a snapshot of a call's status." },
|
||||
{ name: "Retrieve Conversation Info", slug: "SLACK_RETRIEVE_CONVERSATION_INFORMATION", description: "Retrieves metadata for a specific conversation." },
|
||||
{ name: "Get Conversation Members", slug: "SLACK_RETRIEVE_CONVERSATION_MEMBERS_LIST", description: "Lists active user IDs in a conversation." },
|
||||
{ name: "Retrieve User DND", slug: "SLACK_RETRIEVE_CURRENT_USER_DND_STATUS", description: "Retrieves DND status for a user." },
|
||||
{ name: "Retrieve File Details", slug: "SLACK_RETRIEVE_DETAILED_INFORMATION_ABOUT_A_FILE", description: "Retrieves metadata and comments for a file." },
|
||||
{ name: "Retrieve User Details", slug: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", description: "Retrieves comprehensive info for a specific user ID." },
|
||||
{ name: "Get Message Permalink", slug: "SLACK_RETRIEVE_MESSAGE_PERMALINK_URL", description: "Gets the permalink URL for a specific message." },
|
||||
{ name: "Retrieve Team Profile", slug: "SLACK_RETRIEVE_TEAM_PROFILE_DETAILS", description: "Retrieves the profile field structure for a team." },
|
||||
{ name: "Retrieve User Profile", slug: "SLACK_RETRIEVE_USER_PROFILE_INFORMATION", description: "Retrieves specific profile info for a user." },
|
||||
{ name: "Revoke Public File", slug: "SLACK_REVOKE_PUBLIC_SHARING_ACCESS_FOR_A_FILE", description: "Revokes a file's public sharing URL." },
|
||||
{ name: "Schedule Message", slug: "SLACK_SCHEDULE_MESSAGE", description: "Schedules a message for a future time (up to 120 days)." },
|
||||
{ name: "Search Messages", slug: "SLACK_SEARCH_MESSAGES", description: "Workspace-wide message search with advanced filters." },
|
||||
{ name: "Send Ephemeral", slug: "SLACK_SEND_EPHEMERAL_MESSAGE", description: "Sends a message visible only to a specific user." },
|
||||
{ name: "Send Message", slug: "SLACK_SEND_MESSAGE", description: "Posts a message to a channel, DM, or group." },
|
||||
{ name: "Set Conversation Purpose", slug: "SLACK_SET_A_CONVERSATION_S_PURPOSE", description: "Updates the purpose description of a channel." },
|
||||
{ name: "Set DND Duration", slug: "SLACK_SET_DND_DURATION", description: "Turns on DND or changes its current duration." },
|
||||
{ name: "Set Profile Photo", slug: "SLACK_SET_PROFILE_PHOTO", description: "Sets the user's profile image with cropping." },
|
||||
{ name: "Set Read Cursor", slug: "SLACK_SET_READ_CURSOR_IN_A_CONVERSATION", description: "Marks a specific timestamp as read." },
|
||||
{ name: "Set User Profile", slug: "SLACK_SET_SLACK_USER_PROFILE_INFORMATION", description: "Updates individual or multiple user profile fields." },
|
||||
{ name: "Set Conversation Topic", slug: "SLACK_SET_THE_TOPIC_OF_A_CONVERSATION", description: "Updates the topic of a conversation." },
|
||||
{ name: "Share Me Message", slug: "SLACK_SHARE_A_ME_MESSAGE_IN_A_CHANNEL", description: "Sends a third-person user action message (/me)." },
|
||||
{ name: "Share Remote File", slug: "SLACK_SHARE_REMOTE_FILE_IN_CHANNELS", description: "Shares a registered remote file into channels." },
|
||||
{ name: "Start Call", slug: "SLACK_START_CALL", description: "Registers a new call for third-party integration." },
|
||||
{ name: "Start RTM Session", slug: "SLACK_START_REAL_TIME_MESSAGING_SESSION", description: "Initiates a real-time messaging WebSocket session." },
|
||||
{ name: "Unarchive Channel", slug: "SLACK_UNARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Unarchives a specific channel." },
|
||||
{ name: "Unarchive Conversation", slug: "SLACK_UNARCHIVE_CHANNEL", description: "Reverses archival for a conversation." },
|
||||
{ name: "Unpin Item", slug: "SLACK_UNPIN_ITEM_FROM_CHANNEL", description: "Unpins a message from a channel." },
|
||||
{ name: "Update User Group", slug: "SLACK_UPDATE_AN_EXISTING_SLACK_USER_GROUP", description: "Updates name, handle, or channels for a user group." },
|
||||
{ name: "Update Remote File", slug: "SLACK_UPDATES_AN_EXISTING_REMOTE_FILE", description: "Updates metadata for a remote file reference." },
|
||||
{ name: "Update Message", slug: "SLACK_UPDATES_A_SLACK_MESSAGE", description: "Modifies the content of an existing message." },
|
||||
{ name: "Update Call Info", slug: "SLACK_UPDATE_SLACK_CALL_INFORMATION", description: "Updates call title or join URLs." },
|
||||
{ name: "Update Group Members", slug: "SLACK_UPDATE_USER_GROUP_MEMBERS", description: "Replaces the member list of a user group." },
|
||||
{ name: "Upload File", slug: "SLACK_UPLOAD_OR_CREATE_A_FILE_IN_SLACK", description: "Uploads content or binary files to Slack." },
|
||||
];
|
||||
|
||||
export const slackToolCatalogMarkdown = slackToolCatalog
|
||||
.map((tool) => `- ${tool.name} (${tool.slug}) - ${tool.description}`)
|
||||
.join("\n");
|
||||
|
|
@ -12,9 +12,6 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js";
|
|||
import * as workspace from "../../workspace/workspace.js";
|
||||
import { IAgentsRepo } from "../../agents/repo.js";
|
||||
import { WorkDir } from "../../config/config.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js";
|
||||
import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
|
|
@ -38,232 +35,6 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({
|
|||
isAvailable: z.custom<() => Promise<boolean>>().optional(),
|
||||
}));
|
||||
|
||||
type SlackToolHint = {
|
||||
search?: string;
|
||||
patterns: string[];
|
||||
fallbackSlugs?: string[];
|
||||
preferSlugIncludes?: string[];
|
||||
excludePatterns?: string[];
|
||||
minScore?: number;
|
||||
};
|
||||
|
||||
const slackToolHints: Record<string, SlackToolHint> = {
|
||||
sendMessage: {
|
||||
search: "message",
|
||||
patterns: ["send", "message", "channel"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_SEND_MESSAGE",
|
||||
"SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL",
|
||||
"SLACK_SEND_A_MESSAGE",
|
||||
],
|
||||
},
|
||||
listConversations: {
|
||||
search: "conversation",
|
||||
patterns: ["list", "conversation", "channel"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_LIST_CONVERSATIONS",
|
||||
"SLACK_LIST_ALL_CHANNELS",
|
||||
"SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS",
|
||||
"SLACK_LIST_CHANNELS",
|
||||
"SLACK_LIST_CHANNEL",
|
||||
],
|
||||
preferSlugIncludes: ["list", "conversation"],
|
||||
minScore: 2,
|
||||
},
|
||||
getConversationHistory: {
|
||||
search: "history",
|
||||
patterns: ["history", "conversation", "message"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_FETCH_CONVERSATION_HISTORY",
|
||||
"SLACK_FETCHES_CONVERSATION_HISTORY",
|
||||
"SLACK_GET_CONVERSATION_HISTORY",
|
||||
"SLACK_GET_CHANNEL_HISTORY",
|
||||
],
|
||||
preferSlugIncludes: ["history"],
|
||||
minScore: 2,
|
||||
},
|
||||
listUsers: {
|
||||
search: "user",
|
||||
patterns: ["list", "user"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_LIST_ALL_USERS",
|
||||
"SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION",
|
||||
"SLACK_LIST_USERS",
|
||||
"SLACK_GET_USERS",
|
||||
"SLACK_USERS_LIST",
|
||||
],
|
||||
preferSlugIncludes: ["list", "user"],
|
||||
excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"],
|
||||
minScore: 2,
|
||||
},
|
||||
getUserInfo: {
|
||||
search: "user",
|
||||
patterns: ["user", "info", "profile"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_GET_USER_INFO",
|
||||
"SLACK_GET_USER",
|
||||
"SLACK_USER_INFO",
|
||||
],
|
||||
preferSlugIncludes: ["user", "info"],
|
||||
minScore: 1,
|
||||
},
|
||||
searchMessages: {
|
||||
search: "search",
|
||||
patterns: ["search", "message"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY",
|
||||
"SLACK_SEARCH_MESSAGES",
|
||||
"SLACK_SEARCH_MESSAGE",
|
||||
],
|
||||
preferSlugIncludes: ["search"],
|
||||
minScore: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const slackToolSlugCache = new Map<string, string>();
|
||||
|
||||
const slackToolSlugOverrides: Partial<Record<keyof typeof slackToolHints, string>> = {
|
||||
sendMessage: "SLACK_SEND_MESSAGE",
|
||||
listConversations: "SLACK_LIST_CONVERSATIONS",
|
||||
getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY",
|
||||
listUsers: "SLACK_LIST_ALL_USERS",
|
||||
getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION",
|
||||
searchMessages: "SLACK_SEARCH_MESSAGES",
|
||||
};
|
||||
|
||||
const compactObject = (input: Record<string, unknown>) =>
|
||||
Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
||||
|
||||
type SlackToolResult = { success: boolean; data?: unknown; error?: string };
|
||||
|
||||
/** Helper to execute a Slack tool with consistent account validation and error handling */
|
||||
async function executeSlackTool(
|
||||
hintKey: keyof typeof slackToolHints,
|
||||
params: Record<string, unknown>
|
||||
): Promise<SlackToolResult> {
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return { success: false, error: 'Slack is not connected' };
|
||||
}
|
||||
try {
|
||||
const toolSlug = await resolveSlackToolSlug(hintKey);
|
||||
return await executeComposioAction(toolSlug, account.id, compactObject(params));
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) =>
|
||||
`${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase();
|
||||
|
||||
const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => {
|
||||
const slug = tool.slug.toLowerCase();
|
||||
const name = (tool.name || "").toLowerCase();
|
||||
const description = (tool.description || "").toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
for (const pattern of patterns) {
|
||||
const needle = pattern.toLowerCase();
|
||||
if (slug.includes(needle)) score += 3;
|
||||
if (name.includes(needle)) score += 2;
|
||||
if (description.includes(needle)) score += 1;
|
||||
}
|
||||
return score;
|
||||
};
|
||||
|
||||
const pickSlackTool = (
|
||||
tools: Array<{ slug: string; name?: string; description?: string }>,
|
||||
hint: SlackToolHint,
|
||||
) => {
|
||||
let candidates = tools;
|
||||
|
||||
if (hint.excludePatterns && hint.excludePatterns.length > 0) {
|
||||
candidates = candidates.filter((tool) => {
|
||||
const haystack = normalizeSlackTool(tool);
|
||||
return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) {
|
||||
const preferred = candidates.filter((tool) =>
|
||||
hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase()))
|
||||
);
|
||||
if (preferred.length > 0) {
|
||||
candidates = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
let best: { slug: string; name?: string; description?: string } | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const tool of candidates) {
|
||||
const score = scoreSlackTool(tool, hint.patterns);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = tool;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return best;
|
||||
};
|
||||
|
||||
const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => {
|
||||
const cached = slackToolSlugCache.get(hintKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const hint = slackToolHints[hintKey];
|
||||
|
||||
const override = slackToolSlugOverrides[hintKey];
|
||||
if (override && slackToolCatalog.some((tool) => tool.slug === override)) {
|
||||
slackToolSlugCache.set(hintKey, override);
|
||||
return override;
|
||||
}
|
||||
const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => {
|
||||
if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) {
|
||||
const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase()));
|
||||
const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase()));
|
||||
if (fallback) return fallback.slug;
|
||||
}
|
||||
|
||||
const best = pickSlackTool(tools, hint);
|
||||
return best?.slug || null;
|
||||
};
|
||||
|
||||
const initialTools = slackToolCatalog;
|
||||
|
||||
if (!initialTools.length) {
|
||||
throw new Error("No Slack tools returned from Composio");
|
||||
}
|
||||
|
||||
const initialSlug = resolveFromTools(initialTools);
|
||||
if (initialSlug) {
|
||||
slackToolSlugCache.set(hintKey, initialSlug);
|
||||
return initialSlug;
|
||||
}
|
||||
|
||||
const allSlug = resolveFromTools(slackToolCatalog);
|
||||
|
||||
if (!allSlug) {
|
||||
const fallback = await listToolkitTools("slack", hint.search || null);
|
||||
const fallbackSlug = resolveFromTools(fallback.items || []);
|
||||
if (!fallbackSlug) {
|
||||
throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`);
|
||||
}
|
||||
slackToolSlugCache.set(hintKey, fallbackSlug);
|
||||
return fallbackSlug;
|
||||
}
|
||||
|
||||
slackToolSlugCache.set(hintKey, allSlug);
|
||||
return allSlug;
|
||||
};
|
||||
|
||||
const LLMPARSE_MIME_TYPES: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
|
|
@ -1113,164 +884,6 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Slack Tools (via Composio)
|
||||
// ============================================================================
|
||||
|
||||
'slack-checkConnection': {
|
||||
description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
if (!isComposioConfigured()) {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'Composio is not configured. Please set up your Composio API key first.',
|
||||
};
|
||||
}
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'Slack is not connected. Please connect Slack from the settings.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
accountId: account.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listAvailableTools': {
|
||||
description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.',
|
||||
inputSchema: z.object({
|
||||
search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'),
|
||||
}),
|
||||
execute: async ({ search }: { search?: string }) => {
|
||||
if (!isComposioConfigured()) {
|
||||
return { success: false, error: 'Composio is not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listToolkitTools('slack', search || null);
|
||||
return {
|
||||
success: true,
|
||||
tools: result.items,
|
||||
count: result.items.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'slack-executeAction': {
|
||||
description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.',
|
||||
inputSchema: z.object({
|
||||
toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'),
|
||||
input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'),
|
||||
}),
|
||||
execute: async ({ toolSlug, input }: { toolSlug: string; input: Record<string, unknown> }) => {
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return { success: false, error: 'Slack is not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeComposioAction(toolSlug, account.id, input);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'slack-sendMessage': {
|
||||
description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.',
|
||||
inputSchema: z.object({
|
||||
channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'),
|
||||
text: z.string().describe('The message text to send'),
|
||||
}),
|
||||
execute: async ({ channel, text }: { channel: string; text: string }) => {
|
||||
return executeSlackTool("sendMessage", { channel, text });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listChannels': {
|
||||
description: 'List Slack channels the user has access to. Returns channel IDs and names.',
|
||||
inputSchema: z.object({
|
||||
types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'),
|
||||
limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'),
|
||||
}),
|
||||
execute: async ({ types, limit }: { types?: string; limit?: number }) => {
|
||||
return executeSlackTool("listConversations", {
|
||||
types: types || "public_channel,private_channel",
|
||||
limit: limit ?? 100,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getChannelHistory': {
|
||||
description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.',
|
||||
inputSchema: z.object({
|
||||
channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'),
|
||||
limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'),
|
||||
}),
|
||||
execute: async ({ channel, limit }: { channel: string; limit?: number }) => {
|
||||
return executeSlackTool("getConversationHistory", {
|
||||
channel,
|
||||
limit: limit !== undefined ? Math.min(limit, 100) : 20,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listUsers': {
|
||||
description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().optional().describe('Maximum number of users to return (default: 100)'),
|
||||
}),
|
||||
execute: async ({ limit }: { limit?: number }) => {
|
||||
return executeSlackTool("listUsers", { limit: limit ?? 100 });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getUserInfo': {
|
||||
description: 'Get detailed information about a specific Slack user by their user ID.',
|
||||
inputSchema: z.object({
|
||||
user: z.string().describe('User ID to get info for (e.g., U01234567)'),
|
||||
}),
|
||||
execute: async ({ user }: { user: string }) => {
|
||||
return executeSlackTool("getUserInfo", { user });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-searchMessages': {
|
||||
description: 'Search for messages in Slack. Find messages containing specific text across channels.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Search query text'),
|
||||
count: z.number().optional().describe('Maximum number of results (default: 20)'),
|
||||
}),
|
||||
execute: async ({ query, count }: { query: string; count?: number }) => {
|
||||
return executeSlackTool("searchMessages", { query, count: count ?? 20 });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getDirectMessages': {
|
||||
description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'),
|
||||
}),
|
||||
execute: async ({ limit }: { limit?: number }) => {
|
||||
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Web Search (Brave Search API)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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']);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export const ROWBOAT_AI_GATEWAY_BASE_URL =
|
||||
process.env.ROWBOAT_AI_GATEWAY_BASE_URL || 'http://localhost:3002/v1';
|
||||
export const API_URL =
|
||||
process.env.API_URL || 'http://localhost:3002/v1';
|
||||
|
||||
export const SUPABASE_PROJECT_URL =
|
||||
process.env.SUPABASE_PROJECT_URL || 'http://127.0.0.1:54321';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/re
|
|||
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
|
||||
|
||||
const container = createContainer({
|
||||
injectionMode: InjectionMode.PROXY,
|
||||
|
|
@ -37,6 +38,7 @@ container.register({
|
|||
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
||||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||
});
|
||||
|
||||
export default container;
|
||||
|
|
@ -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<void> {
|
|||
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<boolean> {
|
|||
async function processAllSources(): Promise<void> {
|
||||
console.log('[GraphBuilder] Checking for new content in all sources...');
|
||||
|
||||
// Auto-configure strictness on first run if not already done
|
||||
autoConfigureStrictnessIfNeeded();
|
||||
|
||||
let anyFilesProcessed = false;
|
||||
|
||||
|
|
@ -555,7 +564,19 @@ async function processAllSources(): Promise<void> {
|
|||
}
|
||||
|
||||
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}`);
|
||||
|
|
|
|||
27
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal file
27
apps/x/packages/core/src/knowledge/inline_task_agent.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { BuiltinTools } from '../application/lib/builtin-tools.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
const toolEntries = Object.keys(BuiltinTools)
|
||||
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
|
||||
.join('\n');
|
||||
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
${toolEntries}
|
||||
---
|
||||
# Task
|
||||
|
||||
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it.
|
||||
|
||||
# Instructions
|
||||
|
||||
1. You will receive the full content of a knowledge note and a specific instruction extracted from a \`@rowboat <instruction>\` line in that note.
|
||||
2. Execute the instruction using your full workspace tool set. You have access to read files, edit files, search, run commands, etc.
|
||||
3. Use the surrounding note content as context for the task.
|
||||
4. Your response will be inserted directly into the note below the @rowboat instruction. Write your output as note content — it must read naturally as part of the document.
|
||||
5. NEVER include meta-commentary, thinking out loud, or narration about what you're doing. No "Let me look that up", "Here are the details", "I found the following", etc. Just write the content itself.
|
||||
6. Keep the result concise and well-formatted in markdown.
|
||||
7. Do not modify the original note file — the service will handle inserting your response.
|
||||
`;
|
||||
}
|
||||
626
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal file
626
apps/x/packages/core/src/knowledge/inline_tasks.ts
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { inlineTask } from '@x/shared';
|
||||
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
const INLINE_TASK_AGENT = 'inline_task_agent';
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal frontmatter helpers (duplicated from renderer to avoid cross-package
|
||||
// dependency — can be moved to shared later).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 };
|
||||
}
|
||||
const closingEnd = endIndex + 4;
|
||||
const raw = content.slice(0, closingEnd);
|
||||
let body = content.slice(closingEnd);
|
||||
if (body.startsWith('\n')) {
|
||||
body = body.slice(1);
|
||||
}
|
||||
return { raw, body };
|
||||
}
|
||||
|
||||
function joinFrontmatter(raw: string | null, body: string): string {
|
||||
if (!raw) return body;
|
||||
return raw + '\n' + body;
|
||||
}
|
||||
|
||||
function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
currentKey = key;
|
||||
result[key] = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function buildFrontmatter(fields: Record<string, string | string[]>): 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---`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InlineTaskSchedule =
|
||||
| { type: 'cron'; expression: string; startDate: string; endDate: string; label: string }
|
||||
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string; label: string }
|
||||
| { type: 'once'; runAt: string; label: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scanDirectoryRecursive(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const files: string[] = [];
|
||||
const entries = fs.readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('.')) continue;
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...scanDirectoryRecursive(fullPath));
|
||||
} else if (stat.isFile() && entry.endsWith('.md')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a run to complete by listening for run-processing-end event
|
||||
*/
|
||||
async function waitForRunCompletion(runId: string): Promise<void> {
|
||||
return new Promise(async (resolve) => {
|
||||
const unsubscribe = await bus.subscribe('*', async (event) => {
|
||||
if (event.type === 'run-processing-end' && event.runId === runId) {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assistant's final text response from a run's log.
|
||||
*/
|
||||
async function extractAgentResponse(runId: string): Promise<string | null> {
|
||||
const run = await fetchRun(runId);
|
||||
// Walk backwards through the log to find the last assistant message
|
||||
for (let i = run.log.length - 1; i >= 0; i--) {
|
||||
const event = run.log[i];
|
||||
if (event.type === 'message' && event.message.role === 'assistant') {
|
||||
const content = event.message.content;
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
// Content may be an array of parts — concatenate text parts
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => (p as { type: 'text'; text: string }).text)
|
||||
.join('');
|
||||
return text || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface InlineTask {
|
||||
instruction: string;
|
||||
schedule: InlineTaskSchedule | null;
|
||||
/** Line index of the opening ```task fence in the body */
|
||||
startLine: number;
|
||||
/** Line index of the closing ``` fence */
|
||||
endLine: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the tell-rowboat block content (JSON format).
|
||||
* Returns { instruction, schedule } or null if not valid JSON.
|
||||
* Also supports legacy @rowboat format.
|
||||
*/
|
||||
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null } | null {
|
||||
const raw = contentLines.join('\n').trim();
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
const parsed = inlineTask.InlineTaskBlockSchema.safeParse(data);
|
||||
if (parsed.success) {
|
||||
return {
|
||||
instruction: parsed.data.instruction,
|
||||
schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null,
|
||||
lastRunAt: parsed.data.lastRunAt ?? null,
|
||||
};
|
||||
}
|
||||
// Fallback for blocks that have instruction but don't fully match schema
|
||||
if (data && typeof data === 'object' && data.instruction) {
|
||||
return {
|
||||
instruction: data.instruction,
|
||||
schedule: data.schedule ?? null,
|
||||
lastRunAt: data.lastRunAt ?? null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Legacy format: @rowboat lines + optional schedule: JSON line
|
||||
}
|
||||
|
||||
// Legacy fallback: parse @rowboat instruction and schedule: line
|
||||
let schedule: InlineTaskSchedule | null = null;
|
||||
const instructionLines: string[] = [];
|
||||
for (const cl of contentLines) {
|
||||
const schedMatch = cl.trim().match(/^schedule:\s*(.+)$/);
|
||||
if (schedMatch) {
|
||||
try {
|
||||
const obj = JSON.parse(schedMatch[1]);
|
||||
if (obj && typeof obj === 'object' && obj.type) {
|
||||
schedule = obj as InlineTaskSchedule;
|
||||
}
|
||||
} catch { /* not JSON schedule, skip */ }
|
||||
} else if (!/^schedule-config:\s/.test(cl.trim())) {
|
||||
instructionLines.push(cl);
|
||||
}
|
||||
}
|
||||
const firstRowboatLine = instructionLines.find(l => l.trim().startsWith('@rowboat'));
|
||||
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
|
||||
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
|
||||
if (!instruction) return null;
|
||||
return { instruction, schedule, lastRunAt: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a scheduled task is due to run.
|
||||
*/
|
||||
function isScheduledTaskDue(schedule: InlineTaskSchedule, lastRunAt: string | null): boolean {
|
||||
const now = new Date();
|
||||
|
||||
// Check startDate/endDate bounds for cron and window
|
||||
if (schedule.type === 'cron' || schedule.type === 'window') {
|
||||
if (schedule.startDate && now < new Date(schedule.startDate)) return false;
|
||||
if (schedule.endDate && now > new Date(schedule.endDate)) return false;
|
||||
}
|
||||
|
||||
switch (schedule.type) {
|
||||
case 'cron': {
|
||||
if (!lastRunAt) return true; // Never run → due
|
||||
try {
|
||||
const lastRun = new Date(lastRunAt);
|
||||
const interval = CronExpressionParser.parse(schedule.expression, {
|
||||
currentDate: lastRun,
|
||||
});
|
||||
const nextRun = interval.next().toDate();
|
||||
return now >= nextRun;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case 'window': {
|
||||
if (!lastRunAt) return true;
|
||||
try {
|
||||
const lastRun = new Date(lastRunAt);
|
||||
const interval = CronExpressionParser.parse(schedule.cron, {
|
||||
currentDate: lastRun,
|
||||
});
|
||||
const nextDate = interval.next().toDate();
|
||||
|
||||
// Check if we're within the time window
|
||||
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
// The cron date must have passed and we need to be in the time window
|
||||
return now >= nextDate && nowMinutes >= startMinutes && nowMinutes <= endMinutes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case 'once': {
|
||||
if (lastRunAt) return false; // Already ran
|
||||
const runAt = new Date(schedule.runAt);
|
||||
return now >= runAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find ```tell-rowboat code blocks in a note body and return tasks that are pending execution.
|
||||
*/
|
||||
function findPendingTasks(body: string): InlineTask[] {
|
||||
const tasks: InlineTask[] = [];
|
||||
const lines = body.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
|
||||
const startLine = i;
|
||||
i++;
|
||||
const contentLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '```') {
|
||||
contentLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const endLine = i; // line with closing ```
|
||||
|
||||
const parsed = parseBlockContent(contentLines);
|
||||
if (parsed) {
|
||||
const { instruction, schedule, lastRunAt } = parsed;
|
||||
|
||||
if (schedule) {
|
||||
if (isScheduledTaskDue(schedule, lastRunAt)) {
|
||||
tasks.push({ instruction, schedule, startLine, endLine });
|
||||
}
|
||||
} else {
|
||||
// One-time task: skip if already ran
|
||||
if (!lastRunAt) {
|
||||
tasks.push({ instruction, schedule: null, startLine, endLine });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the agent result below the tell-rowboat code block in the body.
|
||||
* Returns the updated body string.
|
||||
*/
|
||||
function insertResultBelow(body: string, endLine: number, result: string): string {
|
||||
const lines = body.split('\n');
|
||||
// Insert a blank line + result after the closing ``` fence
|
||||
lines.splice(endLine + 1, 0, '', result);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determine if a note has any "live" tell-rowboat tasks.
|
||||
* A task is live if:
|
||||
* - It's a one-time task that hasn't been completed yet
|
||||
* - It's a scheduled task whose endDate hasn't passed (or has no endDate)
|
||||
* - It's a scheduled task before its startDate (will run in the future)
|
||||
*/
|
||||
function hasLiveTasks(body: string): boolean {
|
||||
const now = new Date();
|
||||
const lines = body.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
|
||||
i++;
|
||||
const contentLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '```') {
|
||||
contentLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
const parsed = parseBlockContent(contentLines);
|
||||
if (!parsed) { i++; continue; }
|
||||
|
||||
const { schedule, lastRunAt } = parsed;
|
||||
|
||||
if (schedule) {
|
||||
if (schedule.type === 'cron' || schedule.type === 'window') {
|
||||
const endDate = schedule.endDate;
|
||||
if (!endDate || now <= new Date(endDate)) {
|
||||
return true;
|
||||
}
|
||||
} else if (schedule.type === 'once') {
|
||||
if (!lastRunAt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// One-time task without schedule: live if never ran
|
||||
if (!lastRunAt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the JSON content inside a task code block to include lastRunAt.
|
||||
* Replaces the content lines between the opening and closing fences.
|
||||
*/
|
||||
function updateBlockData(body: string, startLine: number, endLine: number, lastRunAt: string): string {
|
||||
const lines = body.split('\n');
|
||||
// Content is between startLine+1 and endLine-1
|
||||
const contentLines = lines.slice(startLine + 1, endLine);
|
||||
const raw = contentLines.join('\n').trim();
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
data.lastRunAt = lastRunAt;
|
||||
const updatedJson = JSON.stringify(data);
|
||||
// Replace content lines with the updated JSON (single line)
|
||||
lines.splice(startLine + 1, endLine - startLine - 1, updatedJson);
|
||||
} catch {
|
||||
// Not valid JSON — skip update
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processInlineTasks(): Promise<void> {
|
||||
console.log('[InlineTasks] Checking live notes...');
|
||||
|
||||
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||
console.log('[InlineTasks] Knowledge directory not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const allFiles = scanDirectoryRecursive(KNOWLEDGE_DIR);
|
||||
let totalProcessed = 0;
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { raw, body } = splitFrontmatter(content);
|
||||
const fields = extractAllFrontmatterValues(raw);
|
||||
|
||||
// Only process files marked as live
|
||||
if (fields['live_note'] !== 'true') continue;
|
||||
|
||||
const tasks = findPendingTasks(body);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
// No pending tasks — check if still live, update if not
|
||||
const live = hasLiveTasks(body);
|
||||
if (!live) {
|
||||
fields['live_note'] = 'false';
|
||||
// Remove rowboat_tasks if present (legacy cleanup)
|
||||
delete fields['rowboat_tasks'];
|
||||
const newRaw = buildFrontmatter(fields);
|
||||
const newContent = joinFrontmatter(newRaw, body);
|
||||
try {
|
||||
fs.writeFileSync(filePath, newContent, 'utf-8');
|
||||
const rel = path.relative(WorkDir, filePath);
|
||||
console.log(`[InlineTasks] Marked ${rel} as no longer live`);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(WorkDir, filePath);
|
||||
console.log(`[InlineTasks] Found ${tasks.length} pending task(s) in ${relativePath}`);
|
||||
|
||||
// Process tasks one at a time, bottom-up so line indices stay valid
|
||||
// (inserting content shifts lines below, so process from bottom to top)
|
||||
const sortedTasks = [...tasks].sort((a, b) => b.endLine - a.endLine);
|
||||
|
||||
let currentBody = body;
|
||||
|
||||
for (const task of sortedTasks) {
|
||||
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
|
||||
|
||||
try {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
|
||||
const message = [
|
||||
`Execute the following instruction from the note "${relativePath}":`,
|
||||
'',
|
||||
`**Instruction:** ${task.instruction}`,
|
||||
'',
|
||||
'**Full note content for context:**',
|
||||
'```markdown',
|
||||
content,
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
await createMessage(run.id, message);
|
||||
await waitForRunCompletion(run.id);
|
||||
|
||||
const result = await extractAgentResponse(run.id);
|
||||
if (result) {
|
||||
currentBody = insertResultBelow(currentBody, task.endLine, result);
|
||||
// Update the block JSON with lastRunAt
|
||||
const timestamp = new Date().toISOString();
|
||||
currentBody = updateBlockData(currentBody, task.startLine, task.endLine, timestamp);
|
||||
totalProcessed++;
|
||||
console.log(`[InlineTasks] Task completed`);
|
||||
} else {
|
||||
console.warn(`[InlineTasks] No response from agent for task`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[InlineTasks] Error processing task:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update frontmatter — only manage live_note, remove legacy rowboat_tasks
|
||||
const live = hasLiveTasks(currentBody);
|
||||
fields['live_note'] = live ? 'true' : 'false';
|
||||
delete fields['rowboat_tasks'];
|
||||
const newRaw = buildFrontmatter(fields);
|
||||
const newContent = joinFrontmatter(newRaw, currentBody);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, newContent, 'utf-8');
|
||||
console.log(`[InlineTasks] Updated ${relativePath}`);
|
||||
} catch (error) {
|
||||
console.error(`[InlineTasks] Error writing ${relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalProcessed > 0) {
|
||||
console.log(`[InlineTasks] Done. Processed ${totalProcessed} task(s).`);
|
||||
} else {
|
||||
console.log('[InlineTasks] No pending tasks found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an instruction contains a scheduling intent using the user's configured LLM.
|
||||
* Returns a schedule object or null for one-time tasks.
|
||||
*/
|
||||
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const provider = createProvider(config.provider);
|
||||
const model = provider.languageModel(config.model);
|
||||
|
||||
const now = new Date();
|
||||
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const localEnd = defaultEnd.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const nowISO = now.toISOString();
|
||||
const defaultEndISO = defaultEnd.toISOString();
|
||||
|
||||
const systemPrompt = `You classify whether a user instruction contains a scheduling intent.
|
||||
|
||||
If the instruction implies a recurring or future-scheduled task, return a JSON object with the schedule.
|
||||
If the instruction is a one-time immediate task, return null.
|
||||
|
||||
Every schedule object MUST include a "label" field: a short, plain-English description starting with "runs" that includes the end date (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12").
|
||||
|
||||
Schedule types:
|
||||
1. "cron" — recurring schedule. Return: {"type":"cron","expression":"<cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
|
||||
Use standard 5-field cron (minute hour day-of-month month day-of-week).
|
||||
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
|
||||
Override these if the user specifies a duration (e.g. "for the next 3 days" → endDate = now + 3 days) or a start (e.g. "starting next Monday").
|
||||
Example: "every morning at 8am" → {"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until Mar 12"}
|
||||
|
||||
2. "window" — recurring with a time window. Return: {"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
|
||||
Use when the user specifies a range like "between 8am and 10am". Same startDate/endDate defaults and override rules as cron.
|
||||
|
||||
3. "once" — run once at a specific future time. Return: {"type":"once","runAt":"<ISO 8601 datetime>","label":"<human readable>"}
|
||||
Use when the user says "tomorrow at 3pm", "next Friday", etc. No startDate/endDate for once.
|
||||
|
||||
Current local time: ${localNow}
|
||||
Timezone: ${tz}
|
||||
Current UTC time: ${nowISO}
|
||||
Default end time (local): ${localEnd}
|
||||
|
||||
Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: instruction,
|
||||
});
|
||||
|
||||
let text = result.text.trim();
|
||||
console.log('[classifySchedule] LLM response:', text);
|
||||
// Strip markdown code fences if the LLM wraps the JSON
|
||||
text = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
|
||||
if (text === 'null' || text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as InlineTaskSchedule;
|
||||
} catch (error) {
|
||||
console.error('[classifySchedule] Error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point — runs as independent polling service
|
||||
*/
|
||||
export async function init() {
|
||||
console.log('[InlineTasks] Starting Inline Task Service...');
|
||||
console.log(`[InlineTasks] Will check for task blocks every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||
|
||||
// Initial run
|
||||
await processInlineTasks();
|
||||
|
||||
// Periodic polling
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
|
||||
|
||||
try {
|
||||
await processInlineTasks();
|
||||
} catch (error) {
|
||||
console.error('[InlineTasks] Error in main loop:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
269
apps/x/packages/core/src/knowledge/label_emails.ts
Normal file
269
apps/x/packages/core/src/knowledge/label_emails.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string> }> {
|
||||
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<string>();
|
||||
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
apps/x/packages/core/src/knowledge/labeling_agent.ts
Normal file
59
apps/x/packages/core/src/knowledge/labeling_agent.ts
Normal file
|
|
@ -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.
|
||||
`;
|
||||
}
|
||||
48
apps/x/packages/core/src/knowledge/labeling_state.ts
Normal file
48
apps/x/packages/core/src/knowledge/labeling_state.ts
Normal file
|
|
@ -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<string, { labeledAt: string }>;
|
||||
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);
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
export const raw = `---
|
||||
import { renderNoteTypesBlock } from './note_system.js';
|
||||
import { renderNoteEffectRules } from './tag_system.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
|
|
@ -130,25 +134,15 @@ 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
|
||||
${renderNoteEffectRules()}
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -217,168 +211,40 @@ 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
|
||||
|
||||
**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"
|
||||
|
||||
### 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
|
||||
${renderNoteEffectRules()}
|
||||
|
||||
## 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 +418,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 +494,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 +875,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 +913,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 +948,5 @@ Before completing, verify:
|
|||
- [ ] Dates are YYYY-MM-DD
|
||||
- [ ] Bidirectional links are consistent
|
||||
- [ ] New notes in correct folders
|
||||
`;
|
||||
`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
`;
|
||||
202
apps/x/packages/core/src/knowledge/note_system.ts
Normal file
202
apps/x/packages/core/src/knowledge/note_system.ts
Normal file
|
|
@ -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")}`;
|
||||
}
|
||||
132
apps/x/packages/core/src/knowledge/note_tagging_agent.ts
Normal file
132
apps/x/packages/core/src/knowledge/note_tagging_agent.ts
Normal file
|
|
@ -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()}
|
||||
`;
|
||||
}
|
||||
48
apps/x/packages/core/src/knowledge/note_tagging_state.ts
Normal file
48
apps/x/packages/core/src/knowledge/note_tagging_state.ts
Normal file
|
|
@ -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<string, { taggedAt: string }>;
|
||||
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);
|
||||
}
|
||||
274
apps/x/packages/core/src/knowledge/tag_notes.ts
Normal file
274
apps/x/packages/core/src/knowledge/tag_notes.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string> }> {
|
||||
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<string>();
|
||||
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
230
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
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 type NoteEffect = 'create' | 'skip' | 'none';
|
||||
|
||||
export interface TagDefinition {
|
||||
tag: string;
|
||||
type: TagType;
|
||||
applicability: TagApplicability;
|
||||
description: string;
|
||||
example?: string;
|
||||
/** Whether an email with this tag should create notes ('create'), be skipped ('skip'), or has no effect on note creation ('none'). */
|
||||
noteEffect?: NoteEffect;
|
||||
}
|
||||
|
||||
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
|
||||
|
||||
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
|
||||
// ── Relationship (both) ──────────────────────────────────────────────
|
||||
{ tag: 'investor', type: 'relationship', applicability: 'both', noteEffect: 'create', 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', noteEffect: 'create', description: 'Paying customers', example: 'We\'re seeing great results with Rowboat. Can we discuss expanding to more teams?' },
|
||||
{ tag: 'prospect', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Potential customers', example: 'Thanks for the demo yesterday. We\'re interested in starting a pilot.' },
|
||||
{ tag: 'partner', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Business partners', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
|
||||
{ tag: 'vendor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Service providers you work with', example: 'Here are the updated employment agreements you requested.' },
|
||||
{ tag: 'product', type: 'relationship', applicability: 'both', noteEffect: 'skip', description: 'Products or services you use (automated)', example: 'Your AWS bill for January 2025 is now available.' },
|
||||
{ tag: 'candidate', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Job applicants', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
|
||||
{ tag: 'team', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Internal team members', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
|
||||
{ tag: 'advisor', type: 'relationship', applicability: 'both', noteEffect: 'create', 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', noteEffect: 'create', description: 'Family or friends', example: 'Are you coming to Thanksgiving this year? Let me know your travel dates.' },
|
||||
{ tag: 'press', type: 'relationship', applicability: 'both', noteEffect: 'create', 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', noteEffect: 'create', 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', noteEffect: 'create', 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', noteEffect: 'none', 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', noteEffect: 'none', 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', noteEffect: 'none', 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', noteEffect: 'none', 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', noteEffect: 'none', 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', noteEffect: 'none', description: 'Previously held this relationship, no longer active', example: 'John — Former customer who churned last year.' },
|
||||
{ tag: 'champion', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Internal advocate pushing for you', example: 'Engineer who loves your product and is selling internally.' },
|
||||
{ tag: 'blocker', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person opposing or blocking progress', example: 'CFO resistant to spending on new tools.' },
|
||||
|
||||
// ── Topic (both) ─────────────────────────────────────────────────────
|
||||
{ tag: 'sales', type: 'topic', applicability: 'both', noteEffect: 'create', 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', noteEffect: 'create', 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', noteEffect: 'create', description: 'Contracts, terms, compliance, and legal matters', example: 'Legal has reviewed the MSA. Attached are our requested changes.' },
|
||||
{ tag: 'finance', type: 'topic', applicability: 'both', noteEffect: 'create', 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', noteEffect: 'create', 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', noteEffect: 'create', 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', noteEffect: 'skip', 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', noteEffect: 'create', 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', noteEffect: 'skip', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
|
||||
{ tag: 'health', type: 'topic', applicability: 'both', noteEffect: 'skip', 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', noteEffect: 'skip', description: 'Courses, education, and skill-building', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
|
||||
{ tag: 'research', type: 'topic', applicability: 'both', noteEffect: 'create', 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', noteEffect: 'create', 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', noteEffect: 'create', 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', noteEffect: 'skip', description: 'Meeting and calendar scheduling', example: 'Are you available for a call next Tuesday at 2pm?' },
|
||||
{ tag: 'cold-outreach', type: 'email-type', applicability: 'email', noteEffect: 'skip', 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', noteEffect: 'skip', description: 'Newsletters, marketing emails, and subscriptions', example: 'This week in AI: The latest developments in agent frameworks...' },
|
||||
{ tag: 'notification', type: 'email-type', applicability: 'email', noteEffect: 'skip', 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', noteEffect: 'skip', description: 'Junk and unwanted email', example: 'Congratulations! You\'ve won $1,000,000...' },
|
||||
{ tag: 'promotion', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Marketing offers and sales pitches', example: '50% off all items this weekend only!' },
|
||||
{ tag: 'social', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Social media notifications', example: 'John Smith commented on your post.' },
|
||||
{ tag: 'forums', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Mailing lists and group discussions', example: 'Re: [dev-list] Question about API design' },
|
||||
|
||||
// ── Action ───────────────────────────────────────────────────────────
|
||||
{ tag: 'action-required', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Needs a response or action from you', example: 'Can you send me the pricing by Friday?' },
|
||||
{ tag: 'fyi', type: 'action', applicability: 'email', noteEffect: 'skip', 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', noteEffect: 'create', 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', noteEffect: 'create', description: 'Waiting on a response from them' },
|
||||
|
||||
// ── Status (email) ───────────────────────────────────────────────────
|
||||
{ tag: 'unread', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Not yet processed' },
|
||||
{ tag: 'to-reply', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Need to respond' },
|
||||
{ tag: 'done', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Handled, can be archived' },
|
||||
|
||||
// ── Source (notes only) ──────────────────────────────────────────────
|
||||
{ tag: 'email', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from email' },
|
||||
{ tag: 'meeting', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from meeting transcript' },
|
||||
{ tag: 'browser', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Content captured from web browsing' },
|
||||
{ tag: 'web-search', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Information from web search' },
|
||||
{ tag: 'manual', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Manually entered by user' },
|
||||
{ tag: 'import', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Imported from another system' },
|
||||
|
||||
// ── Status (notes) ──────────────────────────────────────────────────
|
||||
{ tag: 'active', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'Currently relevant, recent activity' },
|
||||
{ tag: 'archived', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No longer active, kept for reference' },
|
||||
{ tag: 'stale', type: 'status', applicability: 'notes', noteEffect: 'none', 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<TagType, string> = {
|
||||
'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<TagType, TagDefinition[]>();
|
||||
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 renderNoteEffectRules(): string {
|
||||
const tags = getTagDefinitions();
|
||||
const skipByType = new Map<string, string[]>();
|
||||
const createByType = new Map<string, string[]>();
|
||||
|
||||
for (const t of tags) {
|
||||
const effect = t.noteEffect ?? 'none';
|
||||
if (effect === 'none') continue;
|
||||
const label = TYPE_LABELS[t.type] ?? t.type;
|
||||
const map = effect === 'skip' ? skipByType : createByType;
|
||||
const list = map.get(label) ?? [];
|
||||
list.push(t.tag.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' '));
|
||||
map.set(label, list);
|
||||
}
|
||||
|
||||
const formatList = (map: Map<string, string[]>) =>
|
||||
Array.from(map.entries()).map(([type, tags]) => `- **${type}:** ${tags.join(', ')}`).join('\n');
|
||||
|
||||
return [
|
||||
`**SKIP if the email has ANY of these labels (skip labels override everything):**`,
|
||||
formatList(skipByType),
|
||||
``,
|
||||
`**CREATE/UPDATE notes if the email has ANY of these labels (and no skip labels present):**`,
|
||||
formatList(createByType),
|
||||
``,
|
||||
`**Logic:** If even one label falls in the "skip" list, skip the email — skip labels are hard filters that override create labels.`,
|
||||
].join('\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);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { IOAuthRepo } from '../auth/repo.js';
|
|||
import { IClientRegistrationRepo } from '../auth/client-repo.js';
|
||||
import { getProviderConfig } from '../auth/providers.js';
|
||||
import * as oauthClient from '../auth/oauth-client.js';
|
||||
import { ROWBOAT_AI_GATEWAY_BASE_URL } from '../config/env.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
|
|
@ -51,7 +51,7 @@ async function getAccessToken(): Promise<string> {
|
|||
export async function getGatewayProvider(): Promise<ProviderV2> {
|
||||
const accessToken = await getAccessToken();
|
||||
return createOpenRouter({
|
||||
baseURL: ROWBOAT_AI_GATEWAY_BASE_URL,
|
||||
baseURL: `${API_URL}/v1/llm`,
|
||||
apiKey: accessToken,
|
||||
});
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ type ProviderSummary = {
|
|||
|
||||
export async function listGatewayModels(): Promise<{ providers: ProviderSummary[] }> {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch(`${ROWBOAT_AI_GATEWAY_BASE_URL}/models`, {
|
||||
const response = await fetch(`${API_URL}/v1/llm/models`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
41
apps/x/packages/core/src/slack/repo.ts
Normal file
41
apps/x/packages/core/src/slack/repo.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { SlackConfig } from './types.js';
|
||||
|
||||
export interface ISlackConfigRepo {
|
||||
getConfig(): Promise<SlackConfig>;
|
||||
setConfig(config: SlackConfig): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSSlackConfigRepo implements ISlackConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, 'config', 'slack.json');
|
||||
private readonly defaultConfig: SlackConfig = { enabled: false, workspaces: [] };
|
||||
|
||||
constructor() {
|
||||
this.ensureConfigFile();
|
||||
}
|
||||
|
||||
private async ensureConfigFile(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<SlackConfig> {
|
||||
try {
|
||||
const content = await fs.readFile(this.configPath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
return SlackConfig.parse(parsed);
|
||||
} catch {
|
||||
return this.defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
async setConfig(config: SlackConfig): Promise<void> {
|
||||
const validated = SlackConfig.parse(config);
|
||||
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
|
||||
}
|
||||
}
|
||||
13
apps/x/packages/core/src/slack/types.ts
Normal file
13
apps/x/packages/core/src/slack/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import z from "zod";
|
||||
|
||||
export const SlackWorkspace = z.object({
|
||||
url: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
export type SlackWorkspace = z.infer<typeof SlackWorkspace>;
|
||||
|
||||
export const SlackConfig = z.object({
|
||||
enabled: z.boolean(),
|
||||
workspaces: z.array(SlackWorkspace).default([]),
|
||||
});
|
||||
export type SlackConfig = z.infer<typeof SlackConfig>;
|
||||
|
|
@ -6,5 +6,6 @@ export * as workspace from './workspace.js';
|
|||
export * as mcp from './mcp.js';
|
||||
export * as agentSchedule from './agent-schedule.js';
|
||||
export * as agentScheduleState from './agent-schedule-state.js';
|
||||
export * as serviceEvents from './service-events.js';
|
||||
export * as serviceEvents from './service-events.js'
|
||||
export * as inlineTask from './inline-task.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
33
apps/x/packages/shared/src/inline-task.ts
Normal file
33
apps/x/packages/shared/src/inline-task.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const InlineTaskScheduleSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('cron'),
|
||||
expression: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('window'),
|
||||
cron: z.string(),
|
||||
startTime: z.string(),
|
||||
endTime: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('once'),
|
||||
runAt: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InlineTaskSchedule = z.infer<typeof InlineTaskScheduleSchema>;
|
||||
|
||||
export const InlineTaskBlockSchema = z.object({
|
||||
instruction: z.string(),
|
||||
schedule: InlineTaskScheduleSchema.optional(),
|
||||
'schedule-label': z.string().optional(),
|
||||
lastRunAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;
|
||||
|
|
@ -271,6 +271,29 @@ const ipcSchemas = {
|
|||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'slack:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
enabled: z.boolean(),
|
||||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||||
}),
|
||||
},
|
||||
'slack:setConfig': {
|
||||
req: z.object({
|
||||
enabled: z.boolean(),
|
||||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'slack:listWorkspaces': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'onboarding:getStatus': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -437,6 +460,19 @@ const ipcSchemas = {
|
|||
})),
|
||||
}),
|
||||
},
|
||||
// Inline task schedule classification
|
||||
'inline-task:classifySchedule': {
|
||||
req: z.object({
|
||||
instruction: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
schedule: z.union([
|
||||
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
|
||||
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
|
||||
z.object({ type: z.literal('once'), runAt: z.string(), label: z.string() }),
|
||||
]).nullable(),
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export const ServiceName = z.enum([
|
|||
'fireflies',
|
||||
'granola',
|
||||
'voice_memo',
|
||||
'email_labeling',
|
||||
'note_tagging',
|
||||
]);
|
||||
|
||||
const ServiceEventBase = z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue