sync: Idea Genealogy + Quick Capture Overhaul

From private repo:

Idea Genealogy:
- Required edge explanations across UI/tools/API/MCP
- Inferred edge schema (category/type/confidence)
- Added part_of relationship type
- Connection UI chips (Made by / Part of / Came from / Related)

Quick Capture Overhaul:
- Jina.ai fallback for JS-rendered sites (Twitter, SPAs)
- Simplified UI: removed mode buttons, auto-detect input type
- Renamed to "Add Stuff", moved to top-right
- Auto-edge creation from description entities
- Increased content summary length (500→1500 chars)

Description Generation Fix:
- Simplified prompt to "what is this"
- Added link/dimensions context
- Better creator attribution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-14 17:08:26 +11:00
co-authored by Claude Opus 4.5
parent f7b8b2058c
commit 2cbc950fd4
21 changed files with 1296 additions and 389 deletions
+21 -4
View File
@@ -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({
@@ -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<string, unknown>;
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<NodeMetadata | undefined> {
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({
+6
View File
@@ -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,
+18 -21
View File
@@ -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 {
+12 -15
View File
@@ -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 {
+68 -8
View File
@@ -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; // 01
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.
+10 -11
View File
@@ -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 */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
justifyContent: 'space-between',
marginBottom: '24px'
}}>
{onCollapse && (
@@ -238,6 +238,13 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
<Minimize2 size={14} />
</button>
)}
{/* Add Stuff - top right */}
<div style={{ maxWidth: '200px' }}>
<QuickAddInput
activeDelegations={orderedDelegations}
onSubmit={handleQuickAddSubmit}
/>
</div>
</div>
{/* Center Section - Start button centered */}
@@ -314,14 +321,6 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
</div>
</button>
</div>
{/* Quick Add - at bottom, full width with padding */}
<div style={{ width: '100%', padding: '0 16px 16px 16px' }}>
<QuickAddInput
activeDelegations={orderedDelegations}
onSubmit={handleQuickAddSubmit}
/>
</div>
</div>
) : null}
@@ -413,7 +412,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
fontWeight: 600,
flexShrink: 0
}}>+</span>
Capture
Add Stuff
</button>
</div>
+73 -209
View File
@@ -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<void>;
}
const MODE_CONFIG: Array<{
key: QuickAddIntent;
label: string;
hint: string;
icon: ReactNode;
}> = [
{
key: 'link',
label: 'Link',
hint: 'Drop URLs for auto extraction',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10 13a5 5 0 0 0 7.54.35l2.12-2.12a5 5 0 1 0-7.07-7.07l-1.29 1.29" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.35l-2.12 2.12a5 5 0 1 0 7.07 7.07l1.29-1.29" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
},
{
key: 'note',
label: 'Note',
hint: 'Quick note, no processing',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 2v6h6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 13H8" strokeLinecap="round" />
<path d="M16 17H8" strokeLinecap="round" />
</svg>
)
},
{
key: 'chat',
label: 'Chat',
hint: 'Paste conversations',
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H9l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
];
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<QuickAddIntent | null>(null);
const [autoMode, setAutoMode] = useState<QuickAddIntent>('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 (
<button
@@ -147,114 +64,105 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
width: '100%',
padding: '16px 24px',
background: 'transparent',
border: '1px solid #333',
borderRadius: '12px',
color: '#888',
fontSize: '12px',
gap: '8px',
padding: '10px 16px',
background: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: '8px',
color: '#22c55e',
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.1em',
letterSpacing: '0.08em',
textTransform: 'uppercase',
cursor: 'pointer',
transition: 'all 0.2s ease'
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
boxShadow: '0 0 12px rgba(34, 197, 94, 0.15)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#444';
e.currentTarget.style.color = '#aaa';
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)';
e.currentTarget.style.borderColor = '#22c55e';
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#333';
e.currentTarget.style.color = '#888';
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)';
e.currentTarget.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.15)';
}}
>
CAPTURE
<span style={{
width: '22px',
height: '22px',
width: '18px',
height: '18px',
borderRadius: '50%',
background: '#22c55e',
color: '#0a0a0a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontSize: '12px',
fontWeight: 700
}}>+</span>
Add Stuff
</button>
);
}
// Expanded state
// Expanded state - full width overlay
return (
<div style={{
position: 'absolute',
top: '60px',
left: '20px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
width: '100%',
background: 'transparent',
padding: '0',
background: '#0a0a0a',
padding: '16px',
borderRadius: '12px',
border: '1px solid #2a2a2a',
zIndex: 100,
animation: 'fadeIn 150ms ease-out'
}}>
{/* Mode tabs */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
{MODE_CONFIG.map((mode) => {
const isActive = effectiveMode === mode.key;
return (
<button
key={mode.key}
type="button"
onClick={() => handleModeClick(mode.key)}
title={mode.hint}
style={{
{/* Header */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 14px',
borderRadius: '8px',
border: isActive ? '1px solid #333' : '1px solid transparent',
background: isActive ? '#1a1a1a' : 'transparent',
color: isActive ? '#fff' : '#666',
fontSize: '12px',
justifyContent: 'space-between'
}}>
<span style={{
color: '#22c55e',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s ease',
letterSpacing: '0.1em',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.color = '#999';
e.currentTarget.style.background = '#1a1a1a';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.color = '#666';
e.currentTarget.style.background = 'transparent';
}
}}
>
<span style={{ display: 'flex', opacity: isActive ? 1 : 0.7 }}>{mode.icon}</span>
{mode.label}
</button>
);
})}
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{
width: '18px',
height: '18px',
borderRadius: '50%',
background: '#22c55e',
color: '#0a0a0a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700
}}>+</span>
Add Stuff
</span>
{/* Close button */}
<button
onClick={() => {
setIsExpanded(false);
setInput('');
setDescription('');
}}
style={{
marginLeft: 'auto',
padding: '8px',
padding: '6px',
background: 'transparent',
border: 'none',
color: '#666',
@@ -274,28 +182,29 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
</button>
</div>
{/* Input area - consistent height */}
{/* Input area - expanded */}
<div style={{ position: 'relative' }}>
<textarea
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={currentPlaceholder}
placeholder="Paste a URL, note, or transcript — RA-H will figure out what it is."
disabled={isPosting || isSoftLimited}
autoFocus
style={{
width: '100%',
minHeight: '80px',
maxHeight: '200px',
padding: '12px 14px',
minHeight: '140px',
maxHeight: '300px',
padding: '14px 16px',
background: '#0a0a0a',
border: '1px solid #1f1f1f',
borderRadius: '8px',
borderRadius: '10px',
color: '#e5e5e5',
fontSize: '14px',
fontFamily: 'inherit',
outline: 'none',
resize: 'none',
lineHeight: '1.6',
transition: 'border-color 0.15s ease'
}}
onFocus={(e) => {
@@ -307,51 +216,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
/>
</div>
{/* Description field - optional */}
<div style={{ position: 'relative' }}>
<textarea
value={description}
onChange={(e) => {
if (e.target.value.length <= 280) {
setDescription(e.target.value);
}
}}
onKeyDown={handleKeyDown}
placeholder="This is a... (optional)"
disabled={isPosting || isSoftLimited}
style={{
width: '100%',
minHeight: '50px',
maxHeight: '100px',
padding: '10px 14px',
background: '#0a0a0a',
border: '1px solid #1f1f1f',
borderRadius: '8px',
color: '#a5a5a5',
fontSize: '13px',
fontFamily: 'inherit',
outline: 'none',
resize: 'none',
transition: 'border-color 0.15s ease'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#333';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#1f1f1f';
}}
/>
<span style={{
position: 'absolute',
bottom: '8px',
right: '10px',
fontSize: '10px',
color: description.length >= 260 ? '#f59e0b' : '#525252'
}}>
{description.length}/280
</span>
</div>
{/* Footer */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '11px', color: '#525252' }}>
@@ -361,8 +225,8 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
onClick={handleSubmit}
disabled={!input.trim() || isPosting || isSoftLimited}
style={{
width: '36px',
height: '36px',
width: '38px',
height: '38px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
+338 -41
View File
@@ -68,6 +68,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState<NodeSearchResult[]>([]);
const [selectedSearchIndex, setSelectedSearchIndex] = useState(0);
const [edgeExplanation, setEdgeExplanation] = useState('');
const [pendingEdgeTarget, setPendingEdgeTarget] = useState<{ id: number; title: string } | null>(null);
const [deletingEdge, setDeletingEdge] = useState<number | null>(null);
const [edgeEditingId, setEdgeEditingId] = useState<number | null>(null);
const [edgeEditingValue, setEdgeEditingValue] = useState<string>('');
@@ -296,7 +297,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
from_node_id: nodeId,
to_node_id: toId,
source: 'user',
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
explanation: 'Referenced via @ mention'
})
});
}));
@@ -345,7 +346,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
from_node_id: nodeId,
to_node_id: toId,
source: 'user',
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
explanation: 'Referenced via @ mention'
})
});
}));
@@ -540,7 +541,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
from_node_id: sourceNodeId,
to_node_id: nodeId,
source: 'user',
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
explanation: 'Referenced via @ mention'
})
});
} catch (e) {
@@ -630,7 +631,8 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Edge management functions (following same patterns as node functions)
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
createEdgeWithExplanation(targetNodeId, '');
setPendingEdgeTarget({ id: targetNodeId, title: _targetNodeTitle || `Node ${targetNodeId}` });
setEdgeExplanation('');
};
// Handle node search keyboard navigation
@@ -648,13 +650,18 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
};
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
createEdgeWithExplanation(suggestion.id, '');
setNodeSearchQuery('');
setPendingEdgeTarget({ id: suggestion.id, title: suggestion.title });
setEdgeExplanation('');
setNodeSearchSuggestions([]);
};
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
if (activeNodeId === null) return;
const trimmed = (explanation || '').trim();
if (!trimmed) {
alert('Please add a short explanation for why this connection exists.');
return;
}
try {
const response = await fetch('/api/edges', {
method: 'POST',
@@ -665,10 +672,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
from_node_id: activeNodeId,
to_node_id: targetNodeId,
source: 'user',
context: {
explanation: explanation,
created_via: 'focus_panel'
}
explanation: trimmed
}),
});
@@ -682,6 +686,10 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Reset state
setAddingEdge(null);
setEdgeExplanation('');
setPendingEdgeTarget(null);
setNodeSearchQuery('');
setNodeSearchSuggestions([]);
setShowConnectionsModal(false);
} catch (error) {
console.error('Error creating edge:', error);
@@ -807,14 +815,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
key={suggestion.id}
onClick={() => handleSelectNodeSuggestion(suggestion)}
style={{
padding: '14px 16px',
padding: '10px 14px',
cursor: 'pointer',
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
transition: 'background 100ms ease',
display: 'flex',
alignItems: 'center',
gap: '12px'
gap: '10px'
}}
onMouseEnter={(e) => {
if (index !== selectedSearchIndex) {
@@ -831,13 +839,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontSize: '9px',
fontWeight: 600,
color: '#0a0a0a',
background: '#22c55e',
padding: '4px 8px',
padding: '3px 6px',
borderRadius: '6px',
minWidth: '28px',
minWidth: '24px',
textAlign: 'center',
flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace"
@@ -845,7 +853,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
{suggestion.id}
</span>
<span style={{
fontSize: '15px',
fontSize: '13px',
color: '#e5e5e5',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -861,6 +869,123 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
))}
</div>
)}
{/* Explanation (required) */}
{pendingEdgeTarget && (
<div style={{
marginTop: '10px',
background: '#0f0f0f',
border: '1px solid #262626',
borderRadius: '12px',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 500 }}>
Connecting to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{[
{ label: 'Made by', value: 'Created by ' },
{ label: 'Part of', value: 'Part of ' },
{ label: 'Came from', value: 'Came from ' },
{ label: 'Related', value: 'Related to ' },
].map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => {
setEdgeExplanation((prev) => {
const trimmed = (prev || '').trim();
return trimmed.length > 0 ? prev : chip.value;
});
}}
style={{
padding: '6px 10px',
fontSize: '12px',
borderRadius: '999px',
border: '1px solid #262626',
background: '#141414',
color: '#e5e5e5',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#141414'; }}
title={`Prefill: ${chip.value.trim()}`}
>
{chip.label}
</button>
))}
</div>
<textarea
value={edgeExplanation}
onChange={(e) => setEdgeExplanation(e.target.value)}
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
rows={2}
style={{
width: '100%',
resize: 'vertical',
background: '#141414',
border: '1px solid #1f1f1f',
color: '#fafafa',
borderRadius: '10px',
padding: '10px',
fontSize: '13px',
outline: 'none',
fontFamily: 'inherit',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
}
if (e.key === 'Escape') {
e.preventDefault();
setPendingEdgeTarget(null);
setEdgeExplanation('');
}
}}
/>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
onClick={() => {
setPendingEdgeTarget(null);
setEdgeExplanation('');
}}
style={{
padding: '8px 10px',
background: 'transparent',
border: '1px solid #262626',
color: '#a3a3a3',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
<button
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
style={{
padding: '8px 10px',
background: '#22c55e',
border: '1px solid #16a34a',
color: '#0a0a0a',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 600
}}
>
Create connection
</button>
</div>
<div style={{ color: '#737373', fontSize: '11px' }}>
Tip: press <span style={{ fontFamily: 'monospace' }}></span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
</div>
</div>
)}
</div>
{/* Existing Connections */}
@@ -877,11 +1002,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}
const visible = edgesExpanded[activeTab] ? list : list.slice(0, 5);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{visible.map((connection) => (
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{
display: 'inline-block',
fontSize: '10px',
@@ -896,7 +1021,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}}>
{connection.connected_node.id}
</span>
<span style={{ color: '#f8fafc', fontSize: '14px', fontWeight: 500 }}>{connection.connected_node.title}</span>
<span style={{ color: '#f8fafc', fontSize: '13px', fontWeight: 500 }}>{connection.connected_node.title}</span>
</div>
{edgeEditingId === connection.edge.id ? (
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
@@ -958,8 +1083,33 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{typeof connection.edge.context?.type === 'string' && (
<span style={{
fontSize: '10px',
color: '#a3a3a3',
background: '#1f1f1f',
border: '1px solid #262626',
padding: '2px 6px',
borderRadius: '999px',
textTransform: 'none'
}}>
{String(connection.edge.context.type).replace(/_/g, ' ')}
</span>
)}
{connection.edge.context?.explanation ? (
<span style={{ color: '#94a3b8', fontSize: '12px' }}> {connection.edge.context.explanation}</span>
<span
style={{
color: '#94a3b8',
fontSize: '12px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
}}
title={String(connection.edge.context.explanation)}
>
{connection.edge.context.explanation}
</span>
) : (
<span style={{ color: '#64748b', fontSize: '12px', fontStyle: 'italic' }}>No explanation</span>
)}
@@ -986,7 +1136,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
disabled={deletingEdge === connection.edge.id}
style={{
color: deletingEdge === connection.edge.id ? '#64748b' : '#94a3b8',
fontSize: '14px',
fontSize: '12px',
background: 'transparent',
border: 'none',
cursor: deletingEdge === connection.edge.id ? 'not-allowed' : 'pointer',
@@ -2127,14 +2277,14 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setNodeSearchSuggestions([]);
}}
style={{
padding: '14px 20px',
padding: '10px 16px',
cursor: 'pointer',
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
transition: 'background 100ms ease',
display: 'flex',
alignItems: 'center',
gap: '14px'
gap: '10px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a';
@@ -2149,13 +2299,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontSize: '9px',
fontWeight: 600,
color: '#0a0a0a',
background: '#22c55e',
padding: '4px 8px',
padding: '3px 6px',
borderRadius: '6px',
minWidth: '28px',
minWidth: '24px',
textAlign: 'center',
flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace"
@@ -2163,7 +2313,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
{suggestion.id}
</span>
<span style={{
fontSize: '14px',
fontSize: '13px',
color: '#e5e5e5',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -2196,6 +2346,126 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
)}
{/* Explanation prompt (required) */}
{pendingEdgeTarget && (
<div style={{
marginTop: '10px',
background: '#141414',
border: '1px solid #262626',
borderRadius: '16px',
padding: '16px 18px',
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 600 }}>
Create connection to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{[
{ label: 'Made by', value: 'Created by ' },
{ label: 'Part of', value: 'Part of ' },
{ label: 'Came from', value: 'Came from ' },
{ label: 'Related', value: 'Related to ' },
].map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => {
setEdgeExplanation((prev) => {
const trimmed = (prev || '').trim();
return trimmed.length > 0 ? prev : chip.value;
});
}}
style={{
padding: '6px 10px',
fontSize: '12px',
borderRadius: '999px',
border: '1px solid #262626',
background: '#0f0f0f',
color: '#e5e5e5',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#0f0f0f'; }}
title={`Prefill: ${chip.value.trim()}`}
>
{chip.label}
</button>
))}
</div>
<textarea
value={edgeExplanation}
onChange={(e) => setEdgeExplanation(e.target.value)}
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
rows={2}
style={{
width: '100%',
resize: 'vertical',
background: '#0f0f0f',
border: '1px solid #333',
color: '#fafafa',
borderRadius: '12px',
padding: '10px 12px',
fontSize: '13px',
outline: 'none',
fontFamily: 'inherit'
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
}
if (e.key === 'Escape') {
e.preventDefault();
setPendingEdgeTarget(null);
setEdgeExplanation('');
}
}}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => {
setPendingEdgeTarget(null);
setEdgeExplanation('');
}}
style={{
padding: '8px 12px',
background: '#262626',
border: 'none',
borderRadius: '10px',
color: '#a3a3a3',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
style={{
padding: '8px 12px',
background: '#22c55e',
border: '1px solid #16a34a',
borderRadius: '10px',
color: '#0a0a0a',
fontSize: '12px',
fontWeight: 700,
cursor: 'pointer'
}}
>
Create
</button>
</div>
<div style={{ color: '#737373', fontSize: '11px' }}>
Tip: press <span style={{ fontFamily: 'monospace' }}></span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
</div>
</div>
)}
{/* Existing Connections */}
{!nodeSearchQuery && (
<div style={{
@@ -2211,7 +2481,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
minHeight: 0
}}>
<div style={{
padding: '16px 20px',
padding: '12px 16px',
borderBottom: '1px solid #1f1f1f',
fontSize: '12px',
color: '#737373',
@@ -2236,15 +2506,15 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<div
key={connection.id}
style={{
padding: '14px 20px',
padding: '10px 16px',
borderBottom: '1px solid #1f1f1f',
display: 'flex',
flexDirection: 'column',
gap: '8px'
gap: '6px'
}}
>
{/* Connection header row */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{
display: 'inline-flex',
alignItems: 'center',
@@ -2253,9 +2523,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
fontWeight: 600,
color: '#0a0a0a',
background: '#22c55e',
padding: '4px 8px',
padding: '3px 6px',
borderRadius: '6px',
minWidth: '28px',
minWidth: '24px',
textAlign: 'center',
flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace"
@@ -2266,7 +2536,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
onClick={() => onNodeClick?.(connection.connected_node.id)}
style={{
flex: 1,
fontSize: '14px',
fontSize: '13px',
color: '#e5e5e5',
cursor: 'pointer',
overflow: 'hidden',
@@ -2302,7 +2572,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
{/* Description row */}
{edgeEditingId === connection.edge.id ? (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<input
value={edgeEditingValue}
onChange={(e) => setEdgeEditingValue(e.target.value)}
@@ -2319,12 +2589,12 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
placeholder="Add explanation..."
style={{
flex: 1,
fontSize: '12px',
fontSize: '11px',
color: '#e5e5e5',
background: '#0a0a0a',
border: '1px solid #333',
borderRadius: '6px',
padding: '8px 10px',
padding: '6px 8px',
outline: 'none',
fontFamily: 'inherit'
}}
@@ -2332,7 +2602,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<button
onClick={() => saveEdgeExplanation(connection.edge.id, connection.edge.context)}
style={{
padding: '6px 12px',
padding: '5px 10px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
@@ -2347,7 +2617,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<button
onClick={cancelEditEdgeExplanation}
style={{
padding: '6px 12px',
padding: '5px 10px',
background: '#262626',
border: 'none',
borderRadius: '6px',
@@ -2368,12 +2638,39 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
color: connection.edge.context?.explanation ? '#888' : '#525252',
cursor: 'pointer',
padding: '4px 0',
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic'
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#aaa'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = connection.edge.context?.explanation ? '#888' : '#525252'; }}
>
{typeof connection.edge.context?.type === 'string' && (
<span style={{
fontSize: '10px',
color: '#a3a3a3',
background: '#0f0f0f',
border: '1px solid #262626',
padding: '2px 6px',
borderRadius: '999px',
flexShrink: 0
}}>
{String(connection.edge.context.type).replace(/_/g, ' ')}
</span>
)}
<span
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={String(connection.edge.context?.explanation || '')}
>
{(connection.edge.context?.explanation as string) || 'Click to add explanation...'}
</span>
</div>
)}
</div>
+5 -1
View File
@@ -19,7 +19,11 @@ WORKFLOW STEPS
Call createEdge for each:
- from_node_id: focused node ID
- to_node_id: related node ID
- context: { explanation: "brief reason" }
- explanation: "brief reason for this connection"
Direction rule: write the explanation so it reads FROM TO.
Examples:
- Episode Podcast: "Episode of this podcast"
- Book Author: "Written by"
4. DONE
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
+5 -1
View File
@@ -30,7 +30,11 @@ WORKFLOW STEPS
For each connection found, call createEdge:
- from_node_id: the focused node ID
- to_node_id: the connected node ID
- context: { explanation: "why this connection matters" }
- explanation: "why this connection matters"
Direction rule: write the explanation so it reads FROM TO.
Examples:
- Insight Source: "Came from / inspired by"
- Episode Podcast: "Episode of this podcast"
The tool handles duplicates gracefully - if edge exists, it returns an error and you continue.
+245
View File
@@ -0,0 +1,245 @@
/**
* Auto-Edge Creation Service
*
* After Quick Capture creates a node, this service:
* 1. Extracts candidate entity strings from the node's description
* 2. Looks up existing entity nodes by exact title match
* 3. Creates edges with explanations for matches
*
* This is a "fast path" for obvious connections only - conservative by design.
*/
import { nodeService, edgeService } from '@/services/database';
import { Node } from '@/types/database';
// Entity-like dimensions where we expect to find referenceable entities
const ENTITY_DIMENSIONS = ['people', 'companies', 'organizations', 'books', 'papers', 'articles', 'podcasts', 'creators'];
/**
* Clean up a candidate entity string by removing common prefixes/suffixes.
*/
function cleanEntityCandidate(candidate: string): string {
let cleaned = candidate.trim();
// Remove common author/attribution prefixes
const prefixPatterns = [
/^by\s+/i,
/^author:\s*/i,
/^written by\s+/i,
/^from\s+/i,
/^via\s+/i,
/^featuring\s+/i,
/^with\s+/i,
/^hosted by\s+/i,
];
for (const pattern of prefixPatterns) {
cleaned = cleaned.replace(pattern, '');
}
return cleaned.trim();
}
/**
* Extract candidate entity strings from text using conservative heuristics.
* Returns proper names, quoted titles, and recognized patterns.
*/
function extractCandidateEntities(text: string): string[] {
if (!text || typeof text !== 'string') return [];
const candidates: Set<string> = new Set();
// Pattern 1: "By [Name]" pattern - common in article descriptions
// Matches: "By Simon Willison", "by Sam Altman"
const byPattern = /\b[Bb]y\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b/g;
let match;
while ((match = byPattern.exec(text)) !== null) {
const name = match[1].trim();
if (name.length >= 4 && !isGenericPhrase(name)) {
candidates.add(name);
}
}
// Pattern 2: Proper name sequences (2-4 capitalized words)
// Matches: "Sam Altman", "Dario Amodei", "Peter Thiel"
const properNamePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g;
while ((match = properNamePattern.exec(text)) !== null) {
const name = match[1].trim();
// Clean the candidate (remove "By ", etc.)
const cleaned = cleanEntityCandidate(name);
if (cleaned.length >= 4 && !isGenericPhrase(cleaned)) {
candidates.add(cleaned);
}
}
// Pattern 3: Quoted titles (single or double quotes)
// Matches: "Zero to One", 'The Lean Startup'
const quotedPattern = /["']([^"']{3,60})["']/g;
while ((match = quotedPattern.exec(text)) !== null) {
const title = match[1].trim();
if (title.length >= 3 && !isGenericPhrase(title)) {
candidates.add(title);
}
}
// Pattern 4: Known organization patterns
// Matches: OpenAI, DeepMind, Y Combinator, Fly.io
const orgPattern = /\b(OpenAI|DeepMind|Anthropic|Google|Microsoft|Meta|Apple|Amazon|Y Combinator|YC|Stripe|Coinbase|Fly\.io|Vercel|Cloudflare)\b/gi;
while ((match = orgPattern.exec(text)) !== null) {
candidates.add(match[1]);
}
return Array.from(candidates);
}
/**
* Check if a phrase is too generic to be a useful entity reference.
*/
function isGenericPhrase(phrase: string): boolean {
const normalized = phrase.toLowerCase();
// Common stopwords and generic terms
const genericTerms = [
'the author', 'the article', 'the book', 'the podcast',
'this article', 'this book', 'this podcast', 'this paper',
'new research', 'recent study', 'key points', 'main ideas',
'artificial intelligence', 'machine learning', 'deep learning',
'first section', 'last section', 'next chapter',
'united states', 'new york', 'san francisco', 'silicon valley'
];
return genericTerms.some(term => normalized === term || normalized.startsWith(term + ' '));
}
/**
* Look up existing nodes that match candidate entity strings.
* Uses exact title matching (case-insensitive) with optional dimension filtering.
*/
async function findMatchingEntityNodes(candidates: string[]): Promise<Map<string, Node>> {
const matches = new Map<string, Node>();
if (candidates.length === 0) return matches;
// Get all nodes (we'll filter in memory for exact title matches)
// In a larger system, we'd use a more efficient query
const allNodes = await nodeService.getNodes({ limit: 10000 });
for (const candidate of candidates) {
const normalizedCandidate = candidate.toLowerCase().trim();
// Find exact title match (case-insensitive)
const matchingNode = allNodes.find(node => {
const normalizedTitle = (node.title || '').toLowerCase().trim();
if (normalizedTitle !== normalizedCandidate) return false;
// Optionally filter to entity-like dimensions for higher confidence
// But don't require it - some entities might not have dimensions yet
const nodeDimensions = node.dimensions || [];
const hasEntityDimension = nodeDimensions.some(dim =>
ENTITY_DIMENSIONS.includes(dim.toLowerCase())
);
// Accept if it has an entity dimension OR if it's a short title (likely a named entity)
return hasEntityDimension || node.title.length < 50;
});
if (matchingNode) {
matches.set(candidate, matchingNode);
}
}
return matches;
}
/**
* Create edges from a new node to matched entity nodes.
* Each edge includes an explanation for auditability.
*/
async function createAutoEdges(
newNodeId: number,
matches: Map<string, Node>
): Promise<number> {
let edgesCreated = 0;
for (const [candidateText, entityNode] of matches) {
// Skip self-references
if (entityNode.id === newNodeId) continue;
// Check if edge already exists
const exists = await edgeService.edgeExists(newNodeId, entityNode.id);
if (exists) continue;
try {
await edgeService.createEdge({
from_node_id: newNodeId,
to_node_id: entityNode.id,
explanation: `Explicitly mentioned in description: "${candidateText}"`,
created_via: 'quick_capture_auto',
source: 'ai_similarity',
skip_inference: false, // Let Idea Genealogy classify the relationship
});
edgesCreated++;
console.log(`[autoEdge] Created edge: ${newNodeId}${entityNode.id} (${entityNode.title})`);
} catch (error) {
console.warn(`[autoEdge] Failed to create edge to ${entityNode.id}:`, error);
}
}
return edgesCreated;
}
/**
* Main entry point: Run auto-edge creation for a newly created node.
* This is designed to be called fire-and-forget (non-blocking).
*/
export async function runAutoEdgeCreation(nodeId: number): Promise<void> {
try {
// Fetch the newly created node
const node = await nodeService.getNodeById(nodeId);
if (!node) {
console.warn(`[autoEdge] Node ${nodeId} not found, skipping auto-edge creation`);
return;
}
// Use description as the source of truth for entity extraction
const description = node.description || '';
if (!description || description.length < 10) {
console.log(`[autoEdge] Node ${nodeId} has no/short description, skipping`);
return;
}
// Extract candidate entities from description
const candidates = extractCandidateEntities(description);
if (candidates.length === 0) {
console.log(`[autoEdge] No entity candidates found in node ${nodeId} description`);
return;
}
console.log(`[autoEdge] Found ${candidates.length} candidates for node ${nodeId}:`, candidates);
// Find matching existing nodes
const matches = await findMatchingEntityNodes(candidates);
if (matches.size === 0) {
console.log(`[autoEdge] No matching entity nodes found for node ${nodeId}`);
return;
}
// Create edges
const edgesCreated = await createAutoEdges(nodeId, matches);
console.log(`[autoEdge] Created ${edgesCreated} auto-edges for node ${nodeId}`);
} catch (error) {
console.error(`[autoEdge] Error in auto-edge creation for node ${nodeId}:`, error);
}
}
/**
* Schedule auto-edge creation to run asynchronously (fire-and-forget).
* Use this from the nodes API to avoid blocking the response.
*/
export function scheduleAutoEdgeCreation(nodeId: number): void {
setImmediate(() => {
runAutoEdgeCreation(nodeId).catch(error => {
console.error(`[autoEdge] Scheduled auto-edge creation failed for node ${nodeId}:`, error);
});
});
}
+45 -1
View File
@@ -48,6 +48,34 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
}
function buildDescriptionPrompt(input: DescriptionInput): string {
const normalizedSource = (input.metadata?.source || '').toLowerCase();
const url = typeof input.link === 'string' ? input.link.trim() : '';
// Best-effort creator hint from structured metadata (when available),
// but never assume a particular extraction source (YouTube vs paper vs website vs note).
const creatorHint =
input.metadata?.author?.trim() ||
input.metadata?.channel_name?.trim() ||
'';
// Best-effort publisher / container hint (less ideal than a true author, but better than nothing).
const publisherHint = input.metadata?.site_name?.trim() || '';
const likelyExternal =
Boolean(url) ||
normalizedSource.includes('youtube') ||
normalizedSource.includes('extract') ||
normalizedSource.includes('paper') ||
normalizedSource.includes('pdf') ||
normalizedSource.includes('website');
const likelyUserAuthored =
!likelyExternal &&
(normalizedSource.includes('quick-add-note') ||
normalizedSource.includes('quick-add-chat') ||
normalizedSource.includes('note') ||
normalizedSource.length === 0);
const lines: string[] = [`Title: ${input.title}`];
if (input.link) lines.push(`URL: ${input.link}`);
@@ -55,11 +83,27 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
if (input.metadata?.channel_name) lines.push(`Channel: ${input.metadata.channel_name}`);
if (input.metadata?.author) lines.push(`Author: ${input.metadata.author}`);
if (input.metadata?.site_name) lines.push(`Site: ${input.metadata.site_name}`);
if (creatorHint) lines.push(`Creator hint: ${creatorHint}`);
if (publisherHint) lines.push(`Publisher hint: ${publisherHint}`);
lines.push(`Likely user-authored: ${likelyUserAuthored ? 'yes' : 'no'}`);
const contentPreview = input.content?.slice(0, 800) || '';
if (contentPreview) lines.push(`Content: ${contentPreview}${input.content && input.content.length > 800 ? '...' : ''}`);
return `Your job is to do your best to answer 'what is this' - the most simple, high level contextual information of what this thing is. Users will be adding a variety of different nodes (ideas, books, podcasts, people, papers etc). Do your best to take the available information and infer what it is - high level. If unsure, that's fine just give your best guess and say you're unsure. Max 280 characters.
return `Your job is to answer: "what is this?" in one short line. Max 280 characters.
GOAL: Include "who created it" when possible.
RULES (in priority order):
1) ONLY use a creator if you have a creator hint (Author/Channel) or the content explicitly says "by <X>" / "hosted by <X>".
- Do NOT treat prominent people in the title/transcript (e.g. a guest) as the creator.
- If a creator hint is provided, prefer it.
2) If you can identify a creator/author/channel/person/org from a creator hint, start with: "By <creator> — ..."
3) If it's likely user-authored, start with: "Your <thing> — ..." (don't invent a creator name).
4) If creator is unknown, do NOT guess; omit the byline.
Then, in the remainder, state what it is (video/paper/article/note/idea/etc) + what it's about (high-signal).
If unsure, say so briefly.
${lines.join('\n')}`;
}
+201 -7
View File
@@ -1,6 +1,120 @@
import { getSQLiteClient } from './sqlite-client';
import { Edge, EdgeData, NodeConnection, Node } from '@/types/database';
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events';
import { nodeService } from './nodes';
import { apiKeyService } from '../storage/apiKeys';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
const inferredEdgeContextSchema = z.object({
category: z.enum(['attribution', 'intellectual']),
type: z.enum([
'created_by',
'features',
'part_of',
'source_of',
'extends',
'supports',
'contradicts',
'related_to'
]),
confidence: z.number().min(0).max(1),
});
async function inferEdgeContext(params: {
explanation: string;
fromNode: Node;
toNode: Node;
}): Promise<Pick<EdgeContext, 'category' | 'type' | 'confidence'>> {
const { explanation, fromNode, toNode } = params;
// Heuristic fast-paths for the 4 core UI chips.
// This makes classification robust and reduces reliance on the model.
const norm = explanation.trim().toLowerCase();
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
return { category: 'attribution', type: 'created_by', confidence: 1.0 };
}
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
return { category: 'attribution', type: 'part_of', confidence: 1.0 };
}
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
return { category: 'attribution', type: 'features', confidence: 0.95 };
}
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
return { category: 'intellectual', type: 'source_of', confidence: 0.9 };
}
if (startsWithAny(['related to', 'related'])) {
return { category: 'intellectual', type: 'related_to', confidence: 0.8 };
}
// If no API key is configured, degrade gracefully.
// We still enforce explanation, but fall back to "related_to" classification.
const apiKey = apiKeyService.getOpenAiKey();
if (!apiKey) {
return { category: 'intellectual', type: 'related_to', confidence: 0.0 };
}
const provider = createOpenAI({ apiKey });
const prompt = [
`Given this edge explanation: "${explanation}"`,
``,
`From node:`,
`- Title: "${fromNode.title}"`,
`- Description: ${fromNode.description || 'No description available'}`,
`- Dimensions: ${fromNode.dimensions?.join(', ') || 'none'}`,
``,
`To node:`,
`- Title: "${toNode.title}"`,
`- Description: ${toNode.description || 'No description available'}`,
`- Dimensions: ${toNode.dimensions?.join(', ') || 'none'}`,
``,
`Classify the relationship:`,
`- category: "attribution" (factual: author, creator, host, guest) or "intellectual" (idea relationship)`,
`- type: one of [created_by, features, part_of, source_of, extends, supports, contradicts, related_to]`,
`- confidence: 0-1`,
``,
`IMPORTANT: Interpret the direction as "FROM node → TO node". Pick a type that reads correctly in that direction:`,
`- created_by: FROM was created/founded/authored by TO`,
`- features: FROM features TO (host/guest/subject appearing in FROM)`,
`- part_of: FROM is part of TO (episode→podcast, chapter→book, note→project)`,
`- source_of: FROM came from TO / was inspired by TO`,
`- extends/supports/contradicts: FROM extends/supports/contradicts TO`,
``,
`Return JSON only: {"category": "...", "type": "...", "confidence": 0.X}`
].join('\n');
try {
const { text } = await generateText({
model: provider('gpt-4o-mini'),
prompt,
temperature: 0.0,
maxOutputTokens: 120,
});
const parsedJson = (() => {
try {
return JSON.parse(text);
} catch {
// Sometimes models wrap JSON in prose; try to recover.
const match = text.match(/\{[\s\S]*\}/);
if (match) return JSON.parse(match[0]);
throw new Error('AI did not return valid JSON');
}
})();
const parsed = inferredEdgeContextSchema.safeParse(parsedJson);
if (!parsed.success) {
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
}
return parsed.data;
} catch (error) {
console.warn('[edges] inferEdgeContext failed; falling back to related_to', error);
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
}
}
export class EdgeService {
async getEdges(): Promise<Edge[]> {
@@ -25,13 +139,40 @@ export class EdgeService {
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
const explanation = (edgeData.explanation || '').trim();
if (!explanation) {
throw new Error('Edge explanation is required');
}
const createdVia: EdgeCreatedVia = edgeData.created_via;
// Fetch nodes for inference context
const [fromNode, toNode] = await Promise.all([
nodeService.getNodeById(edgeData.from_node_id),
nodeService.getNodeById(edgeData.to_node_id),
]);
if (!fromNode) throw new Error(`Source node ${edgeData.from_node_id} not found`);
if (!toNode) throw new Error(`Target node ${edgeData.to_node_id} not found`);
const inferred = edgeData.skip_inference
? { category: 'intellectual' as const, type: 'related_to' as const, confidence: 0.0 }
: await inferEdgeContext({ explanation, fromNode, toNode });
const context: EdgeContext = {
...inferred,
inferred_at: now,
explanation,
created_via: createdVia,
};
const result = sqlite.prepare(`
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(
edgeData.from_node_id,
edgeData.to_node_id,
JSON.stringify(edgeData.context || {}),
JSON.stringify(context),
edgeData.source,
now
);
@@ -67,6 +208,51 @@ export class EdgeService {
const updateFields: string[] = [];
const params: any[] = [];
// If explanation changes, re-infer classification and write full EdgeContext
if (Object.prototype.hasOwnProperty.call(updates, 'context') && updates.context && typeof updates.context === 'object') {
const incomingContext = updates.context as Partial<EdgeContext> & { explanation?: unknown };
if (typeof incomingContext.explanation === 'string') {
const explanation = incomingContext.explanation.trim();
if (!explanation) {
throw new Error('Edge explanation is required');
}
const existingEdge = await this.getEdgeById(id);
if (!existingEdge) {
throw new Error(`Edge with ID ${id} not found`);
}
const [fromNode, toNode] = await Promise.all([
nodeService.getNodeById(existingEdge.from_node_id),
nodeService.getNodeById(existingEdge.to_node_id),
]);
if (!fromNode) throw new Error(`Source node ${existingEdge.from_node_id} not found`);
if (!toNode) throw new Error(`Target node ${existingEdge.to_node_id} not found`);
const inferred = await inferEdgeContext({ explanation, fromNode, toNode });
const now = new Date().toISOString();
const existingContext = (existingEdge.context && typeof existingEdge.context === 'object')
? (existingEdge.context as Partial<EdgeContext>)
: undefined;
const created_via: EdgeCreatedVia =
(incomingContext.created_via as EdgeCreatedVia) ||
(existingContext?.created_via as EdgeCreatedVia) ||
'ui';
updates.context = {
...existingContext,
...incomingContext,
...inferred,
inferred_at: now,
explanation,
created_via,
} satisfies EdgeContext;
}
}
// Build dynamic update query
Object.entries(updates).forEach(([key, value]) => {
if (key !== 'id' && key !== 'created_at' && value !== undefined) {
@@ -309,17 +495,23 @@ export class EdgeService {
}
async createBidirectionalEdge(fromId: number, toId: number, options?: {
context?: any;
explanation?: string;
created_via?: EdgeCreatedVia;
source?: 'user' | 'ai_similarity' | 'helper_name';
skip_inference?: boolean;
}): Promise<Edge[]> {
const edges: Edge[] = [];
const explanation = (options?.explanation || 'Similarity-based connection').trim();
const created_via: EdgeCreatedVia = options?.created_via || 'workflow';
// Create edge from A to B
const forwardEdge = await this.createEdge({
from_node_id: fromId,
to_node_id: toId,
context: options?.context,
source: options?.source || 'ai_similarity'
explanation,
created_via,
source: options?.source || 'ai_similarity',
skip_inference: options?.skip_inference,
});
edges.push(forwardEdge);
@@ -327,8 +519,10 @@ export class EdgeService {
const backwardEdge = await this.createEdge({
from_node_id: toId,
to_node_id: fromId,
context: options?.context,
source: options?.source || 'ai_similarity'
explanation,
created_via,
source: options?.source || 'ai_similarity',
skip_inference: options?.skip_inference,
});
edges.push(backwardEdge);
+6 -1
View File
@@ -111,13 +111,18 @@ function parseMetadata(metadata: unknown): Record<string, any> {
if (!metadata) return {};
if (typeof metadata === 'string') {
try {
return JSON.parse(metadata);
const parsed = JSON.parse(metadata);
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, any>;
}
return {};
} catch (error) {
console.warn('Failed to parse node metadata JSON:', error);
return {};
}
}
if (typeof metadata === 'object') {
if (metadata === null) return {};
return metadata as Record<string, any>;
}
return {};
+75 -21
View File
@@ -27,6 +27,46 @@ export class WebsiteExtractor {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
};
/**
* Extract content using Jina.ai Reader API
* Used as fallback when cheerio fails (JS-rendered sites, Twitter, SPAs)
* Free tier: 20 requests/minute per IP (no API key needed)
*/
private async extractWithJina(url: string): Promise<ExtractionResult> {
const jinaUrl = `https://r.jina.ai/${url}`;
const response = await fetch(jinaUrl, {
headers: { 'User-Agent': 'RA-H/1.0' },
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`Jina extraction failed: ${response.status}`);
}
const markdown = await response.text();
// Parse title from first markdown heading or first line
const titleMatch = markdown.match(/^#\s+(.+)$/m) || markdown.match(/^(.+)$/m);
const title = titleMatch?.[1]?.trim() || 'Extracted Content';
// Build metadata
const metadata: WebsiteMetadata = {
title,
extraction_method: 'jina',
};
// Format content consistently with cheerio output
const content = this.formatContent(metadata, markdown);
return {
content,
chunk: markdown,
metadata,
url,
};
}
/**
* Clean extracted content for better readability
*/
@@ -190,19 +230,13 @@ export class WebsiteExtractor {
}
/**
* Main extraction method
* Extract content using cheerio (static HTML parser)
* Fast, free, no external calls - but fails on JS-rendered sites
*/
async extract(url: string): Promise<ExtractionResult> {
// Validate URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error('Invalid URL format - must start with http:// or https://');
}
try {
// Fetch the webpage
private async extractWithCheerio(url: string): Promise<ExtractionResult> {
const response = await fetch(url, {
headers: this.headers,
signal: AbortSignal.timeout(30000), // 30 second timeout
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
@@ -210,34 +244,54 @@ export class WebsiteExtractor {
}
const html = await response.text();
// Parse HTML with cheerio
const $ = cheerio.load(html);
// Extract metadata and content
const metadata = this.extractMetadata($);
// Mark extraction method for downstream metadata
metadata.extraction_method = 'typescript_cheerio';
metadata.extraction_method = 'cheerio';
const mainContent = this.extractMainContent($);
// Format content for display
const content = this.formatContent(metadata, mainContent);
// Chunk is the main content text
const chunk = mainContent;
return {
content,
chunk,
chunk: mainContent,
metadata,
url,
};
}
/**
* Main extraction method
* Strategy: Try cheerio first (fast, free), fall back to Jina for JS-rendered sites
*/
async extract(url: string): Promise<ExtractionResult> {
// Validate URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw new Error('Invalid URL format - must start with http:// or https://');
}
// Try cheerio first (fast, no external call)
try {
const result = await this.extractWithCheerio(url);
// If content is substantial, cheerio worked
if (result.chunk.length > 200) {
return result;
}
console.log('[WebsiteExtractor] Cheerio extraction too short, trying Jina...');
} catch (error: any) {
if (error.name === 'AbortError') {
throw new Error('Request timeout - website took too long to respond');
}
throw error;
console.log('[WebsiteExtractor] Cheerio failed, trying Jina...', error.message);
}
// Fallback to Jina for JS-rendered sites
try {
return await this.extractWithJina(url);
} catch (jinaError: any) {
throw new Error(`Extraction failed: ${jinaError.message}`);
}
}
}
+34 -4
View File
@@ -5,12 +5,32 @@ import { nodeService } from '@/services/database/nodes';
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
export const createEdgeTool = tool({
description: 'Create directed relationship between nodes',
description:
'Create directed relationship between nodes.\n\n' +
'Direction rule: FROM node → TO node should read correctly.\n' +
'Prefer the 4 core relations unless the user clearly wants an advanced intellectual relation:\n' +
'- Made by → created_by (attribution)\n' +
'- Part of → part_of (attribution)\n' +
'- Came from → source_of (intellectual)\n' +
'- Related → related_to (intellectual fallback)\n\n' +
'Examples:\n' +
'- Episode → Podcast: "Episode of this podcast"\n' +
'- Book → Author: "Written by"\n' +
'- Company → Founder: "Founded by"\n' +
'- Insight → Source: "Came from / inspired by"\n',
inputSchema: z.object({
from_node_id: z.number().describe('The ID of the source node (where the connection originates)'),
to_node_id: z.number().describe('The ID of the target node (where the connection points to)'),
context: z.record(z.any()).optional().describe('Additional context about this connection - can include explanation, relationship type, strength, notes, etc.'),
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe('Source of this edge - use "ai" for AI-created connections, "user" for manual connections, "ai_similarity" for similarity-based connections')
explanation: z.string().describe(
'REQUIRED: Why does this connection exist? Be specific. ' +
'Write it as a relationship that reads FROM → TO. ' +
'Examples: "Author of this book", "Guest on this podcast", ' +
'"Episode of this podcast", "This insight came from this podcast episode", "Extends the concept introduced here"'
),
source: z.enum(['user', 'ai', 'ai_similarity', 'helper_name']).default('ai').describe(
'Source of this edge. Use "ai" (or "helper_name") for AI-created connections, ' +
'"user" for manual connections, "ai_similarity" for similarity-based connections.'
)
}),
execute: async (params) => {
console.log('🔗 CreateEdge tool called with params:', JSON.stringify(params, null, 2));
@@ -41,6 +61,15 @@ export const createEdgeTool = tool({
};
}
const explanation = (params.explanation || '').trim();
if (!explanation) {
return {
success: false,
error: 'explanation is required. Provide a clear reason for why these two nodes should be connected.',
data: null
};
}
const [fromNode, toNode] = await Promise.all([
nodeService.getNodeById(params.from_node_id),
nodeService.getNodeById(params.to_node_id)
@@ -84,7 +113,8 @@ export const createEdgeTool = tool({
const newEdge = await edgeService.createEdge({
from_node_id: params.from_node_id,
to_node_id: params.to_node_id,
context: params.context || {},
explanation,
created_via: 'agent',
source
});
+5 -4
View File
@@ -15,14 +15,15 @@ Description: "${description}"
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
"reasoning": "Brief explanation of why you chose these categories"
}
Guidelines:
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- Make description informative and contextual
- For academic papers, include tags like: research, academic, paper, plus domain-specific tags
- For AI/ML papers, include: ai, machine-learning, artificial-intelligence, deep-learning
- For economics papers, include: economics, finance, markets, policy
@@ -32,7 +33,7 @@ Guidelines:
const response = await generateText({
model: openai('gpt-5-mini'),
prompt,
maxOutputTokens: 500
maxOutputTokens: 800
});
let content = response.text || '{}';
@@ -122,7 +123,7 @@ export const paperExtractTool = tool({
// Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
result.content?.substring(0, 1000) || 'PDF document content',
result.content?.substring(0, 2000) || 'PDF document content',
'pdf'
);
+5 -4
View File
@@ -15,14 +15,15 @@ Description: "${description}"
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
"reasoning": "Brief explanation of why you chose these categories"
}
Guidelines:
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- Make description informative and contextual
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
@@ -31,7 +32,7 @@ Guidelines:
const response = await generateText({
model: openai('gpt-5-mini'),
prompt,
maxOutputTokens: 500
maxOutputTokens: 800
});
let content = response.text || '{}';
@@ -113,7 +114,7 @@ export const websiteExtractTool = tool({
// Step 2: AI Analysis for enhanced metadata
const aiAnalysis = await analyzeContentWithAI(
result.metadata?.title || `Website: ${new URL(url).hostname}`,
result.content?.substring(0, 500) || 'Website content',
result.content?.substring(0, 2000) || 'Website content',
'website'
);
+4 -3
View File
@@ -22,7 +22,7 @@ Available types: ${AVAILABLE_SEGMENTS.join(', ')}
Respond with ONLY valid JSON (no markdown, no code blocks):
{
"enhancedDescription": "Improved 2-3 sentence description explaining what this content is about",
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"],
"segments": ["research"],
"reasoning": "Brief explanation of why you chose these categories"
@@ -30,8 +30,9 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
Guidelines:
- Choose 1 segment that best fits the content type
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- Make description informative and contextual
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
@@ -40,7 +41,7 @@ Guidelines:
const response = await generateText({
model: openai('gpt-5-mini'),
prompt,
maxOutputTokens: 500
maxOutputTokens: 800
});
let content = response.text || '{}';
+35 -3
View File
@@ -58,10 +58,40 @@ export interface Edge {
from_node_id: number;
to_node_id: number;
context?: any;
source: 'user' | 'ai_similarity' | 'helper_name';
source: EdgeSource;
created_at: string;
}
export type EdgeSource = 'user' | 'ai_similarity' | 'helper_name';
export type EdgeContextCategory = 'attribution' | 'intellectual';
export type EdgeContextType =
| 'created_by'
| 'features'
| 'part_of'
| 'source_of'
| 'extends'
| 'supports'
| 'contradicts'
| 'related_to';
export type EdgeCreatedVia = 'ui' | 'agent' | 'mcp' | 'workflow' | 'quicklink' | 'quick_capture_auto';
export interface EdgeContext {
// SYSTEM-INFERRED (AI classifies from explanation + nodes)
category: EdgeContextCategory;
type: EdgeContextType;
confidence: number; // 0-1
inferred_at: string; // ISO timestamp
// PROVIDED AT CREATION / EDIT
explanation: string;
// SYSTEM-MANAGED
created_via: EdgeCreatedVia;
}
export interface Chat {
id: number;
user_message?: string;
@@ -120,8 +150,10 @@ export interface ChunkData {
export interface EdgeData {
from_node_id: number;
to_node_id: number;
context?: any;
source: 'user' | 'ai_similarity' | 'helper_name';
explanation: string;
created_via: EdgeCreatedVia;
source: EdgeSource;
skip_inference?: boolean; // reserved for bulk imports / migrations
}
export interface ChatData {