diff --git a/app/api/edges/route.ts b/app/api/edges/route.ts index 23b53df..935ab53 100644 --- a/app/api/edges/route.ts +++ b/app/api/edges/route.ts @@ -27,10 +27,10 @@ export async function POST(request: NextRequest) { const body = await request.json(); // Validate required fields - if (!body.from_node_id || !body.to_node_id) { + if (!body.from_node_id || !body.to_node_id || typeof body.explanation !== 'string') { return NextResponse.json({ success: false, - error: 'Missing required fields: from_node_id and to_node_id are required' + error: 'Missing required fields: from_node_id, to_node_id, and explanation are required' }, { status: 400 }); } @@ -57,6 +57,21 @@ export async function POST(request: NextRequest) { const fromId = parseInt(body.from_node_id); const toId = parseInt(body.to_node_id); + const explanation = String(body.explanation || '').trim(); + + if (!explanation) { + return NextResponse.json({ + success: false, + error: 'explanation is required and cannot be empty' + }, { status: 400 }); + } + + const skipInference = Boolean(body.skip_inference); + const createdVia = (() => { + const raw = typeof body.created_via === 'string' ? body.created_via : ''; + if (['ui', 'agent', 'mcp', 'workflow', 'quicklink'].includes(raw)) return raw as any; + return 'ui' as const; + })(); // Idempotency: prevent duplicate edges between same pair try { @@ -76,8 +91,10 @@ export async function POST(request: NextRequest) { const edge = await edgeService.createEdge({ from_node_id: fromId, to_node_id: toId, - context: body.context || {}, - source: body.source + explanation, + created_via: createdVia, + source: body.source, + skip_inference: skipInference }); return NextResponse.json({ diff --git a/app/api/nodes/[id]/regenerate-description/route.ts b/app/api/nodes/[id]/regenerate-description/route.ts index 8ea0415..2207a87 100644 --- a/app/api/nodes/[id]/regenerate-description/route.ts +++ b/app/api/nodes/[id]/regenerate-description/route.ts @@ -4,6 +4,55 @@ import { generateDescription } from '@/services/database/descriptionService'; export const runtime = 'nodejs'; +type NodeMetadata = { source?: string; channel_name?: string; author?: string; site_name?: string; type?: string } & Record; + +function parseMetadata(raw: unknown): NodeMetadata | undefined { + if (!raw) return undefined; + if (typeof raw === 'string') { + try { + return JSON.parse(raw) as NodeMetadata; + } catch { + return undefined; + } + } + if (typeof raw === 'object') { + return raw as NodeMetadata; + } + return undefined; +} + +async function enrichYoutubeMetadataIfMissing(link: string, metadata: NodeMetadata | undefined): Promise { + const url = link.trim(); + if (!url) return metadata; + if (!url.includes('youtube.com') && !url.includes('youtu.be')) return metadata; + + const existing = metadata || {}; + const hasCreatorHint = Boolean( + (typeof existing.author === 'string' && existing.author.trim()) || + (typeof existing.channel_name === 'string' && existing.channel_name.trim()) + ); + if (hasCreatorHint) return existing; + + try { + const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; + const response = await fetch(oembedUrl, { signal: AbortSignal.timeout(6000) }); + if (!response.ok) return existing; + const data = await response.json(); + const authorName = typeof data.author_name === 'string' ? data.author_name.trim() : ''; + const providerName = typeof data.provider_name === 'string' ? data.provider_name.trim() : ''; + if (!authorName) return existing; + + return { + ...existing, + source: typeof existing.source === 'string' && existing.source.trim().length > 0 ? existing.source : 'youtube', + channel_name: typeof existing.channel_name === 'string' && existing.channel_name.trim().length > 0 ? existing.channel_name : authorName, + site_name: typeof existing.site_name === 'string' && existing.site_name.trim().length > 0 ? existing.site_name : (providerName || 'YouTube'), + }; + } catch { + return existing; + } +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -28,19 +77,25 @@ export async function POST( }, { status: 404 }); } + const parsedMetadata = parseMetadata(node.metadata); + const enrichedMetadata = node.link + ? await enrichYoutubeMetadataIfMissing(node.link, parsedMetadata) + : parsedMetadata; + // Generate new description using the description service const newDescription = await generateDescription({ title: node.title, content: node.content || undefined, link: node.link || undefined, - metadata: node.metadata as { source?: string; channel_name?: string; author?: string; site_name?: string } | undefined, - type: (node.metadata as { type?: string } | null)?.type, + metadata: enrichedMetadata, + type: enrichedMetadata?.type, dimensions: node.dimensions || [] }); // Update the node with the new description const updatedNode = await nodeService.updateNode(nodeId, { - description: newDescription + description: newDescription, + metadata: enrichedMetadata ?? parsedMetadata ?? node.metadata }); return NextResponse.json({ diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts index ab17cde..8469b2d 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -5,6 +5,7 @@ import { autoEmbedQueue } from '@/services/embedding/autoEmbedQueue'; import { hasSufficientContent } from '@/services/embedding/constants'; import { DimensionService } from '@/services/database/dimensionService'; import { generateDescription } from '@/services/database/descriptionService'; +import { scheduleAutoEdgeCreation } from '@/services/agents/autoEdge'; export const runtime = 'nodejs'; @@ -126,6 +127,11 @@ export async function POST(request: NextRequest) { autoEmbedQueue.enqueue(node.id, { reason: 'node_created' }); } + // Schedule auto-edge creation (fire-and-forget, non-blocking) + if (node.id) { + scheduleAutoEdgeCreation(node.id); + } + return NextResponse.json({ success: true, data: node, diff --git a/apps/mcp-server/server.js b/apps/mcp-server/server.js index 43d7b25..6de2348 100644 --- a/apps/mcp-server/server.js +++ b/apps/mcp-server/server.js @@ -159,8 +159,7 @@ const getNodesOutputSchema = { const createEdgeInputSchema = { sourceId: z.number().int().positive().describe('Source node ID'), targetId: z.number().int().positive().describe('Target node ID'), - type: z.string().optional().describe('Edge type/label'), - weight: z.number().min(0).max(1).optional().describe('Edge weight 0-1') + explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.') }; const createEdgeOutputSchema = { @@ -191,8 +190,7 @@ const queryEdgesOutputSchema = { // rah_update_edge schemas const updateEdgeInputSchema = { id: z.number().int().positive().describe('Edge ID to update'), - type: z.string().optional().describe('New edge type/label'), - weight: z.number().min(0).max(1).optional().describe('New edge weight 0-1') + explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)') }; const updateEdgeOutputSchema = { @@ -553,12 +551,13 @@ mcpServer.registerTool( inputSchema: createEdgeInputSchema, outputSchema: createEdgeOutputSchema }, - async ({ sourceId, targetId, type, weight }) => { + async ({ sourceId, targetId, explanation }) => { const payload = { - source_id: sourceId, - target_id: targetId, - type: type || 'related', - weight: weight ?? 0.5 + from_node_id: sourceId, + to_node_id: targetId, + explanation: explanation.trim(), + source: 'helper_name', + created_via: 'mcp' }; const result = await callRaHApi('/api/edges', { @@ -602,10 +601,10 @@ mcpServer.registerTool( count: edges.length, edges: edges.map(e => ({ id: e.id, - source_id: e.source_id, - target_id: e.target_id, - type: e.type ?? null, - weight: e.weight ?? null + source_id: e.from_node_id, + target_id: e.to_node_id, + type: e.context?.type ?? null, + weight: typeof e.context?.confidence === 'number' ? e.context.confidence : null })) } }; @@ -620,18 +619,16 @@ mcpServer.registerTool( inputSchema: updateEdgeInputSchema, outputSchema: updateEdgeOutputSchema }, - async ({ id, type, weight }) => { - const payload = {}; - if (type !== undefined) payload.type = type; - if (weight !== undefined) payload.weight = weight; - - if (Object.keys(payload).length === 0) { - throw new McpError(ErrorCode.InvalidParams, 'At least one field (type or weight) must be provided.'); + async ({ id, explanation }) => { + if (typeof explanation !== 'string' || explanation.trim().length === 0) { + throw new McpError(ErrorCode.InvalidParams, 'explanation is required.'); } const result = await callRaHApi(`/api/edges/${id}`, { method: 'PUT', - body: JSON.stringify(payload) + body: JSON.stringify({ + context: { explanation: explanation.trim(), created_via: 'mcp' } + }) }); return { diff --git a/apps/mcp-server/stdio-server.js b/apps/mcp-server/stdio-server.js index e159000..f0d4ccc 100644 --- a/apps/mcp-server/stdio-server.js +++ b/apps/mcp-server/stdio-server.js @@ -108,8 +108,7 @@ const getNodesOutputSchema = { const createEdgeInputSchema = { sourceId: z.number().int().positive().describe('Source node ID'), targetId: z.number().int().positive().describe('Target node ID'), - type: z.string().optional().describe('Edge type/label'), - weight: z.number().min(0).max(1).optional().describe('Edge weight 0-1') + explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.') }; const createEdgeOutputSchema = { @@ -140,8 +139,7 @@ const queryEdgesOutputSchema = { // rah_update_edge schemas const updateEdgeInputSchema = { id: z.number().int().positive().describe('Edge ID to update'), - type: z.string().optional().describe('New edge type/label'), - weight: z.number().min(0).max(1).optional().describe('New edge weight 0-1') + explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)') }; const updateEdgeOutputSchema = { @@ -525,12 +523,13 @@ server.registerTool( inputSchema: createEdgeInputSchema, outputSchema: createEdgeOutputSchema }, - async ({ sourceId, targetId, type, weight }) => { + async ({ sourceId, targetId, explanation }) => { const payload = { from_node_id: sourceId, to_node_id: targetId, - type: type || 'related', - weight: weight ?? 0.5 + explanation: explanation.trim(), + source: 'helper_name', + created_via: 'mcp' }; const result = await callRaHApi('/api/edges', { @@ -592,18 +591,16 @@ server.registerTool( inputSchema: updateEdgeInputSchema, outputSchema: updateEdgeOutputSchema }, - async ({ id, type, weight }) => { - const payload = {}; - if (type !== undefined) payload.type = type; - if (weight !== undefined) payload.weight = weight; - - if (Object.keys(payload).length === 0) { - throw new Error('At least one field (type or weight) must be provided.'); + async ({ id, explanation }) => { + if (typeof explanation !== 'string' || explanation.trim().length === 0) { + throw new Error('explanation is required'); } const result = await callRaHApi(`/api/edges/${id}`, { method: 'PUT', - body: JSON.stringify(payload) + body: JSON.stringify({ + context: { explanation: explanation.trim(), created_via: 'mcp' } + }) }); return { diff --git a/docs/2_schema.md b/docs/2_schema.md index 2a319ac..2291383 100644 --- a/docs/2_schema.md +++ b/docs/2_schema.md @@ -58,18 +58,78 @@ Primary knowledge storage. Each row is a discrete knowledge item. ### edges Directed relationships between nodes (knowledge graph). -**Columns:** +**Important behavior:** +- **Storage is directed** (`from_node_id → to_node_id`) +- **UI treats connections as bidirectional** (a node shows edges where it is either `from` or `to`) +- **Every new edge requires an explanation** (enforced in service layer) +- **Every new edge is classified** into a structured `EdgeContext` (stored as JSON) + +**Columns (SQLite):** - `id` (INTEGER PK) -- `from_node_id` (INTEGER FK → nodes.id) -- `to_node_id` (INTEGER FK → nodes.id) -- `source` (TEXT) - How edge was created (e.g., "user", "agent") -- `context` (TEXT) - Relationship context/description -- `user_feedback` (INTEGER) - User rating +- `from_node_id` (INTEGER FK → nodes.id) — directed “from” +- `to_node_id` (INTEGER FK → nodes.id) — directed “to” +- `source` (TEXT) — creation source (`user`, `helper_name`, `ai_similarity`) - `created_at` (TEXT) +- `context` (TEXT) — JSON blob (canonical; see `EdgeContext` below) +- `explanation` (TEXT) — legacy column (currently not the canonical source of truth) +- `user_feedback` (INTEGER) — user rating (not currently used in core flows) **Indexes:** -- `idx_edges_from` - Fast "outgoing edges" queries -- `idx_edges_to` - Fast "incoming edges" queries +- `idx_edges_from` — fast “outgoing edges” queries +- `idx_edges_to` — fast “incoming edges” queries + +#### EdgeContext (canonical relationship metadata) +Stored as JSON in `edges.context`. This is the “Idea Genealogy” layer. + +```typescript +interface EdgeContext { + // SYSTEM-INFERRED (AI + heuristics classify from explanation + node context) + category: 'attribution' | 'intellectual'; + type: + | 'created_by' // attribution: authorship/creation/founding + | 'features' // attribution: appears in / host / guest / explicitly mentioned + | 'part_of' // attribution: membership/container (episode→podcast, chapter→book, video→channel) + | 'source_of' // intellectual: idea/insight came from source + | 'extends' // intellectual: builds on + | 'supports' // intellectual: evidence for + | 'contradicts' // intellectual: in tension + | 'related_to'; // intellectual: fallback + confidence: number; // 0–1 + inferred_at: string; // ISO timestamp + + // PROVIDED BY USER/AGENT + explanation: string; // required; free-form text (user can edit) + + // SYSTEM-MANAGED + created_via: 'ui' | 'agent' | 'mcp' | 'workflow' | 'quicklink'; +} +``` + +#### Direction rule (how to write explanations) +Explanations must read correctly **FROM → TO**: +- `created_by`: **FROM** was created/authored/founded by **TO** +- `features`: **FROM** features/mentions **TO** +- `part_of`: **FROM** is part of **TO** +- `source_of`: **FROM** came from / was inspired by **TO** + +#### Inference + guardrails +On edge create and on explanation edits, RA-H: +- runs lightweight **heuristics** for common phrases (e.g., “Created by …”, “Part of …”, “Came from …”, “Features …”) +- otherwise runs an AI classification step to populate `category/type/confidence` + +The UI also provides 4 quick chips to reduce user cognitive load: +- **Made by** → “Created by …” +- **Part of** → “Part of …” +- **Came from** → “Came from …” +- **Related** → “Related to …” + +#### Where edges get created/updated +All edge creation funnels through the service layer enforcement: +- UI (`FocusPanel`) — requires explanation; allows editing explanation (re-infers) +- REST API `POST /api/edges` — requires `explanation` +- Tooling (`createEdge` tool) — requires `explanation` +- MCP (`rah_create_edge`) — requires `explanation` +- Workflows (e.g. `connect`, `integrate`) — call `createEdge` with `explanation` ### chunks Long-form content split into searchable pieces. diff --git a/src/components/agents/AgentsPanel.tsx b/src/components/agents/AgentsPanel.tsx index 356d9a4..8164ef0 100644 --- a/src/components/agents/AgentsPanel.tsx +++ b/src/components/agents/AgentsPanel.tsx @@ -202,11 +202,11 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension height: '100%', position: 'relative' }}> - {/* Top Bar - just collapse button */} + {/* Top Bar - collapse button + Add Stuff */}
{onCollapse && ( @@ -238,6 +238,13 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension )} + {/* Add Stuff - top right */} +
+ +
{/* Center Section - Start button centered */} @@ -280,7 +287,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension } }} > - Start -
- - {/* Quick Add - at bottom, full width with padding */} -
- -
) : null} @@ -399,7 +398,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension e.currentTarget.style.borderColor = '#1f1f1f'; }} > - + - Capture + Add Stuff diff --git a/src/components/agents/QuickAddInput.tsx b/src/components/agents/QuickAddInput.tsx index d54c170..c198dcb 100644 --- a/src/components/agents/QuickAddInput.tsx +++ b/src/components/agents/QuickAddInput.tsx @@ -1,14 +1,11 @@ "use client"; -import { useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; +import { useState } from 'react'; import type { AgentDelegation } from '@/services/agents/delegation'; -type QuickAddIntent = 'link' | 'note' | 'chat'; - interface QuickAddSubmitPayload { input: string; - mode: QuickAddIntent; + mode: 'link' | 'note' | 'chat'; description?: string; } @@ -17,63 +14,10 @@ interface QuickAddInputProps { onSubmit: (payload: QuickAddSubmitPayload) => Promise; } -const MODE_CONFIG: Array<{ - key: QuickAddIntent; - label: string; - hint: string; - icon: ReactNode; -}> = [ - { - key: 'link', - label: 'Link', - hint: 'Drop URLs for auto extraction', - icon: ( - - - - - ) - }, - { - key: 'note', - label: 'Note', - hint: 'Quick note, no processing', - icon: ( - - - - - - - ) - }, - { - key: 'chat', - label: 'Chat', - hint: 'Paste conversations', - icon: ( - - - - ) - } -]; - export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) { const [input, setInput] = useState(''); - const [description, setDescription] = useState(''); const [isPosting, setIsPosting] = useState(false); const [isExpanded, setIsExpanded] = useState(false); - const [manualMode, setManualMode] = useState(null); - const [autoMode, setAutoMode] = useState('link'); - - const effectiveMode: QuickAddIntent = manualMode ?? autoMode; - - const currentPlaceholder = useMemo(() => { - if (effectiveMode === 'note') return 'Write a quick note...'; - if (effectiveMode === 'chat') return 'Paste conversation...'; - return 'Paste a link or write something...'; - }, [effectiveMode]); const maxConcurrent = 5; const activeCount = activeDelegations.filter( @@ -81,44 +25,17 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI ).length; const isSoftLimited = activeCount >= maxConcurrent; - const inferChatIntent = (value: string) => { - const trimmed = value.trim(); - if (!trimmed || manualMode) return; - - const newlineCount = (trimmed.match(/\n/g)?.length ?? 0); - const looksLikeTranscript = - newlineCount >= 2 || - /You said:|ChatGPT said:|Claude said:|Assistant:|User:/i.test(trimmed); - - if (looksLikeTranscript && trimmed.length > 280) { - setAutoMode('chat'); - } - }; - - const handleInputChange = (value: string) => { - setInput(value); - inferChatIntent(value); - }; - - const handleModeClick = (mode: QuickAddIntent) => { - setManualMode(mode); - setAutoMode(mode); - }; - const handleSubmit = async () => { if (!input.trim() || isPosting || isSoftLimited) return; setIsPosting(true); try { + // Mode is auto-detected server-side via quickAdd.ts detectInputType() await onSubmit({ input: input.trim(), - mode: effectiveMode, - description: description.trim() || undefined + mode: 'link', // Default; actual type is inferred server-side }); setInput(''); - setDescription(''); - setManualMode(null); - setAutoMode('link'); setIsExpanded(false); } catch (error) { console.error('[QuickAddInput] Submit error:', error); @@ -138,7 +55,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI } }; - // Collapsed state - visible trigger + // Collapsed state - prominent "ADD STUFF" button if (!isExpanded) { return ( ); } - // Expanded state + // Expanded state - full width overlay return (
- {/* Mode tabs */} -
- {MODE_CONFIG.map((mode) => { - const isActive = effectiveMode === mode.key; - return ( - - ); - })} - + {/* Header */} +
+ + + + Add Stuff + + {/* Close button */}
- {/* Input area - consistent height */} + {/* Input area - expanded */}