mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 10:56:29 +02:00
email labels
This commit is contained in:
parent
e8a666499a
commit
36a91ca87b
24 changed files with 2276 additions and 3197 deletions
|
|
@ -17,6 +17,8 @@ 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 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 +172,12 @@ app.whenReady().then(async () => {
|
|||
// start knowledge graph builder
|
||||
initGraphBuilder();
|
||||
|
||||
// start email labeling service
|
||||
initEmailLabeling();
|
||||
|
||||
// start note tagging service
|
||||
initNoteTagging();
|
||||
|
||||
// start background agent runner (scheduled agents)
|
||||
initAgentRunner();
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter, extractTags } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
|
|
@ -509,6 +510,10 @@ function App() {
|
|||
const initialContentRef = useRef<string>('')
|
||||
const renameInProgressRef = useRef(false)
|
||||
|
||||
// Frontmatter state: store raw frontmatter per file path, tags for active file
|
||||
const frontmatterByPathRef = useRef<Map<string, string | null>>(new Map())
|
||||
const [activeFileTags, setActiveFileTags] = useState<string[]>([])
|
||||
|
||||
// Version history state
|
||||
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
||||
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
|
||||
|
|
@ -892,12 +897,15 @@ 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
|
||||
setActiveFileTags(extractTags(fm))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -926,6 +934,7 @@ function App() {
|
|||
editorContentRef.current = cachedContent
|
||||
editorPathRef.current = selectedPath
|
||||
initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
|
||||
setActiveFileTags(extractTags(frontmatterByPathRef.current.get(selectedPath) ?? null))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -940,6 +949,8 @@ function App() {
|
|||
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)
|
||||
|
|
@ -949,15 +960,16 @@ function App() {
|
|||
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline)
|
||||
const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits
|
||||
if (!shouldPreserveActiveDraft) {
|
||||
setEditorContent(result.data)
|
||||
setEditorContent(body)
|
||||
if (pathToLoad.endsWith('.md')) {
|
||||
setEditorCacheForPath(pathToLoad, result.data)
|
||||
setEditorCacheForPath(pathToLoad, body)
|
||||
}
|
||||
editorContentRef.current = result.data
|
||||
editorContentRef.current = body
|
||||
editorPathRef.current = pathToLoad
|
||||
initialContentByPathRef.current.set(pathToLoad, result.data)
|
||||
initialContentRef.current = result.data
|
||||
initialContentByPathRef.current.set(pathToLoad, body)
|
||||
initialContentRef.current = body
|
||||
setLastSaved(null)
|
||||
setActiveFileTags(extractTags(fm))
|
||||
} else {
|
||||
// Still update the editor's path so subsequent autosaves write to the correct file.
|
||||
editorPathRef.current = pathToLoad
|
||||
|
|
@ -1006,7 +1018,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 +1048,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 +1087,9 @@ function App() {
|
|||
})
|
||||
}
|
||||
if (selectedPathRef.current === pathAtStart) {
|
||||
editorContentRef.current = contentToSave
|
||||
setEditorContent(contentToSave)
|
||||
const bodyForEditor = splitFrontmatter(contentToSave).body
|
||||
editorContentRef.current = bodyForEditor
|
||||
setEditorContent(bodyForEditor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1083,7 +1101,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 +1123,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) {
|
||||
|
|
@ -2162,6 +2181,7 @@ function App() {
|
|||
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
|
||||
}
|
||||
|
|
@ -2768,6 +2788,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 +2831,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)
|
||||
|
|
@ -3391,6 +3418,7 @@ function App() {
|
|||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||
tags={isActive ? activeFileTags : undefined}
|
||||
onHistoryHandlersChange={(handlers) => {
|
||||
if (handlers) {
|
||||
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
||||
|
|
|
|||
310
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
310
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import * as React from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { ArrowDown, ArrowUp, X } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { splitFrontmatter, extractTags } from '@/lib/frontmatter'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
children?: TreeNode[]
|
||||
stat?: { size: number; mtimeMs: number }
|
||||
}
|
||||
|
||||
type NoteEntry = {
|
||||
path: string
|
||||
name: string
|
||||
folder: string
|
||||
tags: string[]
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'folder' | 'mtimeMs'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
type BasesViewProps = {
|
||||
tree: TreeNode[]
|
||||
onSelectNote: (path: string) => void
|
||||
}
|
||||
|
||||
function collectFilePaths(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
return nodes.flatMap((n) =>
|
||||
n.kind === 'file' && n.name.endsWith('.md')
|
||||
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
|
||||
: n.children
|
||||
? collectFilePaths(n.children)
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
/** Build a stable fingerprint from the tree's file paths + mtimes so we only reload when files actually change. */
|
||||
function treeFingerprint(nodes: TreeNode[]): string {
|
||||
const files = collectFilePaths(nodes)
|
||||
return files.map((f) => `${f.path}:${f.mtimeMs}`).join('\n')
|
||||
}
|
||||
|
||||
function getFolder(path: string): string {
|
||||
const parts = path.split('/')
|
||||
if (parts.length >= 3) return parts[1]
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
if (!ms) return ''
|
||||
const d = new Date(ms)
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function BasesView({ tree, onSelectNote }: BasesViewProps) {
|
||||
const [notes, setNotes] = useState<NoteEntry[]>([])
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set())
|
||||
const [sortField, setSortField] = useState<SortField>('mtimeMs')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
const lastFingerprintRef = useRef<string>('')
|
||||
|
||||
// Stable fingerprint — only changes when actual file paths/mtimes differ
|
||||
const fingerprint = useMemo(() => treeFingerprint(tree), [tree])
|
||||
|
||||
// Load notes data when fingerprint changes
|
||||
useEffect(() => {
|
||||
if (fingerprint === lastFingerprintRef.current) return
|
||||
lastFingerprintRef.current = fingerprint
|
||||
|
||||
let cancelled = false
|
||||
const files = collectFilePaths(tree)
|
||||
|
||||
async function loadNotes() {
|
||||
const entries: NoteEntry[] = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: file.path,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const { raw } = splitFrontmatter(result.data)
|
||||
const tags = extractTags(raw)
|
||||
entries.push({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
folder: getFolder(file.path),
|
||||
tags,
|
||||
mtimeMs: file.mtimeMs,
|
||||
})
|
||||
} catch {
|
||||
entries.push({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
folder: getFolder(file.path),
|
||||
tags: [],
|
||||
mtimeMs: file.mtimeMs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setNotes(entries)
|
||||
setInitialLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadNotes()
|
||||
return () => { cancelled = true }
|
||||
}, [fingerprint, tree])
|
||||
|
||||
// Collect all unique tags
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>()
|
||||
for (const note of notes) {
|
||||
for (const tag of note.tags) {
|
||||
tagSet.add(tag)
|
||||
}
|
||||
}
|
||||
return [...tagSet].sort((a, b) => a.localeCompare(b))
|
||||
}, [notes])
|
||||
|
||||
// Filter and sort
|
||||
const filteredNotes = useMemo(() => {
|
||||
let result = notes
|
||||
if (selectedTags.size > 0) {
|
||||
const tagsArray = [...selectedTags]
|
||||
result = result.filter((note) =>
|
||||
tagsArray.every((tag) => note.tags.includes(tag)),
|
||||
)
|
||||
}
|
||||
result = [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
if (sortField === 'name') {
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
} else if (sortField === 'folder') {
|
||||
cmp = a.folder.localeCompare(b.folder)
|
||||
} else {
|
||||
cmp = a.mtimeMs - b.mtimeMs
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return result
|
||||
}, [notes, selectedTags, sortField, sortDir])
|
||||
|
||||
const toggleTag = useCallback((tag: string) => {
|
||||
setSelectedTags((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(tag)) {
|
||||
next.delete(tag)
|
||||
} else {
|
||||
next.add(tag)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSelectedTags(new Set())
|
||||
}, [])
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
setSortField((prev) => {
|
||||
if (prev === field) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
return prev
|
||||
}
|
||||
setSortDir(field === 'mtimeMs' ? 'desc' : 'asc')
|
||||
return field
|
||||
})
|
||||
}, [])
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return null
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="size-3 inline ml-1" />
|
||||
) : (
|
||||
<ArrowDown className="size-3 inline ml-1" />
|
||||
)
|
||||
}
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="space-y-3 w-full max-w-2xl px-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-10 rounded bg-muted/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Filter bar */}
|
||||
<div className="shrink-0 border-b border-border px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
Showing {filteredNotes.length} of {notes.length} notes
|
||||
</span>
|
||||
{selectedTags.size > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
|
||||
selectedTags.has(tag)
|
||||
? 'bg-primary text-primary-foreground border-transparent'
|
||||
: 'bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-background border-b border-border">
|
||||
<tr>
|
||||
<th
|
||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Name
|
||||
<SortIcon field="name" />
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
||||
onClick={() => handleSort('folder')}
|
||||
>
|
||||
Folder
|
||||
<SortIcon field="folder" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground select-none">
|
||||
Tags
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
||||
onClick={() => handleSort('mtimeMs')}
|
||||
>
|
||||
Last Modified
|
||||
<SortIcon field="mtimeMs" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredNotes.map((note) => (
|
||||
<tr
|
||||
key={note.path}
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
<td className="px-4 py-2 font-medium">{note.name}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{note.folder}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{note.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleTag(tag)
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(note.mtimeMs)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredNotes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No notes found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -176,6 +176,7 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
return result
|
||||
}
|
||||
import { EditorToolbar } from './editor-toolbar'
|
||||
import { TagPills } from './tag-pills'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||
|
|
@ -200,6 +201,7 @@ interface MarkdownEditorProps {
|
|||
editorSessionKey?: number
|
||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||
editable?: boolean
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -288,6 +290,7 @@ export function MarkdownEditor({
|
|||
editorSessionKey = 0,
|
||||
onHistoryHandlersChange,
|
||||
editable = true,
|
||||
tags,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -721,6 +724,7 @@ export function MarkdownEditor({
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
/>
|
||||
{tags && <TagPills tags={tags} />}
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{wikiLinks ? (
|
||||
|
|
|
|||
|
|
@ -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, ChevronRight, Plus, X } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -22,7 +22,7 @@ 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 +60,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 +692,311 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Note Tagging Settings ---
|
||||
|
||||
interface TagDef {
|
||||
tag: string
|
||||
type: string
|
||||
applicability: "email" | "notes" | "both"
|
||||
description: string
|
||||
example?: string
|
||||
}
|
||||
|
||||
const TAG_TYPE_ORDER = [
|
||||
"relationship", "relationship-sub", "topic", "email-type",
|
||||
"filter", "action", "status", "source",
|
||||
]
|
||||
|
||||
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", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
|
||||
{ tag: "customer", type: "relationship", applicability: "both", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
|
||||
{ tag: "prospect", type: "relationship", applicability: "both", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
|
||||
{ tag: "partner", type: "relationship", applicability: "both", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
|
||||
{ tag: "vendor", type: "relationship", applicability: "both", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
|
||||
{ tag: "product", type: "relationship", applicability: "both", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
|
||||
{ tag: "candidate", type: "relationship", applicability: "both", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
|
||||
{ tag: "team", type: "relationship", applicability: "both", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
|
||||
{ tag: "advisor", type: "relationship", applicability: "both", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
|
||||
{ tag: "personal", type: "relationship", applicability: "both", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
|
||||
{ tag: "press", type: "relationship", applicability: "both", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
|
||||
{ tag: "community", type: "relationship", applicability: "both", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
|
||||
{ tag: "government", type: "relationship", applicability: "both", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
|
||||
{ tag: "primary", type: "relationship-sub", applicability: "notes", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
|
||||
{ tag: "secondary", type: "relationship-sub", applicability: "notes", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
|
||||
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
|
||||
{ tag: "cc", type: "relationship-sub", applicability: "notes", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
|
||||
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
|
||||
{ tag: "former", type: "relationship-sub", applicability: "notes", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
|
||||
{ tag: "champion", type: "relationship-sub", applicability: "notes", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
|
||||
{ tag: "blocker", type: "relationship-sub", applicability: "notes", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
|
||||
{ tag: "sales", type: "topic", applicability: "both", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
|
||||
{ tag: "support", type: "topic", applicability: "both", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
|
||||
{ tag: "legal", type: "topic", applicability: "both", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
|
||||
{ tag: "finance", type: "topic", applicability: "both", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
|
||||
{ tag: "hiring", type: "topic", applicability: "both", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
|
||||
{ tag: "fundraising", type: "topic", applicability: "both", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
|
||||
{ tag: "travel", type: "topic", applicability: "both", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
|
||||
{ tag: "event", type: "topic", applicability: "both", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
|
||||
{ tag: "shopping", type: "topic", applicability: "both", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
|
||||
{ tag: "health", type: "topic", applicability: "both", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
|
||||
{ tag: "learning", type: "topic", applicability: "both", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
|
||||
{ tag: "research", type: "topic", applicability: "both", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
|
||||
{ tag: "intro", type: "email-type", applicability: "both", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
|
||||
{ tag: "followup", type: "email-type", applicability: "both", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
|
||||
{ tag: "scheduling", type: "email-type", applicability: "email", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
|
||||
{ tag: "cold-outreach", type: "email-type", applicability: "email", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
|
||||
{ tag: "newsletter", type: "email-type", applicability: "email", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
|
||||
{ tag: "notification", type: "email-type", applicability: "email", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
|
||||
{ tag: "spam", type: "filter", applicability: "email", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
|
||||
{ tag: "promotion", type: "filter", applicability: "email", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
|
||||
{ tag: "social", type: "filter", applicability: "email", description: "Social media notifications", example: "John Smith commented on your post." },
|
||||
{ tag: "forums", type: "filter", applicability: "email", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
|
||||
{ tag: "action-required", type: "action", applicability: "both", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
|
||||
{ tag: "fyi", type: "action", applicability: "email", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
|
||||
{ tag: "urgent", type: "action", applicability: "both", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
|
||||
{ tag: "waiting", type: "action", applicability: "both", description: "Waiting on a response from them" },
|
||||
{ tag: "unread", type: "status", applicability: "email", description: "Not yet processed" },
|
||||
{ tag: "to-reply", type: "status", applicability: "email", description: "Need to respond" },
|
||||
{ tag: "done", type: "status", applicability: "email", description: "Handled, can be archived" },
|
||||
{ tag: "active", type: "status", applicability: "notes", description: "Currently relevant, recent activity" },
|
||||
{ tag: "archived", type: "status", applicability: "notes", description: "No longer active, kept for reference" },
|
||||
{ tag: "stale", type: "status", applicability: "notes", description: "No activity in 60+ days, needs attention or archive" },
|
||||
{ tag: "email", type: "source", applicability: "notes", description: "Created or updated from email" },
|
||||
{ tag: "meeting", type: "source", applicability: "notes", description: "Created or updated from meeting transcript" },
|
||||
{ tag: "browser", type: "source", applicability: "notes", description: "Content captured from web browsing" },
|
||||
{ tag: "web-search", type: "source", applicability: "notes", description: "Information from web search" },
|
||||
{ tag: "manual", type: "source", applicability: "notes", description: "Manually entered by user" },
|
||||
{ tag: "import", type: "source", applicability: "notes", description: "Imported from another system" },
|
||||
]
|
||||
|
||||
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 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 groups = useMemo(() => {
|
||||
const map = new Map<string, TagDef[]>()
|
||||
for (const tag of tags) {
|
||||
const list = map.get(tag.type) ?? []
|
||||
list.push(tag)
|
||||
map.set(tag.type, list)
|
||||
}
|
||||
return TAG_TYPE_ORDER.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) => {
|
||||
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 newTag: TagDef = { tag: "", type, applicability: "both", description: "" }
|
||||
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])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
|
||||
{groups.map(group => (
|
||||
<div key={group.type}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.type)}
|
||||
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", !collapsedGroups.has(group.type) && "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={() => addTag(group.type)}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{!collapsedGroups.has(group.type) && group.tags.length > 0 && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="grid grid-cols-[100px_1fr_1fr_80px_24px] gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div>Tag</div>
|
||||
<div>Description</div>
|
||||
<div>Example</div>
|
||||
<div>Applies to</div>
|
||||
<div />
|
||||
</div>
|
||||
{group.tags.map((tag, localIdx) => {
|
||||
const globalIdx = getGlobalIndex(group.type, localIdx)
|
||||
return (
|
||||
<div key={globalIdx} className="grid grid-cols-[100px_1fr_1fr_80px_24px] gap-1 border-t px-2 py-0.5 items-center">
|
||||
<Input
|
||||
value={tag.tag}
|
||||
onChange={e => updateTag(globalIdx, "tag", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="tag-name"
|
||||
title={tag.tag}
|
||||
/>
|
||||
<Input
|
||||
value={tag.description}
|
||||
onChange={e => updateTag(globalIdx, "description", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Description"
|
||||
title={tag.description}
|
||||
/>
|
||||
<Input
|
||||
value={tag.example || ""}
|
||||
onChange={e => updateTag(globalIdx, "example", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Example"
|
||||
title={tag.example || ""}
|
||||
/>
|
||||
<Select
|
||||
value={tag.applicability}
|
||||
onValueChange={v => updateTag(globalIdx, "applicability", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="notes">Notes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
onClick={() => removeTag(globalIdx)}
|
||||
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!collapsedGroups.has(group.type) && group.tags.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</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 +1020,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -814,9 +1126,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
</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 ? (
|
||||
|
|
|
|||
17
apps/x/apps/renderer/src/components/tag-pills.tsx
Normal file
17
apps/x/apps/renderer/src/components/tag-pills.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
interface TagPillsProps {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export function TagPills({ tags }: TagPillsProps) {
|
||||
if (tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="tag-pills-row">
|
||||
{tags.map((tag, i) => (
|
||||
<span key={`${tag}-${i}`} className="tag-pill">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
164
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Utilities for splitting, joining, and extracting tags from YAML frontmatter
|
||||
* in knowledge notes and email files.
|
||||
*/
|
||||
|
||||
/** Split content into raw frontmatter block and body text. */
|
||||
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
|
||||
if (!content.startsWith('---')) {
|
||||
return { raw: null, body: content }
|
||||
}
|
||||
const endIndex = content.indexOf('\n---', 3)
|
||||
if (endIndex === -1) {
|
||||
return { raw: null, body: content }
|
||||
}
|
||||
// raw includes both delimiters and the trailing newline after closing ---
|
||||
const closingEnd = endIndex + 4 // '\n---' is 4 chars
|
||||
const raw = content.slice(0, closingEnd)
|
||||
// body starts after the closing --- and its trailing newline
|
||||
let body = content.slice(closingEnd)
|
||||
if (body.startsWith('\n')) {
|
||||
body = body.slice(1)
|
||||
}
|
||||
return { raw, body }
|
||||
}
|
||||
|
||||
/** Re-prepend raw frontmatter before body when saving. */
|
||||
export function joinFrontmatter(raw: string | null, body: string): string {
|
||||
if (!raw) return body
|
||||
return raw + '\n' + body
|
||||
}
|
||||
|
||||
/** Tag category keys used in the categorized frontmatter format. */
|
||||
const TAG_CATEGORY_KEYS = new Set([
|
||||
'relationship',
|
||||
'relationship_sub',
|
||||
'topic',
|
||||
'email_type',
|
||||
'action',
|
||||
'status',
|
||||
'source',
|
||||
])
|
||||
|
||||
/** Keys that are metadata, not tags — skip when collecting tags. */
|
||||
const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at'])
|
||||
|
||||
/**
|
||||
* Extract tags from raw frontmatter YAML.
|
||||
*
|
||||
* Handles three formats:
|
||||
* - Legacy flat list: `tags:` followed by ` - value` items
|
||||
* - Categorized format: top-level keys like `relationship: customer` or
|
||||
* `topic:` followed by ` - value` list items
|
||||
* - Email format: `labels:` with nested keys (relationship, topics, type, filter, action)
|
||||
* where values can be single strings or ` - value` arrays
|
||||
*
|
||||
* Skips metadata keys like `processed`, `labeled_at`, `tagged_at`.
|
||||
*/
|
||||
export function extractTags(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
|
||||
const lines = raw.split('\n')
|
||||
const tags: string[] = []
|
||||
|
||||
let inTags = false
|
||||
let inLabels = false
|
||||
let inLabelSubKey = false
|
||||
let inCategoryList = false
|
||||
|
||||
for (const line of lines) {
|
||||
// Top-level key detection — resets all nested state
|
||||
if (/^\w/.test(line) || line === '---') {
|
||||
inTags = false
|
||||
inLabels = false
|
||||
inLabelSubKey = false
|
||||
inCategoryList = false
|
||||
}
|
||||
|
||||
// Legacy note format: tags:
|
||||
if (/^tags:\s*$/.test(line)) {
|
||||
inTags = true
|
||||
inLabels = false
|
||||
inCategoryList = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Email format: labels:
|
||||
if (/^labels:\s*$/.test(line)) {
|
||||
inLabels = true
|
||||
inTags = false
|
||||
inCategoryList = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Categorized format: top-level tag category key
|
||||
const topKeyMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (topKeyMatch) {
|
||||
const key = topKeyMatch[1]
|
||||
const inlineValue = topKeyMatch[2].trim()
|
||||
|
||||
if (TAG_CATEGORY_KEYS.has(key)) {
|
||||
if (inlineValue) {
|
||||
// Single value: `relationship: customer`
|
||||
tags.push(inlineValue)
|
||||
inCategoryList = false
|
||||
} else {
|
||||
// List follows: `topic:\n - sales`
|
||||
inCategoryList = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Collect tag items under `tags:`
|
||||
if (inTags) {
|
||||
const match = line.match(/^\s+-\s+(.+)$/)
|
||||
if (match) {
|
||||
tags.push(match[1].trim())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect list items under a category key
|
||||
if (inCategoryList) {
|
||||
const match = line.match(/^\s+-\s+(.+)$/)
|
||||
if (match) {
|
||||
tags.push(match[1].trim())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle labels: nested structure
|
||||
if (inLabels) {
|
||||
// Sub-key like ` relationship:` or ` topics:`
|
||||
const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/)
|
||||
if (subKeyMatch) {
|
||||
const key = subKeyMatch[1]
|
||||
const inlineValue = subKeyMatch[2].trim()
|
||||
if (METADATA_KEYS.has(key)) {
|
||||
inLabelSubKey = false
|
||||
continue
|
||||
}
|
||||
if (inlineValue) {
|
||||
// Inline value like ` type: person`
|
||||
tags.push(inlineValue)
|
||||
inLabelSubKey = false
|
||||
} else {
|
||||
// Array follows
|
||||
inLabelSubKey = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Array item under a sub-key like ` - value`
|
||||
if (inLabelSubKey) {
|
||||
const itemMatch = line.match(/^\s{4}-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
tags.push(itemMatch[1].trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
|
@ -237,6 +237,34 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tag pills row shown between toolbar and editor content */
|
||||
.tag-pills-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--background);
|
||||
flex-shrink: 0;
|
||||
max-height: 4.5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 8px;
|
||||
border-radius: 9999px;
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dark .tag-pill {
|
||||
background-color: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.editor-toolbar .separator {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { jsonSchema, ModelMessage } from "ai";
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { getNoteCreationStrictness } from "../config/note_creation_config.js";
|
||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||
|
|
@ -25,9 +24,9 @@ import { IRunsLock } from "../runs/lock.js";
|
|||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
import { parse } from "yaml";
|
||||
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js";
|
||||
import { raw as noteCreationLowRaw } from "../knowledge/note_creation_low.js";
|
||||
import { raw as noteCreationHighRaw } from "../knowledge/note_creation_high.js";
|
||||
import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
|
||||
import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
|
|
@ -316,19 +315,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,
|
||||
|
|
@ -353,6 +340,56 @@ 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;
|
||||
}
|
||||
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
return await repo.fetch(id);
|
||||
}
|
||||
|
|
@ -706,7 +743,7 @@ export async function* streamAgent({
|
|||
|
||||
// set up provider + model
|
||||
const provider = createProvider(modelConfig.provider);
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"];
|
||||
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: modelConfig.model;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
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,7 @@
|
|||
export const raw = `---
|
||||
import { renderNoteTypesBlock } from './note_system.js';
|
||||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
|
|
@ -130,25 +133,26 @@ Either:
|
|||
|
||||
---
|
||||
|
||||
# The Core Rule: Medium Strictness
|
||||
# The Core Rule: Label-Based Filtering
|
||||
|
||||
**MEDIUM STRICTNESS MODE**
|
||||
**Emails now have YAML frontmatter with labels.** Use these labels to decide whether to process or skip.
|
||||
|
||||
**Meetings create notes because:**
|
||||
- You chose to spend time with these people
|
||||
- If you met them, they matter enough to track
|
||||
- Meeting transcripts have rich context
|
||||
**Meetings and voice memos always create notes** — no label check needed.
|
||||
|
||||
**Emails can create notes if:**
|
||||
- The email contains personalized content (not mass mail)
|
||||
- The sender seems relevant to your work (business context, not consumer services)
|
||||
- The email is part of a meaningful exchange (not one-off transactional)
|
||||
**For emails, read the YAML frontmatter labels and apply these rules:**
|
||||
|
||||
**Skip creating notes for:**
|
||||
- Mass emails and newsletters
|
||||
- Automated/transactional emails
|
||||
- Consumer service providers (utilities, subscriptions, etc.)
|
||||
- Cold sales outreach with no prior relationship indication
|
||||
**CREATE/UPDATE notes if the email has ANY of these labels:**
|
||||
- **Relationship:** Investor, Customer, Prospect, Partner, Vendor, Candidate, Team, Advisor, Personal, Press, Community, Government
|
||||
- **Topic:** Sales, Support, Legal, Finance, Hiring, Fundraising, Event, Research
|
||||
- **Type:** Intro, Followup
|
||||
- **Action:** Action Required, Urgent, Waiting
|
||||
|
||||
**SKIP if the email ONLY has these labels (and none from above):**
|
||||
- **Relationship:** Product
|
||||
- **Topic:** Travel, Shopping, Health, Learning
|
||||
- **Type:** Scheduling, Cold Outreach, Newsletter, Notification
|
||||
- **Filter:** Spam, Promotion, Social, Forums
|
||||
- **Action:** FYI
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -217,168 +221,55 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data
|
|||
|
||||
---
|
||||
|
||||
# Step 1: Source Filtering
|
||||
# Step 1: Source Filtering (Label-Based)
|
||||
|
||||
## Skip These Sources (Both Meetings and Emails)
|
||||
## For Meetings and Voice Memos
|
||||
Always process — no filtering needed.
|
||||
|
||||
### Mass Emails and Newsletters
|
||||
## For Emails — Read YAML Frontmatter
|
||||
|
||||
**Indicators:**
|
||||
- Sent to a list (To: contains multiple addresses, or undisclosed-recipients)
|
||||
- Unsubscribe link in body or footer
|
||||
- From a no-reply or marketing address (noreply@, newsletter@, marketing@, hello@)
|
||||
- Generic greeting ("Hi there", "Dear subscriber", "Hello!")
|
||||
- Promotional language ("Don't miss out", "Limited time", "% off")
|
||||
- Mailing list headers (List-Unsubscribe, Mailing-List)
|
||||
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
|
||||
Emails have YAML frontmatter with labels prepended by the labeling agent:
|
||||
|
||||
**Action:** SKIP with reason "Newsletter/mass email"
|
||||
\`\`\`yaml
|
||||
---
|
||||
labels:
|
||||
relationship:
|
||||
- Investor
|
||||
topics:
|
||||
- Fundraising
|
||||
type: Intro
|
||||
filter: []
|
||||
action: FYI
|
||||
processed: true
|
||||
labeled_at: "2026-02-28T12:00:00Z"
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
### Product Updates & Changelogs
|
||||
## Decision Rules
|
||||
|
||||
**Indicators:**
|
||||
- Subject contains: "changelog", "what's new", "product update", "release notes", "v1.x", "new features"
|
||||
- Content describes feature releases, bug fixes, or product changes
|
||||
- Sent to all users/customers (not personalized to you specifically)
|
||||
- From tools/SaaS you use: Cal.com, Notion, Slack, Linear, Figma, etc.
|
||||
- No action required from you — purely informational
|
||||
- Written in announcement style, not conversational
|
||||
Check the labels against the create/skip lists:
|
||||
|
||||
**Examples to SKIP:**
|
||||
- "Cal.com Changelog v6.1" — product update
|
||||
- "What's new in Notion - January 2026" — feature announcement
|
||||
- "Introducing new Slack features" — product marketing
|
||||
- "Linear Release Notes" — changelog
|
||||
**CREATE/UPDATE notes if ANY label matches:**
|
||||
- relationship: Investor, Customer, Prospect, Partner, Vendor, Candidate, Team, Advisor, Personal, Press, Community, Government
|
||||
- topics: Sales, Support, Legal, Finance, Hiring, Fundraising, Event, Research
|
||||
- type: Intro, Followup
|
||||
- action: Action Required, Urgent, Waiting
|
||||
|
||||
**Action:** SKIP with reason "Product update/changelog"
|
||||
**SKIP if labels ONLY match:**
|
||||
- relationship: Product
|
||||
- topics: Travel, Shopping, Health, Learning
|
||||
- type: Scheduling, Cold Outreach, Newsletter, Notification
|
||||
- filter: Spam, Promotion, Social, Forums
|
||||
- action: FYI
|
||||
|
||||
### Cold Outreach / Sales Emails
|
||||
|
||||
**THE RULE: If someone emails you offering services and you never responded, SKIP.**
|
||||
|
||||
It doesn't matter how personalized, detailed, or relevant the pitch seems. If:
|
||||
1. They initiated contact (you didn't reach out first)
|
||||
2. They're offering services/products
|
||||
3. You never replied or engaged
|
||||
|
||||
Then it's cold outreach and should be SKIPPED. Do NOT create notes for cold outreach senders or their organizations.
|
||||
|
||||
**EXCEPTION:** If they reference a prior real-world interaction, CREATE a note:
|
||||
- "Great meeting you at [conference/event]"
|
||||
- "Following up on our conversation at..."
|
||||
- "It was nice chatting at [place]"
|
||||
- "[Mutual contact] suggested I reach out after we met"
|
||||
|
||||
This indicates a real relationship that started offline, not cold outreach.
|
||||
|
||||
**Indicators:**
|
||||
- Unsolicited contact from someone you've never interacted with
|
||||
- Offering services you didn't request (HR, payroll, compliance, bookkeeping, recruiting, dev shops, marketing, etc.)
|
||||
- Sales-y language: "wanted to reach out", "thought this might help", "quick question about your..."
|
||||
- Mentions your company growth/funding/hiring/tech stack as a hook
|
||||
- Attaches "free guides", "case studies", "resources", or "frameworks"
|
||||
- Asks for a call/meeting without any prior relationship
|
||||
- From domains you've never contacted or met with before
|
||||
- No existing note for this person or organization
|
||||
- **No reply from the user in the email thread**
|
||||
|
||||
**Examples to SKIP:**
|
||||
- "Saw you raised funding, wanted to reach out about our services"
|
||||
- "Quick question about your bookkeeping/compliance/hiring"
|
||||
- "Shared this guide that might help with [your problem]"
|
||||
- "Noticed you're scaling, we help startups with..."
|
||||
- "Would love 15 minutes to show you how we can help"
|
||||
- Detailed pitch about HR/payroll/India expansion services (still cold outreach!)
|
||||
- Follow-up emails to previous cold outreach that got no response
|
||||
|
||||
**Key distinction:**
|
||||
- **You reaching out to a vendor** → worth tracking (you initiated)
|
||||
- **You replied to their outreach** → worth tracking (you engaged)
|
||||
- **Vendor cold emailing you with no response** → SKIP (no relationship exists)
|
||||
|
||||
**IMPORTANT: CC'd people on cold outreach**
|
||||
When an email is identified as cold outreach, skip notes for ALL parties involved:
|
||||
- The sender (the person doing the outreach)
|
||||
- Anyone CC'd on the email (colleagues of the sender, other contacts they're trying to connect)
|
||||
- The organization they represent
|
||||
|
||||
If someone only appears in your memory as "CC'd on outreach emails from [Sender]", they don't warrant a note — they're just incidentally included in cold outreach, not a real relationship.
|
||||
|
||||
**Action:** SKIP with reason "Cold outreach/sales email - no engagement from user"
|
||||
|
||||
### Automated/Transactional
|
||||
|
||||
**Indicators:**
|
||||
- From automated systems (notifications@, alerts@, no-reply@)
|
||||
- Password resets, login alerts, shipping notifications
|
||||
- Calendar invites without substance
|
||||
- Receipts and invoices (unless from key vendor/customer)
|
||||
- GitHub/Jira/Slack notifications
|
||||
|
||||
**Action:** SKIP with reason "Automated/transactional"
|
||||
|
||||
### Low-Signal
|
||||
|
||||
**Indicators:**
|
||||
- Very short with no substance ("Thanks!", "Sounds good", "Got it")
|
||||
- Only contains forwarded message with no commentary
|
||||
- Auto-replies ("I'm out of office")
|
||||
|
||||
**Action:** SKIP with reason "Low signal"
|
||||
|
||||
### Consumer Services (Medium strictness specific)
|
||||
|
||||
**Indicators:**
|
||||
- From consumer service companies (utilities, streaming, retail)
|
||||
- Account management emails
|
||||
- Subscription confirmations
|
||||
- Delivery notifications
|
||||
|
||||
**Action:** SKIP with reason "Consumer service"
|
||||
|
||||
### Infrastructure & SaaS Providers
|
||||
|
||||
**Skip emails from these types of services:**
|
||||
- Domain registrars: GoDaddy, Namecheap, Google Domains, Cloudflare
|
||||
- Hosting providers: AWS, Google Cloud, Azure, DigitalOcean, Heroku, Vercel, Netlify
|
||||
- Email providers: Google Workspace, Microsoft 365, Zoho
|
||||
- Payment processors: Stripe, PayPal, Square, Razorpay
|
||||
- Developer tools: GitHub, GitLab, Bitbucket, npm, Docker Hub
|
||||
- Analytics: Google Analytics, Mixpanel, Amplitude, Segment
|
||||
- Auth providers: Auth0, Okta, Firebase Auth
|
||||
- Support platforms: Zendesk, Intercom, Freshdesk
|
||||
- HR/Payroll: Gusto, Rippling, Deel, Remote
|
||||
|
||||
**Indicators:**
|
||||
- Automated system notifications (renewal reminders, usage alerts, security notices)
|
||||
- No personalized content from a human
|
||||
- From domains like @godaddy.com, @aws.amazon.com, @stripe.com, etc.
|
||||
- Templates about account status, billing, or technical alerts
|
||||
|
||||
**Action:** SKIP with reason "Infrastructure/SaaS provider notification"
|
||||
|
||||
## Email-Specific Processing (Medium Strictness)
|
||||
|
||||
For emails, evaluate if the content is personalized and business-relevant:
|
||||
|
||||
**Create note if:**
|
||||
- The email is personally addressed and substantive
|
||||
- The sender appears to be from a business/organization relevant to your work
|
||||
- The content discusses work, projects, opportunities, or professional topics
|
||||
- It's a warm intro from anyone (not just existing contacts)
|
||||
- It's a thoughtful cold outreach that's specific to your work
|
||||
|
||||
**Do not create note if:**
|
||||
- Clearly mass/templated email
|
||||
- Consumer service interaction
|
||||
- Generic sales pitch with no personalization
|
||||
**Logic:** If even one label falls in the "create" list, process the email. Only skip if ALL labels fall in the "skip" list.
|
||||
|
||||
## Filter Decision Output
|
||||
|
||||
If skipping:
|
||||
\`\`\`
|
||||
SKIP
|
||||
Reason: {reason}
|
||||
Reason: Labels indicate skip-only categories: {list the labels}
|
||||
\`\`\`
|
||||
|
||||
If processing, continue to Step 2.
|
||||
|
|
@ -552,16 +443,16 @@ Resolution Map:
|
|||
- "the integration" → "Acme Integration" (same project)
|
||||
\`\`\`
|
||||
|
||||
## 4b: Apply Source Type Rules (Medium Strictness)
|
||||
## 4b: Apply Source Type Rules
|
||||
|
||||
**If source_type == "meeting":**
|
||||
**If source_type == "meeting" or "voice_memo":**
|
||||
- Resolved entities → Update existing notes
|
||||
- New entities that pass filters → Create new notes
|
||||
|
||||
**If source_type == "email" (MEDIUM STRICTNESS):**
|
||||
**If source_type == "email":**
|
||||
- The email already passed label-based filtering in Step 1
|
||||
- Resolved entities → Update existing notes
|
||||
- New entities → Create notes IF the email is personalized and business-relevant
|
||||
- New entities from cold sales pitches without personalization → Skip
|
||||
- New entities → Create notes (the labels already confirmed this email is worth processing)
|
||||
|
||||
## 4c: Disambiguation Rules
|
||||
|
||||
|
|
@ -628,39 +519,23 @@ For entities not resolved to existing notes, determine if they warrant new notes
|
|||
|
||||
## People
|
||||
|
||||
### Who Gets a Note (Medium Strictness)
|
||||
### Who Gets a Note
|
||||
|
||||
**CREATE a note for people who are:**
|
||||
- External (not @user.domain)
|
||||
- Attendees in meetings
|
||||
- Email correspondents sending personalized, business-relevant content
|
||||
- Email correspondents (emails that reach this step already passed label-based filtering)
|
||||
- Decision makers or contacts at customers, prospects, or partners
|
||||
- Investors or potential investors
|
||||
- Candidates you are interviewing
|
||||
- Advisors or mentors
|
||||
- Key collaborators
|
||||
- Introducers who connect you to valuable contacts
|
||||
- Anyone reaching out with a specific, relevant opportunity
|
||||
|
||||
**DO NOT create notes for:**
|
||||
- Transactional service providers (bank employees, support reps)
|
||||
- One-time administrative contacts
|
||||
- Large group meeting attendees you didn't interact with
|
||||
- Internal colleagues (@user.domain)
|
||||
- Assistants handling only logistics
|
||||
- Generic role-based contacts
|
||||
- Consumer service representatives
|
||||
- Generic cold sales outreach with no personalization
|
||||
|
||||
### The Relevance Test (Medium Strictness)
|
||||
|
||||
Ask: Is this person relevant to my professional work or goals?
|
||||
|
||||
- Sarah Chen, VP Engineering evaluating your product → **Yes, create note**
|
||||
- James from HSBC who set up your account → **No, skip**
|
||||
- Investor reaching out about your company → **Yes, create note**
|
||||
- Cold recruiter with a generic pitch → **No, skip**
|
||||
- Someone reaching out about a specific opportunity → **Yes, create note**
|
||||
|
||||
### Role Inference
|
||||
|
||||
|
|
@ -1025,153 +900,18 @@ After writing, verify links go both ways.
|
|||
|
||||
---
|
||||
|
||||
# Note Templates
|
||||
|
||||
## People
|
||||
\`\`\`markdown
|
||||
# {Full Name}
|
||||
|
||||
## Info
|
||||
**Role:** {role, or inferred role with qualifier, or leave blank if truly unknown}
|
||||
**Organization:** [[Organizations/{organization}]] or leave blank
|
||||
**Email:** {email or leave blank}
|
||||
**Aliases:** {comma-separated: first name, nicknames, email}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: Who they are, why you know them, what you're working on together.}
|
||||
|
||||
## Connected to
|
||||
- [[Organizations/{Organization}]] — works at
|
||||
- [[People/{Person}]] — {colleague, introduced by, reports to}
|
||||
- [[Projects/{Project}]] — {role}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Organizations
|
||||
\`\`\`markdown
|
||||
# {Organization Name}
|
||||
|
||||
## Info
|
||||
**Type:** {company|team|institution|other}
|
||||
**Industry:** {industry or leave blank}
|
||||
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
|
||||
**Domain:** {primary email domain}
|
||||
**Aliases:** {comma-separated: short names, abbreviations}
|
||||
**First met:** {YYYY-MM-DD}
|
||||
**Last seen:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this org is, what your relationship is.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Contacts
|
||||
{For transactional contacts who don't get their own notes}
|
||||
|
||||
## Projects
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Projects
|
||||
\`\`\`markdown
|
||||
# {Project Name}
|
||||
|
||||
## Info
|
||||
**Type:** {deal|product|initiative|hiring|other}
|
||||
**Status:** {active|planning|on hold|completed|cancelled}
|
||||
**Started:** {YYYY-MM-DD or leave blank}
|
||||
**Last activity:** {YYYY-MM-DD}
|
||||
|
||||
## Summary
|
||||
{2-3 sentences: What this project is, goal, current state.}
|
||||
|
||||
## People
|
||||
- [[People/{Person}]] — {role}
|
||||
|
||||
## Organizations
|
||||
- [[Organizations/{Org}]] — {customer|partner|etc.}
|
||||
|
||||
## Related
|
||||
- [[Topics/{Topic}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Timeline
|
||||
**{YYYY-MM-DD}** ({meeting|email})
|
||||
{What happened.}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}. {Rationale}.
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
\`\`\`
|
||||
|
||||
## Topics
|
||||
\`\`\`markdown
|
||||
# {Topic Name}
|
||||
|
||||
## About
|
||||
{1-2 sentences: What this topic covers.}
|
||||
|
||||
**Keywords:** {comma-separated}
|
||||
**Aliases:** {other ways this topic is referenced}
|
||||
**First mentioned:** {YYYY-MM-DD}
|
||||
**Last mentioned:** {YYYY-MM-DD}
|
||||
|
||||
## Related
|
||||
- [[People/{Person}]] — {relationship}
|
||||
- [[Organizations/{Org}]] — {relationship}
|
||||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Log
|
||||
**{YYYY-MM-DD}** ({meeting|email}: {title})
|
||||
{Summary with [[Folder/Name]] links}
|
||||
|
||||
## Decisions
|
||||
- **{YYYY-MM-DD}**: {Decision}
|
||||
|
||||
## Open items
|
||||
{Commitments and next steps only. Leave empty if none.}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
\`\`\`
|
||||
${renderNoteTypesBlock()}
|
||||
|
||||
---
|
||||
|
||||
# Summary: Medium Strictness Rules
|
||||
# Summary: Label-Based Rules
|
||||
|
||||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
||||
|-------------|---------------|----------------|------------------------|
|
||||
| Meeting | Yes | Yes | Yes |
|
||||
| Voice memo | Yes | Yes | Yes |
|
||||
| Email (personalized, business-relevant) | Yes | Yes | Yes |
|
||||
| Email (mass/automated/consumer) | No (SKIP) | No | No |
|
||||
| Email (cold outreach with personalization) | Yes | Yes | Yes |
|
||||
| Email (generic cold outreach) | No | No | No |
|
||||
| Email (has create label) | Yes | Yes | Yes |
|
||||
| Email (only skip labels) | No (SKIP) | No | No |
|
||||
|
||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||
\`\`\`
|
||||
|
|
@ -1198,7 +938,7 @@ Before completing, verify:
|
|||
|
||||
**Source Type:**
|
||||
- [ ] Correctly identified as meeting or email
|
||||
- [ ] Applied correct medium strictness rules
|
||||
- [ ] Applied label-based filtering rules correctly
|
||||
|
||||
**Resolution:**
|
||||
- [ ] Extracted all name variants from source
|
||||
|
|
@ -1233,4 +973,5 @@ Before completing, verify:
|
|||
- [ ] Dates are YYYY-MM-DD
|
||||
- [ ] Bidirectional links are consistent
|
||||
- [ ] New notes in correct folders
|
||||
`;
|
||||
`;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
197
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
197
apps/x/packages/core/src/knowledge/tag_system.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
|
||||
export type TagApplicability = 'email' | 'notes' | 'both';
|
||||
|
||||
export type TagType =
|
||||
| 'relationship'
|
||||
| 'relationship-sub'
|
||||
| 'topic'
|
||||
| 'email-type'
|
||||
| 'filter'
|
||||
| 'action'
|
||||
| 'status'
|
||||
| 'source';
|
||||
|
||||
export interface TagDefinition {
|
||||
tag: string;
|
||||
type: TagType;
|
||||
applicability: TagApplicability;
|
||||
description: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
|
||||
|
||||
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
|
||||
// ── Relationship (both) ──────────────────────────────────────────────
|
||||
{ tag: 'investor', type: 'relationship', applicability: 'both', description: 'Investors, VCs, or angels', example: 'Following up on our meeting — we\'d like to move forward with the Series A term sheet.' },
|
||||
{ tag: 'customer', type: 'relationship', applicability: 'both', description: 'Paying customers', example: 'We\'re seeing great results with Rowboat. Can we discuss expanding to more teams?' },
|
||||
{ tag: 'prospect', type: 'relationship', applicability: 'both', description: 'Potential customers', example: 'Thanks for the demo yesterday. We\'re interested in starting a pilot.' },
|
||||
{ tag: 'partner', type: 'relationship', applicability: 'both', description: 'Business partners', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
|
||||
{ tag: 'vendor', type: 'relationship', applicability: 'both', description: 'Service providers you work with', example: 'Here are the updated employment agreements you requested.' },
|
||||
{ tag: 'product', type: 'relationship', applicability: 'both', description: 'Products or services you use (automated)', example: 'Your AWS bill for January 2025 is now available.' },
|
||||
{ tag: 'candidate', type: 'relationship', applicability: 'both', description: 'Job applicants', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
|
||||
{ tag: 'team', type: 'relationship', applicability: 'both', description: 'Internal team members', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
|
||||
{ tag: 'advisor', type: 'relationship', applicability: 'both', description: 'Advisors, mentors, or board members', example: 'I\'ve reviewed the deck. Here are my thoughts on the GTM strategy.' },
|
||||
{ tag: 'personal', type: 'relationship', applicability: 'both', description: 'Family or friends', example: 'Are you coming to Thanksgiving this year? Let me know your travel dates.' },
|
||||
{ tag: 'press', type: 'relationship', applicability: 'both', description: 'Journalists or media', example: 'I\'m writing a piece on AI agents. Would you be available for an interview?' },
|
||||
{ tag: 'community', type: 'relationship', applicability: 'both', description: 'Users, peers, or open source contributors', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' },
|
||||
{ tag: 'government', type: 'relationship', applicability: 'both', description: 'Government agencies', example: 'Your Delaware franchise tax is due by March 1, 2025.' },
|
||||
|
||||
// ── Relationship Sub-Tags (notes only) ───────────────────────────────
|
||||
{ tag: 'primary', type: 'relationship-sub', applicability: 'notes', description: 'Main contact or decision maker', example: 'Sarah Chen — VP Engineering, your main point of contact at Acme.' },
|
||||
{ tag: 'secondary', type: 'relationship-sub', applicability: 'notes', description: 'Supporting contact, involved but not the lead', example: 'David Kim — Engineer CC\'d on customer emails.' },
|
||||
{ tag: 'executive-assistant', type: 'relationship-sub', applicability: 'notes', description: 'EA or admin handling scheduling and logistics', example: 'Lisa — Sarah\'s EA who schedules all her meetings.' },
|
||||
{ tag: 'cc', type: 'relationship-sub', applicability: 'notes', description: 'Person who\'s CC\'d but not actively engaged', example: 'Manager looped in for visibility on deal.' },
|
||||
{ tag: 'referred-by', type: 'relationship-sub', applicability: 'notes', description: 'Person who made an introduction or referral', example: 'David Park — Investor who intro\'d you to Sarah.' },
|
||||
{ tag: 'former', type: 'relationship-sub', applicability: 'notes', description: 'Previously held this relationship, no longer active', example: 'John — Former customer who churned last year.' },
|
||||
{ tag: 'champion', type: 'relationship-sub', applicability: 'notes', description: 'Internal advocate pushing for you', example: 'Engineer who loves your product and is selling internally.' },
|
||||
{ tag: 'blocker', type: 'relationship-sub', applicability: 'notes', description: 'Person opposing or blocking progress', example: 'CFO resistant to spending on new tools.' },
|
||||
|
||||
// ── Topic (both) ─────────────────────────────────────────────────────
|
||||
{ tag: 'sales', type: 'topic', applicability: 'both', description: 'Sales conversations, deals, and revenue', example: 'Here\'s the pricing proposal we discussed. Let me know if you have questions.' },
|
||||
{ tag: 'support', type: 'topic', applicability: 'both', description: 'Help requests, issues, and customer support', example: 'We\'re seeing an error when trying to export. Can you help?' },
|
||||
{ tag: 'legal', type: 'topic', applicability: 'both', description: 'Contracts, terms, compliance, and legal matters', example: 'Legal has reviewed the MSA. Attached are our requested changes.' },
|
||||
{ tag: 'finance', type: 'topic', applicability: 'both', description: 'Money, invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' },
|
||||
{ tag: 'hiring', type: 'topic', applicability: 'both', description: 'Recruiting, interviews, and employment', example: 'We\'d like to move forward with a final round interview. Are you available Thursday?' },
|
||||
{ tag: 'fundraising', type: 'topic', applicability: 'both', description: 'Raising money and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' },
|
||||
{ tag: 'travel', type: 'topic', applicability: 'both', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' },
|
||||
{ tag: 'event', type: 'topic', applicability: 'both', description: 'Conferences, meetups, and gatherings', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' },
|
||||
{ tag: 'shopping', type: 'topic', applicability: 'both', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
|
||||
{ tag: 'health', type: 'topic', applicability: 'both', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' },
|
||||
{ tag: 'learning', type: 'topic', applicability: 'both', description: 'Courses, education, and skill-building', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
|
||||
{ tag: 'research', type: 'topic', applicability: 'both', description: 'Research requests and information gathering', example: 'Here\'s the market analysis you requested on the AI agent space.' },
|
||||
|
||||
// ── Email Type ───────────────────────────────────────────────────────
|
||||
{ tag: 'intro', type: 'email-type', applicability: 'both', description: 'Warm introduction from someone you know', example: 'I\'d like to introduce you to Sarah Chen, VP Engineering at Acme.' },
|
||||
{ tag: 'followup', type: 'email-type', applicability: 'both', description: 'Following up on a previous conversation', example: 'Following up on our call last week. Have you had a chance to review the proposal?' },
|
||||
{ tag: 'scheduling', type: 'email-type', applicability: 'email', description: 'Meeting and calendar scheduling', example: 'Are you available for a call next Tuesday at 2pm?' },
|
||||
{ tag: 'cold-outreach', type: 'email-type', applicability: 'email', description: 'Unsolicited contact from someone you don\'t know', example: 'Hi, I noticed your company is growing fast. I\'d love to show you how we can help with...' },
|
||||
{ tag: 'newsletter', type: 'email-type', applicability: 'email', description: 'Newsletters, marketing emails, and subscriptions', example: 'This week in AI: The latest developments in agent frameworks...' },
|
||||
{ tag: 'notification', type: 'email-type', applicability: 'email', description: 'Automated alerts, receipts, and system notifications', example: 'Your password was changed successfully. If this wasn\'t you, contact support.' },
|
||||
|
||||
// ── Filter (email only) ──────────────────────────────────────────────
|
||||
{ tag: 'spam', type: 'filter', applicability: 'email', description: 'Junk and unwanted email', example: 'Congratulations! You\'ve won $1,000,000...' },
|
||||
{ tag: 'promotion', type: 'filter', applicability: 'email', description: 'Marketing offers and sales pitches', example: '50% off all items this weekend only!' },
|
||||
{ tag: 'social', type: 'filter', applicability: 'email', description: 'Social media notifications', example: 'John Smith commented on your post.' },
|
||||
{ tag: 'forums', type: 'filter', applicability: 'email', description: 'Mailing lists and group discussions', example: 'Re: [dev-list] Question about API design' },
|
||||
|
||||
// ── Action ───────────────────────────────────────────────────────────
|
||||
{ tag: 'action-required', type: 'action', applicability: 'both', description: 'Needs a response or action from you', example: 'Can you send me the pricing by Friday?' },
|
||||
{ tag: 'fyi', type: 'action', applicability: 'email', description: 'Informational only, no action needed', example: 'Just wanted to let you know the deal closed. Thanks for your help!' },
|
||||
{ tag: 'urgent', type: 'action', applicability: 'both', description: 'Time-sensitive, needs immediate attention', example: 'We need your signature on the contract by EOD today or we lose the deal.' },
|
||||
{ tag: 'waiting', type: 'action', applicability: 'both', description: 'Waiting on a response from them' },
|
||||
|
||||
// ── Status (email) ───────────────────────────────────────────────────
|
||||
{ tag: 'unread', type: 'status', applicability: 'email', description: 'Not yet processed' },
|
||||
{ tag: 'to-reply', type: 'status', applicability: 'email', description: 'Need to respond' },
|
||||
{ tag: 'done', type: 'status', applicability: 'email', description: 'Handled, can be archived' },
|
||||
|
||||
// ── Source (notes only) ──────────────────────────────────────────────
|
||||
{ tag: 'email', type: 'source', applicability: 'notes', description: 'Created or updated from email' },
|
||||
{ tag: 'meeting', type: 'source', applicability: 'notes', description: 'Created or updated from meeting transcript' },
|
||||
{ tag: 'browser', type: 'source', applicability: 'notes', description: 'Content captured from web browsing' },
|
||||
{ tag: 'web-search', type: 'source', applicability: 'notes', description: 'Information from web search' },
|
||||
{ tag: 'manual', type: 'source', applicability: 'notes', description: 'Manually entered by user' },
|
||||
{ tag: 'import', type: 'source', applicability: 'notes', description: 'Imported from another system' },
|
||||
|
||||
// ── Status (notes) ──────────────────────────────────────────────────
|
||||
{ tag: 'active', type: 'status', applicability: 'notes', description: 'Currently relevant, recent activity' },
|
||||
{ tag: 'archived', type: 'status', applicability: 'notes', description: 'No longer active, kept for reference' },
|
||||
{ tag: 'stale', type: 'status', applicability: 'notes', description: 'No activity in 60+ days, needs attention or archive' },
|
||||
];
|
||||
|
||||
// ── Disk-backed config with mtime caching ──────────────────────────────────
|
||||
|
||||
export const TAGS_CONFIG_PATH = path.join(WorkDir, "config", "tags.json");
|
||||
|
||||
let cachedTagDefinitions: TagDefinition[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
function ensureTagsConfigSync(): void {
|
||||
if (!fs.existsSync(TAGS_CONFIG_PATH)) {
|
||||
fs.writeFileSync(
|
||||
TAGS_CONFIG_PATH,
|
||||
JSON.stringify(DEFAULT_TAG_DEFINITIONS, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTagDefinitions(): TagDefinition[] {
|
||||
ensureTagsConfigSync();
|
||||
try {
|
||||
const stats = fs.statSync(TAGS_CONFIG_PATH);
|
||||
if (cachedTagDefinitions && cachedMtimeMs === stats.mtimeMs) {
|
||||
return cachedTagDefinitions;
|
||||
}
|
||||
const content = fs.readFileSync(TAGS_CONFIG_PATH, "utf8");
|
||||
cachedTagDefinitions = JSON.parse(content);
|
||||
cachedMtimeMs = stats.mtimeMs;
|
||||
return cachedTagDefinitions!;
|
||||
} catch {
|
||||
cachedTagDefinitions = null;
|
||||
cachedMtimeMs = null;
|
||||
return DEFAULT_TAG_DEFINITIONS;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_ORDER: TagType[] = [
|
||||
'relationship', 'relationship-sub', 'topic', 'email-type',
|
||||
'filter', 'action', 'status', 'source',
|
||||
];
|
||||
|
||||
const TYPE_LABELS: Record<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 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);
|
||||
}
|
||||
|
|
@ -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