feat(suggested-topics): populate and integrate suggested topics

This commit is contained in:
Arjun 2026-04-17 21:28:57 +05:30 committed by arkml
parent e9cdd3f6eb
commit eaab438666
6 changed files with 448 additions and 66 deletions

View file

@ -262,6 +262,60 @@ const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
const getSuggestedTopicTargetFolder = (category?: string) => {
const normalized = category?.trim().toLowerCase()
switch (normalized) {
case 'people':
case 'person':
return 'People'
case 'organizations':
case 'organization':
return 'Organizations'
case 'projects':
case 'project':
return 'Projects'
case 'meetings':
case 'meeting':
return 'Meetings'
case 'topics':
case 'topic':
default:
return 'Topics'
}
}
const buildSuggestedTopicExplorePrompt = ({
title,
description,
category,
}: {
title: string
description: string
category?: string
}) => {
const folder = getSuggestedTopicTargetFolder(category)
const categoryLabel = category?.trim() || 'Topics'
return [
'I am exploring a suggested topic card from the Suggested Topics panel.',
'This card may represent a person, organization, topic, or project.',
'',
'Card context:',
`- Title: ${title}`,
`- Category: ${categoryLabel}`,
`- Description: ${description}`,
`- Target folder if we set this up: knowledge/${folder}/`,
'',
`Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`,
'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.',
'Do not create or modify anything yet.',
'Treat a clear confirmation from me as explicit approval to proceed.',
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
].join('\n')
}
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
@ -585,7 +639,7 @@ function App() {
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
nodes: [],
@ -2664,15 +2718,16 @@ function App() {
}
handleNewChat()
// Left-pane "new chat" should always open full chat view.
if (selectedPath || isGraphOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
} else {
setExpandedFrom(null)
}
setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen])
setIsSuggestedTopicsOpen(false)
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen])
// Sidebar variant: create/switch chat tab without leaving file/graph context.
const handleNewChatTabInSidebar = useCallback(() => {
@ -2766,19 +2821,26 @@ function App() {
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
}
setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
}, [selectedPath, isGraphOpen])
setIsSuggestedTopicsOpen(false)
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
if (expandedFrom.graph) {
setIsGraphOpen(true)
setIsSuggestedTopicsOpen(false)
} else if (expandedFrom.suggestedTopics) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(true)
} else if (expandedFrom.path) {
setIsGraphOpen(false)
setIsSuggestedTopicsOpen(false)
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
@ -3179,7 +3241,7 @@ function App() {
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3257,12 +3319,16 @@ function App() {
const handleTabKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen)
const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
const selectedKnowledgePath = isGraphOpen
? GRAPH_TAB_PATH
: isSuggestedTopicsOpen
? SUGGESTED_TOPICS_TAB_PATH
: selectedPath
const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath
? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
@ -3316,7 +3382,7 @@ function App() {
}
document.addEventListener('keydown', handleTabKeyDown)
return () => document.removeEventListener('keydown', handleTabKeyDown)
}, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
@ -3341,7 +3407,7 @@ function App() {
}),
},
}))
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -3463,14 +3529,14 @@ function App() {
},
openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'graph' })
},
openBases: () => {
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
@ -4042,7 +4108,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen)
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => {
@ -4084,7 +4150,7 @@ function App() {
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
setIsChatSidebarOpen(true)
}
@ -4095,7 +4161,7 @@ function App() {
return
}
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
if (selectedPath || isGraphOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad)
return
@ -4119,14 +4185,14 @@ function App() {
} else {
// Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
} else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen || isBrowserOpen) {
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat()
} else {
@ -4152,6 +4218,7 @@ function App() {
onToggleMeeting={() => { void handleToggleMeeting() }}
isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser}
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
/>
<SidebarInset
@ -4233,7 +4300,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4248,7 +4315,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !isBrowserOpen && expandedFrom && (
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4263,7 +4330,7 @@ function App() {
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)}
{(selectedPath || isGraphOpen) && (
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4287,8 +4354,8 @@ function App() {
) : isSuggestedTopicsOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<SuggestedTopicsView
onExploreTopic={(title, description) => {
const prompt = `I'd like to explore the topic: ${title}. ${description}`
onExploreTopic={(topic) => {
const prompt = buildSuggestedTopicExplorePrompt(topic)
submitFromPalette(prompt, null)
}}
/>

View file

@ -186,6 +186,7 @@ type SidebarContentPanelProps = {
onToggleMeeting?: () => void
isBrowserOpen?: boolean
onToggleBrowser?: () => void
isSuggestedTopicsOpen?: boolean
onOpenSuggestedTopics?: () => void
} & React.ComponentProps<typeof Sidebar>
@ -418,6 +419,7 @@ export function SidebarContentPanel({
onToggleMeeting,
isBrowserOpen = false,
onToggleBrowser,
isSuggestedTopicsOpen = false,
onOpenSuggestedTopics,
...props
}: SidebarContentPanelProps) {
@ -579,6 +581,21 @@ export function SidebarContentPanel({
<span>Run browser task</span>
</button>
)}
{onOpenSuggestedTopics && (
<button
type="button"
onClick={onOpenSuggestedTopics}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
isSuggestedTopicsOpen
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<Lightbulb className="size-4" />
<span>Suggested Topics</span>
</button>
)}
</div>
</SidebarHeader>
<SidebarContent>
@ -707,18 +724,6 @@ export function SidebarContentPanel({
</AlertDialog>
)}
</div>
{onOpenSuggestedTopics && (
<button
onClick={onOpenSuggestedTopics}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Lightbulb className="size-4" />
<span>Suggested Topics</span>
<span className="ml-auto rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-blue-600 dark:text-blue-400">
NEW
</span>
</button>
)}
<SettingsDialog>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<Settings className="size-4" />

View file

@ -2,6 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Lightbulb, Loader2 } from 'lucide-react'
import { SuggestedTopicBlockSchema, type SuggestedTopicBlock } from '@x/shared/dist/blocks.js'
const SUGGESTED_TOPICS_PATH = 'suggested-topics.md'
const LEGACY_SUGGESTED_TOPICS_PATHS = [
'config/suggested-topics.md',
'knowledge/Notes/Suggested Topics.md',
]
/** Parse suggestedtopic code-fence blocks from the markdown file content. */
function parseTopics(content: string): SuggestedTopicBlock[] {
const topics: SuggestedTopicBlock[] = []
@ -16,13 +22,42 @@ function parseTopics(content: string): SuggestedTopicBlock[] {
// Skip malformed blocks
}
}
if (topics.length > 0) return topics
const lines = content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'))
for (const line of lines) {
try {
const parsed = JSON.parse(line)
const topic = SuggestedTopicBlockSchema.parse(parsed)
topics.push(topic)
} catch {
// Skip malformed lines
}
}
return topics
}
function serializeTopics(topics: SuggestedTopicBlock[]): string {
const blocks = topics.map((topic) => [
'```suggestedtopic',
JSON.stringify(topic),
'```',
].join('\n'))
return ['# Suggested Topics', ...blocks].join('\n\n') + '\n'
}
const CATEGORY_COLORS: Record<string, string> = {
Meetings: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
Projects: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
People: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
Organizations: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400',
Topics: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
}
@ -33,10 +68,11 @@ function getCategoryColor(category?: string): string {
interface TopicCardProps {
topic: SuggestedTopicBlock
onExplore: (topic: SuggestedTopicBlock) => void
onTrack: () => void
isRemoving: boolean
}
function TopicCard({ topic, onExplore }: TopicCardProps) {
function TopicCard({ topic, onTrack, isRemoving }: TopicCardProps) {
return (
<div className="group flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-5 transition-all hover:border-border hover:shadow-sm">
<div className="flex items-start justify-between gap-3">
@ -55,32 +91,72 @@ function TopicCard({ topic, onExplore }: TopicCardProps) {
{topic.description}
</p>
<button
onClick={() => onExplore(topic)}
className="mt-auto flex w-fit items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
type="button"
onClick={onTrack}
disabled={isRemoving}
className="mt-auto flex w-fit items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
>
Explore
{isRemoving ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Tracking
</>
) : (
<>
Track
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</>
)}
</button>
</div>
)
}
interface SuggestedTopicsViewProps {
onExploreTopic: (title: string, description: string) => void
onExploreTopic: (topic: SuggestedTopicBlock) => void
}
export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) {
const [topics, setTopics] = useState<SuggestedTopicBlock[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [removingIndex, setRemovingIndex] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const result = await window.ipc.invoke('workspace:readFile', {
path: 'config/suggested-topics.md',
let result
try {
result = await window.ipc.invoke('workspace:readFile', {
path: SUGGESTED_TOPICS_PATH,
})
} catch {
let legacyResult: { data?: string } | null = null
let legacyPath: string | null = null
for (const path of LEGACY_SUGGESTED_TOPICS_PATHS) {
try {
legacyResult = await window.ipc.invoke('workspace:readFile', { path })
legacyPath = path
break
} catch {
// Try next legacy location.
}
}
if (!legacyResult || !legacyPath) {
throw new Error('Suggested topics file not found')
}
await window.ipc.invoke('workspace:writeFile', {
path: SUGGESTED_TOPICS_PATH,
data: legacyResult.data,
opts: { encoding: 'utf8' },
})
await window.ipc.invoke('workspace:remove', {
path: legacyPath,
opts: { trash: true },
})
result = legacyResult
}
if (cancelled) return
if (result.data) {
setTopics(parseTopics(result.data))
@ -95,11 +171,30 @@ export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps
return () => { cancelled = true }
}, [])
const handleExplore = useCallback(
(topic: SuggestedTopicBlock) => {
onExploreTopic(topic.title, topic.description)
const handleTrack = useCallback(
async (topic: SuggestedTopicBlock, topicIndex: number) => {
if (removingIndex !== null) return
const nextTopics = topics.filter((_, idx) => idx !== topicIndex)
setRemovingIndex(topicIndex)
setError(null)
try {
await window.ipc.invoke('workspace:writeFile', {
path: SUGGESTED_TOPICS_PATH,
data: serializeTopics(nextTopics),
opts: { encoding: 'utf8' },
})
setTopics(nextTopics)
} catch (err) {
console.error('Failed to remove suggested topic:', err)
setError('Failed to update suggested topics. Please try again.')
return
} finally {
setRemovingIndex(null)
}
onExploreTopic(topic)
},
[onExploreTopic],
[onExploreTopic, removingIndex, topics],
)
if (loading) {
@ -131,13 +226,18 @@ export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps
<h2 className="text-base font-semibold text-foreground">Suggested Topics</h2>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Topics surfaced from your knowledge graph. Explore them to create new notes.
Suggested notes surfaced from your knowledge graph. Track one to start a tracking note.
</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{topics.map((topic, i) => (
<TopicCard key={`${topic.title}-${i}`} topic={topic} onExplore={handleExplore} />
<TopicCard
key={`${topic.title}-${i}`}
topic={topic}
onTrack={() => { void handleTrack(topic, i) }}
isRemoving={removingIndex === i}
/>
))}
</div>
</div>

View file

@ -180,6 +180,21 @@ Workflow:
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
### Suggested Topics exploration flow
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
- "I am exploring a suggested topic card from the Suggested Topics panel."
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
In that flow:
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
## The Exact Text to Insert
Write it verbatim like this (including the blank line between fence and target):

View file

@ -25,6 +25,12 @@ import { getTagDefinitions } from './tag_system.js';
const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
const NOTE_CREATION_AGENT = 'note_creation';
const SUGGESTED_TOPICS_REL_PATH = 'suggested-topics.md';
const SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'suggested-topics.md');
const LEGACY_SUGGESTED_TOPICS_REL_PATH = 'config/suggested-topics.md';
const LEGACY_SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'config', 'suggested-topics.md');
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH = 'knowledge/Notes/Suggested Topics.md';
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', 'Notes', 'Suggested Topics.md');
// Configuration for the graph builder service
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
@ -88,6 +94,49 @@ function extractPathFromToolInput(input: string): string | null {
}
}
function ensureSuggestedTopicsFileLocation(): string {
if (fs.existsSync(SUGGESTED_TOPICS_PATH)) {
return SUGGESTED_TOPICS_PATH;
}
const legacyCandidates: Array<{ absPath: string; relPath: string }> = [
{ absPath: LEGACY_SUGGESTED_TOPICS_PATH, relPath: LEGACY_SUGGESTED_TOPICS_REL_PATH },
{ absPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH, relPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH },
];
for (const legacy of legacyCandidates) {
if (!fs.existsSync(legacy.absPath)) {
continue;
}
try {
fs.renameSync(legacy.absPath, SUGGESTED_TOPICS_PATH);
console.log(`[buildGraph] Moved suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}`);
return SUGGESTED_TOPICS_PATH;
} catch (error) {
console.error(`[buildGraph] Failed to move suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}:`, error);
return legacy.absPath;
}
}
return SUGGESTED_TOPICS_PATH;
}
function readSuggestedTopicsFile(): string {
try {
const suggestedTopicsPath = ensureSuggestedTopicsFileLocation();
if (!fs.existsSync(suggestedTopicsPath)) {
return '_No existing suggested topics file._';
}
const content = fs.readFileSync(suggestedTopicsPath, 'utf-8').trim();
return content.length > 0 ? content : '_Existing suggested topics file is empty._';
} catch (error) {
console.error(`[buildGraph] Error reading suggested topics file:`, error);
return '_Failed to read existing suggested topics file._';
}
}
/**
* Get unprocessed voice memo files from knowledge/Voice Memos/
* Voice memos are created directly in this directory by the UI.
@ -203,6 +252,7 @@ async function createNotesFromBatch(
const run = await createRun({
agentId: NOTE_CREATION_AGENT,
});
const suggestedTopicsContent = readSuggestedTopicsFile();
// Build message with index and all files in the batch
let message = `Process the following ${files.length} source files and create/update obsidian notes.\n\n`;
@ -210,8 +260,9 @@ async function createNotesFromBatch(
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
message += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\n`;
message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`;
message += `- Use workspace tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`;
message += `- Follow the note templates and guidelines in your instructions\n\n`;
// Add the knowledge base index
@ -219,6 +270,11 @@ async function createNotesFromBatch(
message += knowledgeIndex;
message += `\n---\n\n`;
message += `# Current Suggested Topics File\n\n`;
message += `Path: ${SUGGESTED_TOPICS_REL_PATH}\n\n`;
message += suggestedTopicsContent;
message += `\n\n---\n\n`;
// Add each file's content
message += `# Source Files to Process\n\n`;
files.forEach((file, idx) => {

View file

@ -485,9 +485,9 @@ RESOLVED (use canonical name with absolute path):
- "Acme", "Acme Corp", "@acme.com" [[Organizations/Acme Corp]]
- "the pilot", "the integration" [[Projects/Acme Integration]]
NEW ENTITIES (create notes if source passes filters):
NEW ENTITIES (create notes or suggestion cards if source passes filters):
- "Jennifer" (CTO, Acme Corp) Create [[People/Jennifer]] or [[People/Jennifer (Acme Corp)]]
- "SOC 2" Create [[Topics/Security Compliance]]
- "SOC 2" Add or update a suggestion card in \`suggested-topics.md\` with category \`Topics\`
AMBIGUOUS (flag or skip):
- "Mike" (no context) Mention in activity only, don't create note
@ -508,8 +508,8 @@ For entities not resolved to existing notes, determine if they warrant new notes
**CREATE a note for people who are:**
- External (not @user.domain)
- Attendees in meetings
- Email correspondents (emails that reach this step already passed label-based filtering)
- People you directly interacted with in meetings
- Email correspondents directly participating in the thread (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
@ -521,6 +521,7 @@ For entities not resolved to existing notes, determine if they warrant new notes
- Large group meeting attendees you didn't interact with
- Internal colleagues (@user.domain)
- Assistants handling only logistics
- People mentioned only as third parties ("we work with X", "I can introduce you to Y") when there has been no direct interaction yet
### Role Inference
@ -579,31 +580,155 @@ For people who don't warrant their own note, add to Organization note's Contacts
- Sarah Lee Support, handled wire transfer issue
\`\`\`
### Direct Interaction Test (People and Organizations)
For **new canonical People and Organizations notes**, require **direct interaction**, not just mention.
**Direct interaction = YES**
- The person sent the email, replied in the thread, or was directly addressed as part of the active exchange
- The person participated in the meeting, and there is evidence the user actually interacted with them or the meeting centered on them
- The organization is directly represented in the exchange by participants/senders and is part of an active first-degree relationship with the user or team
- The user is directly evaluating, selling to, buying from, partnering with, interviewing, or coordinating with that person or organization
**Direct interaction = NO**
- Someone else mentions them in passing
- A sender says they work with someone at another company
- A sender offers to introduce the user to someone
- A company is referenced as a customer, partner, employer, competitor, or example, but nobody from that company is directly involved in the interaction
- The source only establishes a second-degree relationship, not a direct one
**Canonical note rule:**
- For **new People/Organizations**, create the canonical note only if both are true:
1. There is **direct interaction**
2. The entity clears the **weekly importance test**
If an entity seems strategically relevant but fails the direct interaction test, do **not** auto-create a canonical note. At most, create a suggestion card in \`suggested-topics.md\`.
### Weekly Importance Test (People and Organizations only)
For **People** and **Organizations**, the final gate for **creating a new canonical note** is an importance test:
**Ask:** _"If I were the user, would I realistically need to look at this note on a weekly basis over the near term?"_
This test is mainly for **People** and **Organizations**. **Do NOT use it as the decision rule for Topic or Project suggestions.**
**Strong YES signals:**
- Active customer, prospect, investor, partner, candidate, advisor, or strategic vendor relationship
- Repeated interaction or a likely ongoing cadence
- Decision-maker, owner, blocker, evaluator, or approver in an active process
- Material relevance to launch, sales, fundraising, hiring, compliance, product delivery, or another current priority
- The user would benefit from a durable reference note instead of repeatedly reopening raw emails or meeting transcripts
**Strong NO signals:**
- One-off logistics, scheduling, or transactional contact
- Assistant, support rep, recruiter, or vendor rep with no ongoing strategic role
- Incidental attendee mentioned once with no leverage on current work
- Passing mention with no evidence of an ongoing relationship
**Borderline signals:**
- Seems potentially important, but there isn't enough evidence yet that the user will need a weekly reference note
- Might become important soon, but the role, relationship, or repeated relevance is still unclear
- Important enough to track, but only through second-degree mention or an offered introduction rather than direct interaction
**Outcome rules for new People/Organizations:**
- **Clear YES + direct interaction** Create/update the canonical \`People/\` or \`Organizations/\` note
- **Borderline or no direct interaction, but still strategically relevant** Do **not** create the canonical note yet; instead create or update a card in \`suggested-topics.md\`
- **Clear NO** Skip note creation and do not add a suggestion unless the source strongly suggests near-term strategic relevance
**When a canonical note already exists:**
- Update the existing note even if the current source is weaker; the importance test is mainly for deciding whether to create a **new** People/Organization note
- If a previously tentative person/org is now clearly important enough for a canonical note, create/update the note and remove any tentative suggestion card for that exact entity from \`suggested-topics.md\`
## Organizations
**CREATE a note if:**
- Someone from that org attended a meeting
- They're a customer, prospect, investor, or partner
- Someone from that org sent relevant personalized correspondence
- There is direct interaction with that org in the source
- They're a customer, prospect, investor, or partner in a direct first-degree interaction
- Someone from that org sent relevant personalized correspondence or joined a meeting you actually had with them
- They pass the weekly importance test above
**DO NOT create for:**
- Tool/service providers mentioned in passing
- One-time transactional vendors
- Consumer service companies
- Organizations only referenced through third-party mention or offered introductions
## Projects
**CREATE a note if:**
**If a project note already exists:** update it.
**If no project note exists:** do **not** create a new canonical note in \`knowledge/Projects/\`.
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the project is strong enough:
- Discussed substantively in a meeting or email thread
- Has a goal and timeline
- Involves multiple interactions
Otherwise skip it.
Projects do **not** use the weekly importance test above. For **new** projects, the default output is a suggestion card, not a canonical note.
## Topics
**CREATE a note if:**
**If a topic note already exists:** update it.
**If no topic note exists:** do **not** create a new canonical note in \`knowledge/Topics/\`.
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the topic is strong enough:
- Recurring theme discussed
- Will come up again across conversations
Otherwise skip it.
Topics do **not** use the weekly importance test above. For **new** topics, the default output is a suggestion card, not a canonical note.
## Suggested Topics Curation
Also maintain \`suggested-topics.md\` as a **curated shortlist** of things worth exploring next.
Despite the filename, \`suggested-topics.md\` can contain cards for **People, Organizations, Topics, or Projects**.
There are **two reasons** to add or update a suggestion card:
1. **High-quality Topic/Project cards**
- Use these for topics or projects that are timely, high-leverage, strategically important, or clearly worth exploring now
- These are not a dump of every topic/project note. Be selective
- For **new** topics and projects, cards are the default output from this pipeline
2. **Tentative People/Organization cards**
- Use these when a person or organization seems important enough to track, but you are **not 100% sure** they clear the weekly-importance test for a canonical note yet
- The card should capture why they might matter and what still needs verification
**Do NOT add cards for:**
- Low-signal administrative or transactional entities
- Stale or completed items with no near-term relevance
- People/organizations that already have a clearly established canonical note, unless the card is about a distinct project/topic exploration rather than the entity itself
**Card guidance:**
- For **Topics/Projects**, use category \`Topics\` or \`Projects\`
- For tentative **People/Organizations**, use category \`People\` or \`Organizations\`
- Title should be concise and canonical when possible
- Description should explain why it matters **now**
- For tentative People/Organizations, description should also mention what is still uncertain or what the user should verify
**Curation rules:**
- Maintain a **high-quality set**, not an ever-growing backlog
- Deduplicate by normalized title
- Prefer current, actionable, recurring, or strategically important items
- Keep only the strongest **8-12 cards total**
- Preserve good existing cards unless the new source clearly supersedes them
- Remove stale cards that are no longer relevant
- If a tentative People/Organization card later becomes clearly important and you create a canonical note, remove the tentative card
**File format for \`suggested-topics.md\`:**
\`\`\`suggestedtopic
{"title":"Security Compliance","description":"Summarize the current compliance posture, blockers, and customer implications.","category":"Topics"}
\`\`\`
The file should start with \`# Suggested Topics\` followed by one or more blocks in that format.
If the file does not exist, create it. If it exists, update it in place or rewrite the full file so the final result is clean, deduped, and curated.
---
# Step 6: Extract Content
@ -824,7 +949,7 @@ If new info contradicts existing:
# Step 9: Write Updates
## 9a: Create and Update Notes
## 9a: Create and Update Notes and Suggested Topic Cards
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
@ -852,6 +977,12 @@ workspace-edit({
})
\`\`\`
**For \`suggested-topics.md\`:**
- Use workspace-relative path \`suggested-topics.md\`
- Read the current file if you need the latest content
- Use \`workspace-writeFile\` to create or rewrite the file when that is simpler and cleaner
- Use \`workspace-edit\` for small targeted edits only if that keeps the file deduped and readable
## 9b: Apply State Changes
For each state change identified in Step 7, update the relevant fields.
@ -867,8 +998,9 @@ If you discovered new name variants during resolution, add them to Aliases field
- Be concise: one line per activity entry
- Note state changes with \`[Field → value]\` in activity
- Escape quotes properly in shell commands
- Write only one file per response (no multi-file write batches)
- Write only one file per response (notes and \`suggested-topics.md\` follow the same rule)
- **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date.
- Keep \`suggested-topics.md\` curated, deduped, and capped to the strongest 8-12 cards
---
@ -957,8 +1089,12 @@ Before completing, verify:
**Filtering:**
- [ ] Excluded self (user.name, user.email, @user.domain)
- [ ] Applied relevance test to each person
- [ ] Applied the direct interaction test to new People/Organizations
- [ ] Applied the weekly importance test to new People/Organizations
- [ ] Transactional contacts in Org Contacts, not People notes
- [ ] Source correctly classified (process vs skip)
- [ ] Third-party mentions did not become new canonical People/Organizations notes
- [ ] Borderline People/Organizations became suggestion cards instead of canonical notes
**Content Quality:**
- [ ] Summaries describe relationship, not communication method
@ -978,6 +1114,9 @@ Before completing, verify:
- [ ] All entity mentions use \`[[Folder/Name]]\` absolute links
- [ ] Activity entries are reverse chronological
- [ ] No duplicate activity entries
- [ ] \`suggested-topics.md\` stays deduped and curated
- [ ] High-quality Topics/Projects were added to suggested topics only when timely and useful
- [ ] New Topics/Projects were not auto-created as canonical notes
- [ ] Dates are YYYY-MM-DD
- [ ] Bidirectional links are consistent
- [ ] New notes in correct folders