feat: port contexts layer and MCP parity
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import {
|
||||
LayoutList,
|
||||
Map,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Table2,
|
||||
BookOpen,
|
||||
Settings,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { PaneType, NavigablePaneType } from '../panes/types';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
|
||||
interface LeftToolbarProps {
|
||||
onSearchClick: () => void;
|
||||
@@ -31,6 +34,8 @@ interface LeftToolbarProps {
|
||||
activeTabType: PaneType | null;
|
||||
theme: Theme;
|
||||
onThemeToggle: () => void;
|
||||
contexts?: ContextSummary[];
|
||||
onContextQuickSelect?: (contextId: number, contextName: string) => void;
|
||||
}
|
||||
|
||||
const NAV_WIDTH_COLLAPSED = 50;
|
||||
@@ -113,7 +118,11 @@ export default function LeftToolbar({
|
||||
activeTabType,
|
||||
theme,
|
||||
onThemeToggle,
|
||||
contexts = [],
|
||||
onContextQuickSelect,
|
||||
}: LeftToolbarProps) {
|
||||
const [contextsExpanded, setContextsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -148,6 +157,77 @@ export default function LeftToolbar({
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
|
||||
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '6px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<NavButton
|
||||
icon={Folder}
|
||||
label="Contexts"
|
||||
expanded={isExpanded}
|
||||
active={activeTabType === 'contexts' || openTabTypes.has('contexts')}
|
||||
onClick={() => onPaneTypeClick('contexts')}
|
||||
activeTone="green"
|
||||
/>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextsExpanded((prev) => !prev)}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: contextsExpanded ? 'var(--rah-bg-hover)' : 'transparent',
|
||||
color: contextsExpanded ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Toggle context list"
|
||||
>
|
||||
{contextsExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isExpanded && contextsExpanded && contexts.length > 0 ? (
|
||||
<div style={{ marginLeft: '14px', paddingLeft: '12px', borderLeft: '1px solid var(--rah-border)', display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
type="button"
|
||||
onClick={() => onContextQuickSelect?.(context.id, context.name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
minHeight: '28px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
padding: '0 8px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{context.name}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)', flexShrink: 0 }}>
|
||||
{context.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{VIEW_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={`${item.paneType}-${item.label}`}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { PanelLeftOpen, GripVertical, X } from 'lucide-react';
|
||||
import SettingsModal, { SettingsTab } from '../settings/SettingsModal';
|
||||
import SearchModal from '../nodes/SearchModal';
|
||||
import { Node } from '@/types/database';
|
||||
import { ContextSummary, Node } from '@/types/database';
|
||||
import { DatabaseEvent } from '@/services/events';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
@@ -12,7 +12,7 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import LeftToolbar from './LeftToolbar';
|
||||
import SplitHandle from './SplitHandle';
|
||||
|
||||
import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane, SkillsPane } from '../panes';
|
||||
import { NodePane, ContextsPane, DimensionsPane, MapPane, ViewsPane, TablePane, SkillsPane } from '../panes';
|
||||
import QuickAddInput from '../agents/QuickAddInput';
|
||||
import type { PaneType, SlotState, PaneAction, SlotId } from '../panes/types';
|
||||
|
||||
@@ -35,10 +35,11 @@ const PANEL_A_WEIGHT_KEY = 'ui.panelA.weight.v1';
|
||||
const PANEL_B_WEIGHT_KEY = 'ui.panelB.weight.v1';
|
||||
const PANEL_C_WEIGHT_KEY = 'ui.panelC.weight.v1';
|
||||
const LEFT_NAV_EXPANDED_KEY = 'ui.leftNavExpanded';
|
||||
const ACTIVE_CONTEXT_KEY = 'ui.focus.activeContextId';
|
||||
const ACTIVE_DIMENSION_KEY = 'ui.focus.activeDimension';
|
||||
|
||||
const DEFAULT_SLOT_A: SlotState = { type: 'views' };
|
||||
const VALID_PANE_TYPES = new Set<PaneType>(['node', 'dimensions', 'map', 'views', 'table', 'skills']);
|
||||
const VALID_PANE_TYPES = new Set<PaneType>(['node', 'contexts', 'dimensions', 'map', 'views', 'table', 'skills']);
|
||||
|
||||
function normalizeSlotState(raw: SlotState | null): SlotState | null {
|
||||
if (!raw) return null;
|
||||
@@ -84,7 +85,14 @@ export default function ThreePanelLayout() {
|
||||
const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0);
|
||||
const [focusPanelRefresh, setFocusPanelRefresh] = useState(0);
|
||||
const [folderViewRefresh, setFolderViewRefresh] = useState(0);
|
||||
const [availableContexts, setAvailableContexts] = useState<ContextSummary[]>([]);
|
||||
const [activeContextId, setActiveContextId] = usePersistentState<number | null>(ACTIVE_CONTEXT_KEY, null);
|
||||
const [activeDimension, setActiveDimension] = usePersistentState<string | null>(ACTIVE_DIMENSION_KEY, null);
|
||||
const [browseContextFilters, setBrowseContextFilters] = useState<Record<SlotId, number | null>>({
|
||||
A: null,
|
||||
B: null,
|
||||
C: null,
|
||||
});
|
||||
const [browseDimensionFilters, setBrowseDimensionFilters] = useState<Record<SlotId, string | null>>({
|
||||
A: null,
|
||||
B: null,
|
||||
@@ -104,6 +112,20 @@ export default function ThreePanelLayout() {
|
||||
setSlotC((prev) => normalizeSlotState(prev));
|
||||
}, [setSlotA, setSlotB, setSlotC]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/contexts');
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
setAvailableContexts(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contexts:', error);
|
||||
}
|
||||
})();
|
||||
}, [nodesPanelRefresh, folderViewRefresh]);
|
||||
|
||||
const getSlotState = useCallback((slot: SlotId): SlotState | null => {
|
||||
switch (slot) {
|
||||
case 'A':
|
||||
@@ -549,6 +571,17 @@ export default function ThreePanelLayout() {
|
||||
openNodeFromSlot(nodeId, 'A');
|
||||
}, [openNodeFromSlot]);
|
||||
|
||||
const handleContextSelect = useCallback((slot: SlotId, contextId: number | null, _contextName?: string | null) => {
|
||||
setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId }));
|
||||
setActiveContextId(contextId);
|
||||
|
||||
if (contextId == null) return;
|
||||
|
||||
setPanelExpanded(slot, true);
|
||||
getSlotSetter(slot)({ type: 'views' });
|
||||
setActivePane(slot);
|
||||
}, [getSlotSetter, setActiveContextId, setPanelExpanded]);
|
||||
|
||||
const handleDimensionPaneSelect = useCallback((slot: SlotId, dimensionName: string | null) => {
|
||||
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: dimensionName }));
|
||||
setActiveDimension(dimensionName);
|
||||
@@ -565,7 +598,7 @@ export default function ThreePanelLayout() {
|
||||
const response = await fetch('/api/quick-add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, mode, description }),
|
||||
body: JSON.stringify({ input, mode, description, contextId: activeContextId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -591,7 +624,7 @@ export default function ThreePanelLayout() {
|
||||
} catch (error) {
|
||||
console.error('[ThreePanelLayout] Quick Add error:', error);
|
||||
}
|
||||
}, [openPaneSingleton]);
|
||||
}, [activeContextId, openPaneSingleton]);
|
||||
|
||||
const handleCloseSlotA = useCallback(() => {
|
||||
setSlotA(null);
|
||||
@@ -619,11 +652,20 @@ export default function ThreePanelLayout() {
|
||||
setActivePane(slot);
|
||||
}
|
||||
break;
|
||||
case 'open-context':
|
||||
handleContextSelect(action.targetSlot ?? slot, action.contextId, action.contextName);
|
||||
break;
|
||||
case 'open-dimension':
|
||||
setBrowseDimensionFilters((prev) => ({ ...prev, [action.targetSlot ?? slot]: action.dimension }));
|
||||
setActiveDimension(action.dimension);
|
||||
setSingletonPaneInSlot(action.targetSlot ?? slot, 'views');
|
||||
setActivePane(action.targetSlot ?? slot);
|
||||
break;
|
||||
case 'open-node':
|
||||
openNodeFromSlot(action.nodeId, slot);
|
||||
break;
|
||||
}
|
||||
}, [openNodeFromSlot, setSingletonPaneInSlot]);
|
||||
}, [handleContextSelect, openNodeFromSlot, setActiveDimension, setSingletonPaneInSlot]);
|
||||
|
||||
const handleSearchNodeSelect = useCallback((nodeId: number) => {
|
||||
handleNodeSelect(nodeId, false);
|
||||
@@ -758,6 +800,18 @@ export default function ThreePanelLayout() {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'contexts':
|
||||
return (
|
||||
<ContextsPane
|
||||
slot={slot}
|
||||
isActive={isActive}
|
||||
onPaneAction={(action) => handleSlotAction(slot, action)}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={handleSwapPanes}
|
||||
onContextSelect={(contextId, contextName) => handleContextSelect(slot, contextId, contextName)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'dimensions':
|
||||
return (
|
||||
<DimensionsPane
|
||||
@@ -802,7 +856,16 @@ export default function ThreePanelLayout() {
|
||||
refreshToken={nodesPanelRefresh}
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))}
|
||||
externalContextFilterId={browseContextFilters[slot]}
|
||||
externalDimensionFilter={browseDimensionFilters[slot]}
|
||||
onContextFilterSelect={(contextId) => {
|
||||
setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId }));
|
||||
setActiveContextId(contextId);
|
||||
}}
|
||||
onClearExternalContextFilter={() => {
|
||||
setBrowseContextFilters((prev) => ({ ...prev, [slot]: null }));
|
||||
setActiveContextId(null);
|
||||
}}
|
||||
onClearExternalDimensionFilter={() => {
|
||||
setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: null }));
|
||||
setActiveDimension(null);
|
||||
@@ -985,6 +1048,15 @@ export default function ThreePanelLayout() {
|
||||
onRefreshClick={handleRefreshAll}
|
||||
theme={theme}
|
||||
onThemeToggle={toggleTheme}
|
||||
contexts={availableContexts}
|
||||
onContextQuickSelect={(contextId, contextName) => {
|
||||
const target = openPaneSingleton('views', 'A');
|
||||
setBrowseContextFilters((prev) => ({ ...prev, [target]: contextId }));
|
||||
setBrowseDimensionFilters((prev) => ({ ...prev, [target]: null }));
|
||||
setActiveContextId(contextId);
|
||||
setActivePane(target);
|
||||
void contextName;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Folder } from 'lucide-react';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { ContextsPaneProps } from './types';
|
||||
|
||||
export default function ContextsPane({
|
||||
slot,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
onContextSelect,
|
||||
}: ContextsPaneProps) {
|
||||
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/contexts');
|
||||
const payload = await response.json();
|
||||
if (response.ok && payload.success) {
|
||||
setContexts(payload.data || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', overflow: 'hidden' }}>
|
||||
<PaneHeader
|
||||
slot={slot}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={onSwapPanes}
|
||||
tabBar={tabBar}
|
||||
toolbarHostRef={setToolbarHost}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
|
||||
<div style={{ maxWidth: '980px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{loading ? (
|
||||
<div style={emptyStateStyle}>Loading contexts...</div>
|
||||
) : contexts.length === 0 ? (
|
||||
<div style={emptyStateStyle}>No contexts yet.</div>
|
||||
) : (
|
||||
contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
type="button"
|
||||
onClick={() => onContextSelect?.(context.id, context.name)}
|
||||
style={rowStyle}
|
||||
>
|
||||
<div style={iconWrapStyle}>
|
||||
<Folder size={17} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--rah-text-primary)' }}>{context.name}</span>
|
||||
<span style={countStyle}>{context.count}</span>
|
||||
</div>
|
||||
{context.description ? (
|
||||
<div style={{ marginTop: '4px', fontSize: '12px', color: 'var(--rah-text-secondary)', lineHeight: 1.45 }}>
|
||||
{context.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 14px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--rah-bg-card)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const iconWrapStyle: React.CSSProperties = {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
color: 'var(--rah-text-muted)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const countStyle: React.CSSProperties = {
|
||||
fontSize: '10px',
|
||||
lineHeight: 1.2,
|
||||
padding: '3px 7px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
color: 'var(--rah-text-muted)',
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '13px',
|
||||
background: 'var(--rah-bg-card)',
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export default function NodePane({
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
openTabs,
|
||||
activeTab,
|
||||
onTabSelect,
|
||||
@@ -92,76 +93,76 @@ export default function NodePane({
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
|
||||
{/* Tabs rendered inline */}
|
||||
{openTabs.length === 0 ? (
|
||||
<span style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>No tabs open</span>
|
||||
) : (
|
||||
openTabs.map((tabId) => {
|
||||
const title = nodeTitles[tabId] || 'Loading...';
|
||||
const isActiveTab = activeTab === tabId;
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: isActiveTab ? 'var(--rah-bg-active)' : 'transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: 'grab',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onTabSelect(tabId)}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: isActiveTab ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar ? tabBar : (
|
||||
/* Legacy node tabs (fallback when no tabBar prop) */
|
||||
openTabs.length === 0 ? (
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>No tabs open</span>
|
||||
) : (
|
||||
openTabs.map((tabId) => {
|
||||
const title = nodeTitles[tabId] || 'Loading...';
|
||||
const isActiveTab = activeTab === tabId;
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
|
||||
}}
|
||||
>
|
||||
{truncateTitle(title)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tabId);
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
|
||||
}}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: isActiveTab ? '#1f1f1f' : 'transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: 'grab',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-active)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</PaneHeader>
|
||||
<button
|
||||
onClick={() => onTabSelect(tabId)}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: isActiveTab ? '#fff' : '#888',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{truncateTitle(title)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tabId);
|
||||
}}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
)} />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<FocusPanel
|
||||
@@ -171,11 +172,8 @@ export default function NodePane({
|
||||
onNodeClick={onNodeClick}
|
||||
onTabClose={onTabClose}
|
||||
refreshTrigger={refreshTrigger}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onOpenInOtherSlot={onOpenInOtherSlot}
|
||||
onTextSelect={onTextSelect}
|
||||
highlightedPassage={highlightedPassage}
|
||||
hideTabBar
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -186,8 +184,8 @@ export default function NodePane({
|
||||
position: 'fixed',
|
||||
top: contextMenu.y,
|
||||
left: contextMenu.x,
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
padding: '4px',
|
||||
zIndex: 9999,
|
||||
@@ -211,18 +209,18 @@ export default function NodePane({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
||||
e.currentTarget.style.color = 'var(--rah-text-active)';
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>↗</span>
|
||||
@@ -243,18 +241,18 @@ export default function NodePane({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
||||
e.currentTarget.style.color = 'var(--rah-text-active)';
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>×</span>
|
||||
|
||||
@@ -12,7 +12,10 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
@@ -28,7 +31,10 @@ export default function ViewsPane({
|
||||
refreshToken,
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId,
|
||||
externalDimensionFilter,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
onClearExternalDimensionFilter,
|
||||
}: ViewsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
@@ -58,7 +64,10 @@ export default function ViewsPane({
|
||||
refreshToken={refreshToken}
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={onDismissPending}
|
||||
externalContextFilterId={externalContextFilterId}
|
||||
externalDimensionFilter={externalDimensionFilter}
|
||||
onContextFilterSelect={onContextFilterSelect}
|
||||
onClearExternalContextFilter={onClearExternalContextFilter}
|
||||
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as NodePane } from './NodePane';
|
||||
export { default as ContextsPane } from './ContextsPane';
|
||||
export { default as DimensionsPane } from './DimensionsPane';
|
||||
export { default as MapPane } from './MapPane';
|
||||
export { default as ViewsPane } from './ViewsPane';
|
||||
|
||||
@@ -18,7 +18,7 @@ export type AgentDelegation = {
|
||||
};
|
||||
|
||||
// Pane types (chat removed in rah-light)
|
||||
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
|
||||
export type PaneType = 'node' | 'contexts' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
|
||||
|
||||
// State for each slot
|
||||
export interface SlotState {
|
||||
@@ -34,6 +34,7 @@ export interface SlotState {
|
||||
// Actions panes can emit to the layout
|
||||
export type PaneAction =
|
||||
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
|
||||
| { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId }
|
||||
| { type: 'open-dimension'; dimension: string; targetSlot?: SlotId }
|
||||
| { type: 'switch-pane-type'; paneType: PaneType }
|
||||
| { type: 'close-pane' };
|
||||
@@ -90,10 +91,17 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
export interface ContextsPaneProps extends BasePaneProps {
|
||||
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
}
|
||||
|
||||
// TablePane specific props
|
||||
export interface TablePaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
@@ -113,6 +121,7 @@ export interface PaneHeaderProps {
|
||||
// Labels for pane types
|
||||
export const PANE_LABELS: Record<PaneType, string> = {
|
||||
node: 'Nodes',
|
||||
contexts: 'Contexts',
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function ContextViewer() {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>
|
||||
Top 10 most-connected nodes are added to background context for tool execution.
|
||||
Context summaries and anchor nodes are used first. Global hub nodes remain secondary diagnostics in background context.
|
||||
</p>
|
||||
|
||||
{/* Toggle */}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
|
||||
interface GridViewProps {
|
||||
nodes: Node[];
|
||||
@@ -43,7 +44,11 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: '12px'
|
||||
}}>
|
||||
{nodes.map(node => (
|
||||
{nodes.map(node => {
|
||||
const processedState = getNodeProcessedState(node.metadata);
|
||||
const isProcessed = processedState === 'processed';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => onNodeClick(node.id)}
|
||||
@@ -51,13 +56,14 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '16px',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1a1a1a',
|
||||
background: isProcessed ? 'rgba(34, 58, 42, 0.45)' : '#0a0a0a',
|
||||
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.28)' : '#1a1a1a'}`,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s',
|
||||
minHeight: '140px'
|
||||
minHeight: '140px',
|
||||
opacity: isProcessed ? 0.84 : 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#111';
|
||||
@@ -103,6 +109,15 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginBottom: '10px',
|
||||
fontSize: '10px',
|
||||
color: isProcessed ? '#86efac' : '#888',
|
||||
textTransform: 'lowercase'
|
||||
}}>
|
||||
{processedState}
|
||||
</div>
|
||||
|
||||
{/* Description or Content Preview */}
|
||||
{(node.description || node.source) && (
|
||||
<div style={{
|
||||
@@ -154,7 +169,8 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
|
||||
interface ListViewProps {
|
||||
nodes: Node[];
|
||||
@@ -48,7 +49,11 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
overflowY: 'auto',
|
||||
padding: '8px'
|
||||
}}>
|
||||
{nodes.map(node => (
|
||||
{nodes.map(node => {
|
||||
const processedState = getNodeProcessedState(node.metadata);
|
||||
const isProcessed = processedState === 'processed';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => onNodeClick(node.id)}
|
||||
@@ -59,13 +64,14 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
marginBottom: '4px',
|
||||
background: 'var(--rah-bg-base)',
|
||||
background: isProcessed ? 'var(--rah-bg-subtle, var(--rah-bg-base))' : 'var(--rah-bg-base)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderLeft: '2px solid var(--rah-border-stronger)',
|
||||
borderLeft: `2px solid ${isProcessed ? 'var(--rah-accent-green, #4ade80)' : 'var(--rah-border-stronger)'}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s ease'
|
||||
transition: 'all 0.15s ease',
|
||||
opacity: isProcessed ? 0.82 : 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-surface)';
|
||||
@@ -132,6 +138,16 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
background: isProcessed ? 'rgba(74, 222, 128, 0.10)' : 'var(--rah-bg-active)',
|
||||
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.35)' : 'var(--rah-border-strong)'}`,
|
||||
borderRadius: '999px',
|
||||
fontSize: '11px',
|
||||
color: isProcessed ? 'var(--rah-accent-green, #4ade80)' : 'var(--rah-text-muted)'
|
||||
}}>
|
||||
{processedState}
|
||||
</span>
|
||||
{/* Dimensions */}
|
||||
{node.dimensions && node.dimensions.length > 0 && (
|
||||
<div style={{
|
||||
@@ -186,7 +202,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
---
|
||||
name: Audit
|
||||
description: "Run a structured audit of graph quality, skill quality, and operational consistency."
|
||||
when_to_use: "User asks for review, QA, cleanup, or governance checks."
|
||||
when_not_to_use: "Simple one-off write/read requests."
|
||||
success_criteria: "Findings are prioritized, concrete, and tied to actionable fixes."
|
||||
description: "Use for structured review, QA, cleanup, or governance checks across graph quality, skill quality, and operational consistency."
|
||||
---
|
||||
|
||||
# Audit
|
||||
@@ -27,3 +24,5 @@ success_criteria: "Findings are prioritized, concrete, and tied to actionable fi
|
||||
- Prefer specific evidence over generic commentary.
|
||||
- Propose the smallest high-leverage fixes first.
|
||||
- Separate defects from optional polish.
|
||||
- Node descriptions must read like natural prose while still making what / why / status clear.
|
||||
- Flag any node description missing a clear why or status component as a high-priority quality issue.
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Calibration
|
||||
description: "Run periodic check-ins to recalibrate goals, projects, preferences, and graph structure."
|
||||
when_to_use: "User asks for a check-in, reset, review, or strategic recalibration."
|
||||
when_not_to_use: "Single isolated question with no strategic update needed."
|
||||
success_criteria: "Graph reflects current reality: updated hubs, changed priorities, and explicit deltas."
|
||||
success_criteria: "Graph reflects current reality: updated contexts, changed priorities, and explicit deltas."
|
||||
---
|
||||
|
||||
# Calibration
|
||||
@@ -14,7 +14,7 @@ Re-anchor the graph to the user's current state.
|
||||
|
||||
## Check-in Sequence
|
||||
|
||||
1. Review major hub nodes and active project nodes.
|
||||
1. Review major contexts, anchor nodes, and active project nodes.
|
||||
2. Ask what changed: goals, priorities, constraints, beliefs, preferences.
|
||||
3. Identify stale nodes and missing nodes.
|
||||
4. Propose precise updates: update existing vs create new.
|
||||
|
||||
@@ -8,7 +8,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
## Core Rules
|
||||
|
||||
1. Search before create to avoid duplicates.
|
||||
2. Every create/update must include an explicit description of WHAT the thing is and WHY it matters.
|
||||
2. Every create/update should aim for a natural description that makes clear what the thing is, why it belongs in the graph, and workflow status.
|
||||
3. Use event dates when known (when it happened, not when saved).
|
||||
4. Apply dimensions deliberately; prefer existing dimensions over creating noisy new ones.
|
||||
5. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
||||
@@ -16,9 +16,10 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
## Write Quality Contract
|
||||
|
||||
- `title`: clear and specific.
|
||||
- `description`: concrete object-level description, not vague summaries.
|
||||
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
|
||||
- `description`: natural prose, not labels. It should still make what / why / status clear when possible.
|
||||
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search. For user-authored ideas or dictated notes, preserve the user's original wording with minimal cleanup.
|
||||
- `link`: external source URL only.
|
||||
- `metadata`: prefer canonical keys `type`, `state`, `captured_method`, `captured_by`, and `source_metadata`.
|
||||
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
||||
|
||||
## Execution Pattern
|
||||
@@ -27,9 +28,11 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
2. Decide: create vs update vs connect.
|
||||
3. Execute minimum required writes.
|
||||
4. Verify result reflects user intent exactly.
|
||||
5. If description framing was materially inferred, complete the write first and then invite one concise user feedback pass instead of blocking creation.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Create duplicate nodes when an update is correct.
|
||||
- Write vague descriptions ("discusses", "explores", "is about").
|
||||
- Replace a user's raw idea/source with a thin summary.
|
||||
- Create weak or directionless edges.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Node Context Enrichment
|
||||
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with dimension review and edge suggestions."
|
||||
---
|
||||
|
||||
# Node Context Enrichment
|
||||
|
||||
Use this when a node already exists but its description is thin, generic, or missing personal context.
|
||||
|
||||
This skill should not silently rewrite and move on when contextual framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine the contextual framing.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace weak descriptions with a single clean natural description that captures:
|
||||
|
||||
1. What the artifact literally is
|
||||
2. Why it is in Brad's graph
|
||||
3. Status in Brad's workflow
|
||||
|
||||
Also review whether the node needs dimension fixes or obvious edge suggestions.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Load the node and inspect title, description, source, link, metadata, dimensions, and nearby edges.
|
||||
2. Search for adjacent context before rewriting.
|
||||
3. Infer the best available "why" from that context.
|
||||
4. Rewrite the full description from scratch in natural prose. Do not append to the old text or use labels like WHAT:, WHY:, or STATUS:.
|
||||
5. Review dimensions.
|
||||
6. Suggest 1-3 high-signal edges when obvious.
|
||||
7. Update the node once the description is strong enough to be useful.
|
||||
8. After the update, tell the user what changed and ask whether they want to refine the important framing:
|
||||
- what it is
|
||||
- why it belongs in the graph
|
||||
- status / current relevance / workflow position
|
||||
|
||||
The user feedback pass is required whenever the enriched "why" or status was inferred rather than directly stated in the node/source.
|
||||
@@ -3,7 +3,7 @@ name: Onboarding
|
||||
description: "Onboard a new user by mapping goals, projects, worldview, and preferences into a strong initial graph."
|
||||
when_to_use: "New user setup or major reset of account context."
|
||||
when_not_to_use: "User asks for a narrow tactical operation only."
|
||||
success_criteria: "User has an initial hub-node structure and clear next steps for graph growth."
|
||||
success_criteria: "User has an initial context structure, anchor candidates, and clear next steps for graph growth."
|
||||
---
|
||||
|
||||
# Onboarding
|
||||
@@ -22,13 +22,14 @@ Understand the user deeply enough to bootstrap a useful externalized context gra
|
||||
|
||||
## Graph Bootstrap
|
||||
|
||||
1. Propose 3-6 hub nodes with clear rationale.
|
||||
2. Propose starter dimensions that reflect real domains.
|
||||
3. Create initial edges between hubs and active projects.
|
||||
4. Confirm with user before writing.
|
||||
1. Propose 3-6 primary contexts with clear rationale.
|
||||
2. Identify one strong anchor candidate per context.
|
||||
3. Propose starter dimensions as secondary metadata and filters.
|
||||
4. Create initial edges between anchor nodes and active project nodes.
|
||||
5. Confirm with user before writing.
|
||||
|
||||
## Output
|
||||
|
||||
- Initial hub map
|
||||
- Initial context map
|
||||
- Suggested first write actions
|
||||
- Suggested weekly maintenance rhythm
|
||||
|
||||
@@ -5,6 +5,7 @@ import { paperExtractTool } from '@/tools/other/paperExtract';
|
||||
import { formatNodeForChat } from '@/tools/infrastructure/nodeFormatter';
|
||||
import { summarizeTranscript } from './transcriptSummarizer';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
|
||||
export type QuickAddMode = 'link' | 'text';
|
||||
|
||||
@@ -242,7 +243,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
`Attempted pipeline: ${type}`,
|
||||
].join('\n');
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -251,11 +252,16 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
source,
|
||||
link: url,
|
||||
metadata: {
|
||||
source: 'quick-add-link-fallback',
|
||||
attempted_pipeline: type,
|
||||
extraction_failed: true,
|
||||
extraction_error: message,
|
||||
refined_at: new Date().toISOString(),
|
||||
type: 'website',
|
||||
state: 'not_processed',
|
||||
captured_method: 'quick_add_link_fallback',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
attempted_pipeline: type,
|
||||
extraction_failed: true,
|
||||
extraction_error: message,
|
||||
refined_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -294,8 +300,13 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
title,
|
||||
source: content,
|
||||
metadata: {
|
||||
source: 'quick-add-note',
|
||||
refined_at: new Date().toISOString(),
|
||||
type: 'note',
|
||||
state: 'not_processed',
|
||||
captured_method: 'quick_add_note',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
refined_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -303,7 +314,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
nodePayload.description = userDescription.trim();
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(nodePayload),
|
||||
@@ -356,48 +367,42 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
|
||||
? ['Where things felt stuck:', ...stickingPoints.map((item) => `- ${item}`)].join('\n')
|
||||
: null;
|
||||
|
||||
const contentParts = [
|
||||
`Overview:\n${baseSummary}`,
|
||||
];
|
||||
|
||||
if (intentLine) {
|
||||
contentParts.push(`What you were trying to do:\n${intentLine}`);
|
||||
}
|
||||
if (progressLine) {
|
||||
contentParts.push(`Where you made progress:\n${progressLine}`);
|
||||
}
|
||||
if (stickingSection) {
|
||||
contentParts.push(stickingSection);
|
||||
}
|
||||
if (highlightSection) contentParts.push(highlightSection);
|
||||
if (followUpSection) contentParts.push(followUpSection);
|
||||
const content = contentParts.join('\n\n');
|
||||
|
||||
const title = deriveChatTitle(transcript, summaryResult.subject);
|
||||
const wordCount = transcript.split(/\s+/).filter(Boolean).length;
|
||||
const compactSummary = baseSummary.replace(/\s+/g, ' ').trim();
|
||||
const whyDetail = intentLine
|
||||
? `It belongs in the graph because it preserves context around ${intentLine.toLowerCase()}.`
|
||||
: 'It belongs in the graph because it preserves context from this conversation.';
|
||||
const statusDetail = 'It has not been reviewed yet.';
|
||||
const nodeDescription = `${compactSummary} ${whyDetail} ${statusDetail}`.slice(0, 500);
|
||||
|
||||
const metadata = {
|
||||
source: 'quick-add-chat',
|
||||
summary_subject: summaryResult.subject,
|
||||
summary_intent: summaryResult.intent,
|
||||
summary_progress: summaryResult.progress,
|
||||
highlights: summaryResult.highlights ?? [],
|
||||
open_questions: summaryResult.openQuestions ?? [],
|
||||
participants: summaryResult.participants ?? [],
|
||||
sticking_points: summaryResult.stickingPoints ?? [],
|
||||
transcript_length_chars: transcript.length,
|
||||
transcript_length_words: wordCount,
|
||||
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
||||
summary_generated_at: new Date().toISOString(),
|
||||
type: 'chat',
|
||||
state: 'not_processed',
|
||||
captured_method: 'quick_add_chat',
|
||||
captured_by: 'human',
|
||||
source_metadata: {
|
||||
summary_subject: summaryResult.subject,
|
||||
summary_intent: summaryResult.intent,
|
||||
summary_progress: summaryResult.progress,
|
||||
highlights: summaryResult.highlights ?? [],
|
||||
open_questions: summaryResult.openQuestions ?? [],
|
||||
participants: summaryResult.participants ?? [],
|
||||
sticking_points: summaryResult.stickingPoints ?? [],
|
||||
transcript_length_chars: transcript.length,
|
||||
transcript_length_words: wordCount,
|
||||
transcript_truncated_for_summary: summaryResult.truncated ?? false,
|
||||
summary_generated_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
source: transcript,
|
||||
description: content,
|
||||
description: nodeDescription,
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,72 +1,233 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { getAutoContextSettings } from '@/services/settings/autoContextSettings';
|
||||
|
||||
interface AutoContextRow {
|
||||
id: number;
|
||||
title: string | null;
|
||||
updated_at: string;
|
||||
edge_count: number | null;
|
||||
}
|
||||
|
||||
export interface AutoContextSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
edgeCount: number;
|
||||
description: string;
|
||||
updatedAt: string;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
export interface ContextAnchorSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
updatedAt: string;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
export interface PromptContextSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
count: number;
|
||||
anchor: ContextAnchorSummary | null;
|
||||
}
|
||||
|
||||
function truncate(value: string | null | undefined, maxChars: number): string {
|
||||
const trimmed = (value || '').trim();
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars)}...`;
|
||||
}
|
||||
|
||||
function fetchAutoContextRows(limit: number): AutoContextSummary[] {
|
||||
const db = getSQLiteClient();
|
||||
const rows = db
|
||||
.query<AutoContextRow>(
|
||||
`
|
||||
SELECT n.id,
|
||||
n.title,
|
||||
n.updated_at,
|
||||
COUNT(DISTINCT e.id) AS edge_count
|
||||
FROM nodes n
|
||||
LEFT JOIN edges e
|
||||
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
WHERE 1=1
|
||||
GROUP BY n.id
|
||||
ORDER BY edge_count DESC, n.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[limit]
|
||||
)
|
||||
.rows;
|
||||
const rows = db.query<{
|
||||
id: number;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
updated_at: string;
|
||||
edge_count: number | null;
|
||||
}>(
|
||||
`
|
||||
SELECT n.id,
|
||||
n.title,
|
||||
n.description,
|
||||
n.updated_at,
|
||||
COUNT(DISTINCT e.id) AS edge_count
|
||||
FROM nodes n
|
||||
LEFT JOIN edges e
|
||||
ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
GROUP BY n.id
|
||||
ORDER BY edge_count DESC, n.updated_at DESC, n.id ASC
|
||||
LIMIT ?
|
||||
`,
|
||||
[limit]
|
||||
).rows;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title || 'Untitled node',
|
||||
description: row.description || '',
|
||||
updatedAt: row.updated_at,
|
||||
edgeCount: Number(row.edge_count ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
|
||||
const settings = getAutoContextSettings();
|
||||
if (!settings.autoContextEnabled) {
|
||||
return [];
|
||||
}
|
||||
export function getHubNodes(limit = 5): AutoContextSummary[] {
|
||||
return fetchAutoContextRows(limit);
|
||||
}
|
||||
|
||||
export function buildAutoContextBlock(limit = 10): string | null {
|
||||
const summaries = getAutoContextSummaries(limit);
|
||||
export function getContextSummaries(limit = 12): PromptContextSummary[] {
|
||||
const db = getSQLiteClient();
|
||||
const rows = db.query<{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
count: number;
|
||||
anchor_id: number | null;
|
||||
anchor_title: string | null;
|
||||
anchor_description: string | null;
|
||||
anchor_updated_at: string | null;
|
||||
anchor_edge_count: number | null;
|
||||
}>(`
|
||||
WITH context_counts AS (
|
||||
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) AS count
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
GROUP BY c.id
|
||||
),
|
||||
ranked_anchors AS (
|
||||
SELECT
|
||||
c.id AS context_id,
|
||||
n.id AS node_id,
|
||||
n.title,
|
||||
n.description,
|
||||
n.updated_at,
|
||||
COUNT(e.id) AS edge_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.id
|
||||
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
|
||||
) AS anchor_rank
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
GROUP BY c.id, n.id
|
||||
)
|
||||
SELECT
|
||||
cc.id,
|
||||
cc.name,
|
||||
cc.description,
|
||||
cc.icon,
|
||||
cc.count,
|
||||
ra.node_id AS anchor_id,
|
||||
ra.title AS anchor_title,
|
||||
ra.description AS anchor_description,
|
||||
ra.updated_at AS anchor_updated_at,
|
||||
ra.edge_count AS anchor_edge_count
|
||||
FROM context_counts cc
|
||||
LEFT JOIN ranked_anchors ra
|
||||
ON ra.context_id = cc.id
|
||||
AND ra.anchor_rank = 1
|
||||
ORDER BY cc.name COLLATE NOCASE ASC
|
||||
LIMIT ?
|
||||
`, [limit]).rows;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
icon: row.icon ?? null,
|
||||
count: Number(row.count ?? 0),
|
||||
anchor: row.anchor_id == null ? null : {
|
||||
id: Number(row.anchor_id),
|
||||
title: row.anchor_title || 'Untitled node',
|
||||
description: row.anchor_description || '',
|
||||
updatedAt: row.anchor_updated_at || '',
|
||||
edgeCount: Number(row.anchor_edge_count ?? 0),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildContextsBlock(limit = 12): string | null {
|
||||
const contexts = getContextSummaries(limit);
|
||||
if (contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'User Contexts',
|
||||
'Use contexts as the primary scope layer. Dimensions are secondary metadata and filters.',
|
||||
'',
|
||||
];
|
||||
|
||||
contexts.forEach((context, index) => {
|
||||
const description = truncate(context.description, 140) || 'No description.';
|
||||
const iconPrefix = context.icon ? `${context.icon} ` : '';
|
||||
lines.push(`${index + 1}. ${iconPrefix}${context.name} (${context.count} nodes)`);
|
||||
lines.push(` ${description}`);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildContextAnchorsBlock(limit = 12): string | null {
|
||||
const contexts = getContextSummaries(limit).filter((context) => context.anchor);
|
||||
if (contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'Context Anchors',
|
||||
'Each context anchor is the highest-edge node in that context. Use it as the starting graph waypoint for that scope.',
|
||||
'',
|
||||
];
|
||||
|
||||
contexts.forEach((context, index) => {
|
||||
const anchor = context.anchor!;
|
||||
lines.push(`${index + 1}. ${context.name}: [NODE:${anchor.id}:"${anchor.title}"] (${anchor.edgeCount} edges)`);
|
||||
if (anchor.description) {
|
||||
lines.push(` ${truncate(anchor.description, 160)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildHubNodesBlock(limit = 5): string | null {
|
||||
const summaries = getHubNodes(limit);
|
||||
if (summaries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'=== BACKGROUND CONTEXT ===',
|
||||
'Top 10 most-connected nodes (important knowledge hubs). Use queryNodes/getNodesById if relevant.',
|
||||
'Global Hub Diagnostics',
|
||||
'These are secondary graph diagnostics only. Do not use them as the primary scope layer when contexts are available.',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const summary of summaries) {
|
||||
lines.push(`[NODE:${summary.id}:"${summary.title}"] (edges: ${summary.edgeCount})`);
|
||||
}
|
||||
summaries.forEach((summary, i) => {
|
||||
lines.push(`${i + 1}. [NODE:${summary.id}:"${summary.title}"] (${summary.edgeCount} edges)`);
|
||||
if (summary.description) {
|
||||
lines.push(` ${truncate(summary.description, 140)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function getAutoContextSummaries(limit = 5): AutoContextSummary[] {
|
||||
const settings = getAutoContextSettings();
|
||||
if (!settings.autoContextEnabled) {
|
||||
return [];
|
||||
}
|
||||
return getHubNodes(limit);
|
||||
}
|
||||
|
||||
export function buildAutoContextBlock(limit = 12): string | null {
|
||||
const settings = getAutoContextSettings();
|
||||
if (!settings.autoContextEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sections = [
|
||||
buildContextsBlock(limit),
|
||||
buildContextAnchorsBlock(limit),
|
||||
buildHubNodesBlock(5),
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.length > 0 ? sections.join('\n\n') : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
type ContextCandidate = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
count: number;
|
||||
anchor_title: string | null;
|
||||
anchor_description: string | null;
|
||||
};
|
||||
|
||||
type InferContextInput = {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
source?: string | null;
|
||||
dimensions?: string[] | null;
|
||||
metadata?: unknown;
|
||||
};
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
|
||||
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
|
||||
'to', 'was', 'with', 'you', 'your'
|
||||
]);
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function tokenize(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return normalizeText(value)
|
||||
.split(' ')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
|
||||
}
|
||||
|
||||
function uniqueTokens(values: Array<string | null | undefined>): string[] {
|
||||
return Array.from(new Set(values.flatMap((value) => tokenize(value))));
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value ?? {});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function fetchContextCandidates(): ContextCandidate[] {
|
||||
const sqlite = getSQLiteClient();
|
||||
return sqlite.query<ContextCandidate>(`
|
||||
WITH context_counts AS (
|
||||
SELECT c.id, c.name, c.description, COUNT(n.id) AS count
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
GROUP BY c.id
|
||||
),
|
||||
ranked_anchors AS (
|
||||
SELECT
|
||||
c.id AS context_id,
|
||||
n.title AS anchor_title,
|
||||
n.description AS anchor_description,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.id
|
||||
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
|
||||
) AS anchor_rank
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
GROUP BY c.id, n.id
|
||||
)
|
||||
SELECT
|
||||
cc.id,
|
||||
cc.name,
|
||||
cc.description,
|
||||
cc.count,
|
||||
ra.anchor_title,
|
||||
ra.anchor_description
|
||||
FROM context_counts cc
|
||||
LEFT JOIN ranked_anchors ra
|
||||
ON ra.context_id = cc.id
|
||||
AND ra.anchor_rank = 1
|
||||
ORDER BY cc.name COLLATE NOCASE ASC
|
||||
`).rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
count: Number(row.count ?? 0),
|
||||
anchor_title: row.anchor_title ?? null,
|
||||
anchor_description: row.anchor_description ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function scoreContextCandidate(candidate: ContextCandidate, input: InferContextInput): number {
|
||||
const titleText = normalizeText(input.title || '');
|
||||
const descriptionText = normalizeText(input.description || '');
|
||||
const sourceText = normalizeText((input.source || '').slice(0, 4000));
|
||||
const metadataText = normalizeText(safeStringify(input.metadata));
|
||||
const dimensionTokens = uniqueTokens(input.dimensions ?? []);
|
||||
const contextName = normalizeText(candidate.name);
|
||||
const contextNameTokens = tokenize(candidate.name);
|
||||
const contextDescriptorTokens = uniqueTokens([
|
||||
candidate.description,
|
||||
candidate.anchor_title,
|
||||
candidate.anchor_description,
|
||||
]);
|
||||
|
||||
let score = 0;
|
||||
|
||||
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) {
|
||||
score += 80;
|
||||
}
|
||||
if (contextName && sourceText.includes(contextName)) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
for (const token of contextNameTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 30;
|
||||
if (titleText.includes(token)) score += 16;
|
||||
if (descriptionText.includes(token)) score += 12;
|
||||
if (sourceText.includes(token)) score += 6;
|
||||
if (metadataText.includes(token)) score += 4;
|
||||
}
|
||||
|
||||
for (const token of contextDescriptorTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 8;
|
||||
if (titleText.includes(token)) score += 4;
|
||||
if (descriptionText.includes(token)) score += 3;
|
||||
if (sourceText.includes(token)) score += 2;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export async function inferBestContextIdForNode(input: InferContextInput): Promise<number | null> {
|
||||
const contexts = fetchContextCandidates();
|
||||
if (contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ranked = contexts
|
||||
.map((context) => ({
|
||||
context,
|
||||
score: scoreContextCandidate(context, input),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
b.score - a.score ||
|
||||
(b.context.count - a.context.count) ||
|
||||
a.context.id - b.context.id
|
||||
);
|
||||
|
||||
const best = ranked[0];
|
||||
if (!best) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (best.score > 0) {
|
||||
return best.context.id;
|
||||
}
|
||||
|
||||
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
|
||||
if (research) {
|
||||
return research.id;
|
||||
}
|
||||
|
||||
return ranked[0].context.id;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import type { Context, ContextSummary, Node } from '@/types/database';
|
||||
import { nodeService } from './nodes';
|
||||
|
||||
type ContextRow = Context;
|
||||
|
||||
function normalizeContextName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function assertContextName(name: unknown): string {
|
||||
if (typeof name !== 'string') {
|
||||
throw new Error('Context name is required.');
|
||||
}
|
||||
const normalized = normalizeContextName(name);
|
||||
if (!normalized) {
|
||||
throw new Error('Context name is required.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertContextDescription(description: unknown): string {
|
||||
if (typeof description !== 'string') {
|
||||
throw new Error('Context description is required.');
|
||||
}
|
||||
const normalized = description.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('Context description is required.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapContextRow(row: ContextRow): Context {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
icon: row.icon ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export class ContextService {
|
||||
async listContexts(): Promise<ContextSummary[]> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const rows = sqlite.query<ContextSummary>(`
|
||||
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) as count
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name COLLATE NOCASE ASC
|
||||
`).rows;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
icon: row.icon ?? null,
|
||||
count: Number(row.count ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async getContextById(id: number): Promise<ContextSummary | null> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const row = sqlite.query<ContextSummary>(`
|
||||
SELECT c.id, c.name, c.description, c.icon, COUNT(n.id) as count
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
WHERE c.id = ?
|
||||
GROUP BY c.id
|
||||
`, [id]).rows[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
icon: row.icon ?? null,
|
||||
count: Number(row.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getContextByName(name: string): Promise<Context | null> {
|
||||
const normalized = assertContextName(name);
|
||||
const sqlite = getSQLiteClient();
|
||||
const row = sqlite.query<ContextRow>(`
|
||||
SELECT id, name, description, icon, created_at, updated_at
|
||||
FROM contexts
|
||||
WHERE LOWER(TRIM(name)) = LOWER(TRIM(?))
|
||||
LIMIT 1
|
||||
`, [normalized]).rows[0];
|
||||
|
||||
return row ? mapContextRow(row) : null;
|
||||
}
|
||||
|
||||
async createContext(input: { name: string; description: string; icon?: string | null }): Promise<Context> {
|
||||
const name = assertContextName(input.name);
|
||||
const description = assertContextDescription(input.description);
|
||||
const icon = typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null;
|
||||
const sqlite = getSQLiteClient();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existing = await this.getContextByName(name);
|
||||
if (existing) {
|
||||
throw new Error(`Context "${name}" already exists.`);
|
||||
}
|
||||
|
||||
const result = sqlite.prepare(`
|
||||
INSERT INTO contexts (name, description, icon, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(name, description, icon, now, now);
|
||||
|
||||
const created = await this.getContextById(Number(result.lastInsertRowid));
|
||||
if (!created) {
|
||||
throw new Error('Failed to create context.');
|
||||
}
|
||||
|
||||
return {
|
||||
...created,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
async updateContext(input: { id: number; name?: string; description?: string; icon?: string | null }): Promise<Context> {
|
||||
const sqlite = getSQLiteClient();
|
||||
const existing = sqlite.query<ContextRow>(`
|
||||
SELECT id, name, description, icon, created_at, updated_at
|
||||
FROM contexts
|
||||
WHERE id = ?
|
||||
`, [input.id]).rows[0];
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Context ${input.id} not found.`);
|
||||
}
|
||||
|
||||
const nextName = input.name !== undefined ? assertContextName(input.name) : existing.name;
|
||||
const nextDescription = input.description !== undefined
|
||||
? assertContextDescription(input.description)
|
||||
: existing.description;
|
||||
const nextIcon = input.icon !== undefined
|
||||
? (typeof input.icon === 'string' && input.icon.trim() ? input.icon.trim() : null)
|
||||
: existing.icon;
|
||||
|
||||
const conflicting = sqlite.query<{ id: number }>(`
|
||||
SELECT id FROM contexts
|
||||
WHERE LOWER(TRIM(name)) = LOWER(TRIM(?))
|
||||
AND id != ?
|
||||
LIMIT 1
|
||||
`, [nextName, input.id]).rows[0];
|
||||
|
||||
if (conflicting) {
|
||||
throw new Error(`Context "${nextName}" already exists.`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
sqlite.prepare(`
|
||||
UPDATE contexts
|
||||
SET name = ?, description = ?, icon = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(nextName, nextDescription, nextIcon, now, input.id);
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
name: nextName,
|
||||
description: nextDescription ?? null,
|
||||
icon: nextIcon ?? null,
|
||||
created_at: existing.created_at,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
async getNodesForContext(id: number): Promise<Node[]> {
|
||||
return nodeService.getNodes({ contextId: id, limit: 500 });
|
||||
}
|
||||
|
||||
async resolveContextId(input: { context_id?: number | null; context_name?: string | null }): Promise<number | null | undefined> {
|
||||
const hasContextId = Object.prototype.hasOwnProperty.call(input, 'context_id');
|
||||
const hasContextName = typeof input.context_name === 'string' && input.context_name.trim().length > 0;
|
||||
|
||||
if (!hasContextId && !hasContextName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (hasContextId && input.context_id === null) {
|
||||
if (hasContextName) {
|
||||
throw new Error('context_name cannot be combined with context_id: null.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let resolvedById: Context | null = null;
|
||||
if (hasContextId) {
|
||||
if (typeof input.context_id !== 'number' || !Number.isInteger(input.context_id) || input.context_id <= 0) {
|
||||
throw new Error('context_id must be a positive integer or null.');
|
||||
}
|
||||
const byId = await this.getContextById(input.context_id);
|
||||
if (!byId) {
|
||||
throw new Error(`Context ${input.context_id} not found.`);
|
||||
}
|
||||
resolvedById = {
|
||||
...byId,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasContextName) {
|
||||
const byName = await this.getContextByName(input.context_name!);
|
||||
if (!byName) {
|
||||
throw new Error(`Context "${input.context_name}" not found.`);
|
||||
}
|
||||
if (resolvedById && resolvedById.id !== byName.id) {
|
||||
throw new Error('context_id and context_name refer to different contexts.');
|
||||
}
|
||||
return byName.id;
|
||||
}
|
||||
|
||||
return resolvedById?.id;
|
||||
}
|
||||
}
|
||||
|
||||
export const contextService = new ContextService();
|
||||
@@ -1,12 +1,13 @@
|
||||
import { openai as openaiProvider } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||
import type { CanonicalNodeMetadata } from '@/types/database';
|
||||
|
||||
export interface DescriptionInput {
|
||||
title: string;
|
||||
source?: string;
|
||||
link?: string;
|
||||
metadata?: {
|
||||
metadata?: CanonicalNodeMetadata & {
|
||||
source?: string;
|
||||
channel_name?: string;
|
||||
author?: string;
|
||||
@@ -18,52 +19,12 @@ export interface DescriptionInput {
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility — canonical source is ../storage/apiKeys
|
||||
export { hasValidOpenAiKey } from '../storage/apiKeys';
|
||||
|
||||
/**
|
||||
* Generate a simple fallback description without AI.
|
||||
* Used when no API key is available or for simple inputs.
|
||||
*/
|
||||
export function generateFallbackDescription(input: DescriptionInput): string {
|
||||
const { title, metadata, dimensions } = input;
|
||||
|
||||
// Build a contextual fallback
|
||||
const parts: string[] = [];
|
||||
|
||||
if (metadata?.author || metadata?.channel_name) {
|
||||
parts.push(`By ${metadata.author || metadata.channel_name}`);
|
||||
}
|
||||
|
||||
if (dimensions?.length) {
|
||||
parts.push(`in ${dimensions.slice(0, 2).join(', ')}`);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
return `${parts.join(' — ')}: ${title.slice(0, 200)}`;
|
||||
}
|
||||
|
||||
return title.slice(0, 280);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 280-character description for a knowledge node.
|
||||
* Contextually grounded - adapts to node type (person, concept, article, etc.)
|
||||
*
|
||||
* IMPORTANT: Returns fallback immediately if no valid API key is configured.
|
||||
* This prevents slow node creation (9-13s timeout) when OpenAI is unavailable.
|
||||
*/
|
||||
export async function generateDescription(input: DescriptionInput): Promise<string> {
|
||||
// Fast path: skip AI if no valid API key
|
||||
if (!hasValidOpenAiKey()) {
|
||||
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
|
||||
return generateFallbackDescription(input);
|
||||
}
|
||||
|
||||
// Fast path: skip AI for very short inputs (likely just notes)
|
||||
if (!input.source && !input.link && input.title.length < 30) {
|
||||
console.log(`[DescriptionService] Short input, using fallback for: "${input.title}"`);
|
||||
return generateFallbackDescription(input);
|
||||
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -78,31 +39,38 @@ export async function generateDescription(input: DescriptionInput): Promise<stri
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const finalDescription = sanitizeDescription(response.text, input);
|
||||
const description = sanitizeDescription(response.text, input);
|
||||
|
||||
console.log(`[DescriptionService] Generated: "${finalDescription}"`);
|
||||
console.log(`[DescriptionService] Generated: "${description}"`);
|
||||
|
||||
return finalDescription;
|
||||
return description;
|
||||
} catch (error) {
|
||||
console.error('[DescriptionService] Error generating description:', error);
|
||||
// Return a fallback description
|
||||
return generateFallbackDescription(input);
|
||||
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
const normalizedSource = (input.metadata?.source || '').toLowerCase();
|
||||
const sourceMetadata = input.metadata?.source_metadata as Record<string, unknown> | undefined;
|
||||
const sourceType = typeof input.metadata?.type === 'string'
|
||||
? input.metadata.type
|
||||
: typeof input.metadata?.source === 'string'
|
||||
? input.metadata.source
|
||||
: '';
|
||||
const normalizedSource = sourceType.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() ||
|
||||
(typeof sourceMetadata?.author === 'string' ? sourceMetadata.author.trim() : '') ||
|
||||
(typeof sourceMetadata?.channel_name === 'string' ? sourceMetadata.channel_name.trim() : '') ||
|
||||
(typeof input.metadata?.author === 'string' ? input.metadata.author.trim() : '') ||
|
||||
(typeof input.metadata?.channel_name === 'string' ? 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 publisherHint =
|
||||
(typeof sourceMetadata?.site_name === 'string' ? sourceMetadata.site_name.trim() : '') ||
|
||||
(typeof input.metadata?.site_name === 'string' ? input.metadata.site_name.trim() : '') ||
|
||||
'';
|
||||
|
||||
const likelyExternal =
|
||||
Boolean(url) ||
|
||||
@@ -123,38 +91,41 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
|
||||
|
||||
if (input.link) lines.push(`URL: ${input.link}`);
|
||||
if (input.dimensions?.length) lines.push(`Dimensions: ${input.dimensions.join(', ')}`);
|
||||
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 (input.metadata?.source) lines.push(`Source type: ${input.metadata.source}`);
|
||||
if (input.metadata?.original_filename) lines.push(`Original filename: ${input.metadata.original_filename}`);
|
||||
if (typeof input.metadata?.pages === 'number') lines.push(`Pages: ${input.metadata.pages}`);
|
||||
if (typeof input.metadata?.text_length === 'number') lines.push(`Text length: ${input.metadata.text_length}`);
|
||||
if (sourceMetadata?.channel_name || input.metadata?.channel_name) lines.push(`Channel: ${sourceMetadata?.channel_name || input.metadata?.channel_name}`);
|
||||
if (sourceMetadata?.author || input.metadata?.author) lines.push(`Author: ${sourceMetadata?.author || input.metadata?.author}`);
|
||||
if (sourceMetadata?.site_name || input.metadata?.site_name) lines.push(`Site: ${sourceMetadata?.site_name || input.metadata?.site_name}`);
|
||||
if (sourceType) lines.push(`Source type: ${sourceType}`);
|
||||
if (sourceMetadata?.original_filename || input.metadata?.original_filename) lines.push(`Original filename: ${sourceMetadata?.original_filename || input.metadata?.original_filename}`);
|
||||
if (typeof sourceMetadata?.pages === 'number' || typeof input.metadata?.pages === 'number') lines.push(`Pages: ${sourceMetadata?.pages || input.metadata?.pages}`);
|
||||
if (typeof sourceMetadata?.text_length === 'number' || typeof input.metadata?.text_length === 'number') lines.push(`Text length: ${sourceMetadata?.text_length || input.metadata?.text_length}`);
|
||||
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.source?.slice(0, 800) || '';
|
||||
if (contentPreview) lines.push(`Source excerpt: ${contentPreview}${input.source && input.source.length > 800 ? '...' : ''}`);
|
||||
const sourcePreview = input.source?.slice(0, 800) || '';
|
||||
if (sourcePreview) lines.push(`Source excerpt: ${sourcePreview}${input.source && input.source.length > 800 ? '...' : ''}`);
|
||||
|
||||
return `Write a description for this knowledge node. Max 280 characters.
|
||||
return `Write a natural description for this knowledge node. Max 500 characters.
|
||||
|
||||
Say WHAT this literally is and WHY it matters. Be concrete and specific — like you're telling a friend what this thing is in one breath.
|
||||
The description should read like normal prose, not a template or checklist. In one compact paragraph or a few natural sentences, make sure it clearly conveys:
|
||||
1) what this literally is
|
||||
2) why it is in Brad's graph
|
||||
3) its current status in Brad's workflow
|
||||
|
||||
RULES:
|
||||
1) Name the format only if the context clearly supports it: "Podcast episode where…", "Blog post arguing…", "Personal note capturing…", "Research paper showing…", "Resume/CV for…", "Document likely containing…", "Idea that…"
|
||||
2) Name people by role — channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
|
||||
3) State the actual claim, finding, or insight from the content — not a vague summary of the topic.
|
||||
4) End with why it's interesting or important — one concrete phrase.
|
||||
5) ABSOLUTELY FORBIDDEN — these words will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for". State things directly instead.
|
||||
6) Do NOT start with "Your note —" or "This note —". Use a concrete opener tied to the actual artifact.
|
||||
7) If the artifact type is unclear, say so explicitly using words like "likely", "appears to be", or "unclear" rather than guessing a confident format.
|
||||
4) If the reason it belongs in the graph cannot be inferred from title, source excerpt, URL, metadata, or dimensions, say that naturally rather than inventing context.
|
||||
5) If workflow status is unknown, say that naturally, for example by noting it has not been reviewed yet.
|
||||
6) Do NOT use labels or headings like "WHAT:", "WHY:", or "STATUS:".
|
||||
7) ABSOLUTELY FORBIDDEN — these words and phrases will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to", "important for", "useful for understanding". State things directly instead.
|
||||
8) Do NOT start with "Your note —" or "This note —". Use a concrete opener tied to the actual artifact.
|
||||
9) If the artifact type is unclear, say so explicitly using words like "likely", "appears to be", or "unclear" rather than guessing a confident format.
|
||||
|
||||
GOOD: "Karpathy blog post — AI agents make software fluid, ripping functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
|
||||
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what comes next."
|
||||
GOOD: "Personal note capturing a recurring pattern: morning optimism reverses to evening pessimism. Indicates a belief-level swing worth tracking."
|
||||
GOOD: "Resume/CV for Brad Morris outlining work in AI systems, context engineering, and RA-H. Useful as a compact record of background, projects, and expertise."
|
||||
GOOD: "Document likely related to Brad Morris's work history and AI consulting, but the exact artifact type is unclear from the available context. Still useful as a reference profile."
|
||||
GOOD: "CS153 lecture by ElevenLabs co-founder Mati Staniszewski on production AI voice systems. Brad likely saved it as a follow-on to his interest in the ElevenLabs voice pipeline after CS153 ep.1, and it has not been reviewed yet."
|
||||
GOOD: "YouTube talk by Lex Fridman with Sam Altman on AGI timelines and OpenAI strategy. It was added via Quick Add and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet."
|
||||
GOOD: "Personal note capturing a recurring pattern: morning optimism reverses to evening pessimism. It belongs in the graph because it points to a belief-level pattern worth tracking against Brad's decision quality, and it has already been processed."
|
||||
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective on future advancements."
|
||||
BAD: "This article explores ideas about how software is changing."
|
||||
|
||||
@@ -170,7 +141,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
|
||||
.replace(/^["']|["']$/g, '');
|
||||
|
||||
if (!singleLine) {
|
||||
return input.title.slice(0, 280);
|
||||
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
|
||||
}
|
||||
|
||||
const noGenericPrefix = singleLine.replace(
|
||||
@@ -178,7 +149,7 @@ function sanitizeDescription(rawText: string, input: DescriptionInput): string {
|
||||
'Personal note capturing '
|
||||
);
|
||||
|
||||
return noGenericPrefix.slice(0, 280);
|
||||
return noGenericPrefix.slice(0, 500);
|
||||
}
|
||||
|
||||
export const descriptionService = {
|
||||
|
||||
@@ -3,6 +3,7 @@ export { nodeService, NodeService } from './nodes';
|
||||
export { chunkService, ChunkService } from './chunks';
|
||||
export { edgeService, EdgeService } from './edges';
|
||||
export { dimensionService, DimensionService } from './dimensionService';
|
||||
export { contextService, ContextService } from './contextService';
|
||||
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
|
||||
|
||||
// Types
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Node, NodeFilters } from '@/types/database';
|
||||
import { eventBroadcaster } from '../events';
|
||||
import { EmbeddingService } from '@/services/embeddings';
|
||||
import { scoreNodeSearchMatch } from './searchRanking';
|
||||
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
|
||||
|
||||
type NodeRow = Node & { dimensions_json: string };
|
||||
type NodeRow = Node & { dimensions_json: string; context_json: string | null };
|
||||
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
|
||||
|
||||
function sanitizeFtsQuery(input: string): string {
|
||||
@@ -81,7 +82,7 @@ export class NodeService {
|
||||
|
||||
async countNodes(filters: NodeFilters = {}): Promise<number> {
|
||||
const { dimensions, search, dimensionsMatch = 'any',
|
||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
|
||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
|
||||
|
||||
if (search?.trim()) {
|
||||
return this.countSearchNodesSQLite(filters);
|
||||
@@ -120,6 +121,7 @@ export class NodeService {
|
||||
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
|
||||
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
|
||||
if (chunkStatus) { query += ` AND n.chunk_status = ?`; params.push(chunkStatus); }
|
||||
if (contextId !== undefined) { query += ` AND n.context_id = ?`; params.push(contextId); }
|
||||
|
||||
const result = sqlite.query<{ total: number }>(query, params);
|
||||
return result.rows[0]?.total ?? 0;
|
||||
@@ -129,7 +131,7 @@ export class NodeService {
|
||||
|
||||
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
|
||||
const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any',
|
||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus } = filters;
|
||||
createdAfter, createdBefore, eventAfter, eventBefore, chunkStatus, contextId } = filters;
|
||||
|
||||
if (search?.trim()) {
|
||||
return this.searchNodesSQLite(filters);
|
||||
@@ -141,11 +143,16 @@ export class NodeService {
|
||||
let query = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
END as context_json,
|
||||
(SELECT COUNT(*) FROM edges WHERE from_node_id = n.id OR to_node_id = n.id) as edge_count
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
@@ -198,6 +205,10 @@ export class NodeService {
|
||||
query += ` AND n.chunk_status = ?`;
|
||||
params.push(chunkStatus);
|
||||
}
|
||||
if (contextId !== undefined) {
|
||||
query += ` AND n.context_id = ?`;
|
||||
params.push(contextId);
|
||||
}
|
||||
|
||||
// Sorting logic
|
||||
if (search) {
|
||||
@@ -255,10 +266,15 @@ export class NodeService {
|
||||
const query = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
||||
n.created_at, n.updated_at,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
END as context_json
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE n.id = ?
|
||||
`;
|
||||
const result = sqlite.query<NodeRow>(query, [id]);
|
||||
@@ -284,24 +300,27 @@ export class NodeService {
|
||||
event_date,
|
||||
dimensions = [],
|
||||
chunk_status,
|
||||
metadata = {}
|
||||
metadata = {},
|
||||
context_id,
|
||||
} = nodeData;
|
||||
const canonicalMetadata = buildCanonicalNodeMetadata({ metadata });
|
||||
const now = new Date().toISOString();
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const nodeId = sqlite.transaction(() => {
|
||||
// Insert node using prepare/run for lastInsertRowid access
|
||||
const nodeResult = sqlite.prepare(`
|
||||
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
title,
|
||||
description ?? null,
|
||||
source ?? null,
|
||||
link ?? null,
|
||||
event_date ?? null,
|
||||
JSON.stringify(metadata),
|
||||
JSON.stringify(canonicalMetadata),
|
||||
chunk_status ?? null,
|
||||
context_id ?? null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
@@ -349,13 +368,17 @@ export class NodeService {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const existingRow = sqlite
|
||||
.query<{ id: number }>('SELECT id FROM nodes WHERE id = ?', [id])
|
||||
.query<{ id: number; metadata: string | null }>('SELECT id, metadata FROM nodes WHERE id = ?', [id])
|
||||
.rows[0];
|
||||
|
||||
if (!existingRow) {
|
||||
throw new Error(`Node with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const mergedMetadata = metadata !== undefined
|
||||
? mergeNodeMetadata(existingRow.metadata, metadata)
|
||||
: undefined;
|
||||
|
||||
sqlite.transaction(() => {
|
||||
// Update node columns (only update provided fields)
|
||||
const setFields: string[] = [];
|
||||
@@ -366,13 +389,17 @@ export class NodeService {
|
||||
if (source !== undefined) { setFields.push('source = ?'); params.push(source); }
|
||||
if (link !== undefined) { setFields.push('link = ?'); params.push(link); }
|
||||
if (event_date !== undefined) { setFields.push('event_date = ?'); params.push(event_date); }
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
|
||||
setFields.push('context_id = ?');
|
||||
params.push(updates.context_id ?? null);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'chunk_status')) {
|
||||
setFields.push('chunk_status = ?');
|
||||
params.push(updates.chunk_status ?? null);
|
||||
}
|
||||
if (metadata !== undefined) {
|
||||
if (mergedMetadata !== undefined) {
|
||||
setFields.push('metadata = ?');
|
||||
params.push(JSON.stringify(metadata));
|
||||
params.push(JSON.stringify(mergedMetadata));
|
||||
}
|
||||
|
||||
// Always update timestamp
|
||||
@@ -445,6 +472,7 @@ export class NodeService {
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
context: row.context_json ? JSON.parse(row.context_json) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -456,6 +484,7 @@ export class NodeService {
|
||||
createdBefore,
|
||||
eventAfter,
|
||||
eventBefore,
|
||||
contextId,
|
||||
} = filters;
|
||||
|
||||
const clauses: string[] = [];
|
||||
@@ -483,6 +512,7 @@ export class NodeService {
|
||||
if (createdBefore) { clauses.push(`${alias}.created_at < ?`); params.push(createdBefore); }
|
||||
if (eventAfter) { clauses.push(`${alias}.event_date >= ?`); params.push(eventAfter); }
|
||||
if (eventBefore) { clauses.push(`${alias}.event_date < ?`); params.push(eventBefore); }
|
||||
if (contextId !== undefined) { clauses.push(`${alias}.context_id = ?`); params.push(contextId); }
|
||||
|
||||
return { clauses, params };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const WEAK_DESCRIPTION_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into)\b/i;
|
||||
const EXPLICIT_ENTITY_PATTERNS = /\b(article|artifact|book|brief|claim|company|concept|conversation|dataset|decision|dimension|document|episode|essay|event|guide|idea|insight|interview|lesson|link|node|note|paper|person|plan|placeholder|podcast|post|presentation|project|question|record|research|resource|skill|source|status|summary|talk|target|test node|thread|tool|transcript|tweet|update|video|website|workflow)\b/i;
|
||||
const UNCERTAINTY_PATTERNS = /\b(likely|probably|possibly|appears to be|seems to be|unclear|uncertain)\b/i;
|
||||
const WHY_PATTERNS = /(why added:|added (?:after|as|for|because|to)|follow-?on|queued for|saved for|relevant because|connected to|not inferred|belongs in the graph because|belongs here because|captures .* idea|ties directly into|ties into)/i;
|
||||
const STATUS_PATTERNS = /(status:|queued|not yet reviewed|in progress|processed|reviewed|saved for later|to review|to read|to watch|to listen|draft|not yet published|unpublished)/i;
|
||||
const GENERIC_EDGE_PATTERNS = /^(related|related to|connected|connected to|association|associated with)$/i;
|
||||
|
||||
export function normalizeDimensionName(value: string): string {
|
||||
@@ -29,6 +31,9 @@ export function normalizeDimensions(values: unknown, max = 5): string[] {
|
||||
|
||||
export function validateExplicitDescription(description: string): string | null {
|
||||
const text = description.trim();
|
||||
if (text.length > 500) {
|
||||
return 'Description must be 500 characters or less.';
|
||||
}
|
||||
if (text.length < 24) {
|
||||
return 'Description must be explicit and substantial (at least 24 characters).';
|
||||
}
|
||||
@@ -38,9 +43,37 @@ export function validateExplicitDescription(description: string): string | null
|
||||
if (!EXPLICIT_ENTITY_PATTERNS.test(text) && !UNCERTAINTY_PATTERNS.test(text)) {
|
||||
return 'Description must explicitly identify what this thing is, or state uncertainty explicitly.';
|
||||
}
|
||||
if (!WHY_PATTERNS.test(text)) {
|
||||
return 'Description must clearly indicate why this belongs in the graph. If that reason is unknown, say so naturally instead of inventing it.';
|
||||
}
|
||||
if (!STATUS_PATTERNS.test(text)) {
|
||||
return 'Description must make the workflow status clear. If status is unknown, say naturally that it has not been reviewed yet or is still in progress.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DescriptionFallbackInput {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export function coerceDescriptionForStorage(input: DescriptionFallbackInput): string {
|
||||
const candidate = typeof input.description === 'string' ? input.description.trim() : '';
|
||||
const clippedCandidate = candidate.slice(0, 500);
|
||||
const validationError = clippedCandidate ? validateExplicitDescription(clippedCandidate) : 'missing';
|
||||
|
||||
if (!validationError && clippedCandidate) {
|
||||
return clippedCandidate;
|
||||
}
|
||||
|
||||
const title = input.title.trim() || 'Untitled node';
|
||||
const opening = clippedCandidate || `${title}.`;
|
||||
const normalizedOpening = opening.endsWith('.') ? opening : `${opening}.`;
|
||||
const fallbackTail = ' It was added to the graph with incomplete context, so the exact reason it belongs here is not yet inferred, and it has not been reviewed yet.';
|
||||
|
||||
return `${normalizedOpening}${fallbackTail}`.replace(/\s+/g, ' ').trim().slice(0, 500);
|
||||
}
|
||||
|
||||
export function validateEdgeExplanation(explanation: string): string | null {
|
||||
const text = explanation.trim();
|
||||
if (text.length < 8) {
|
||||
|
||||
@@ -69,6 +69,7 @@ class SQLiteClient {
|
||||
|
||||
// Ensure logging schema (rename memory->logs if needed, create triggers/views)
|
||||
this.ensureLoggingAndMemorySchema();
|
||||
this.ensureContextsSchema();
|
||||
}
|
||||
|
||||
console.log('SQLite client initialized successfully');
|
||||
@@ -219,6 +220,11 @@ class SQLiteClient {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hasReadyLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
|
||||
if (hasReadyLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) If logs table missing but legacy memory table exists, migrate
|
||||
const hasLogs = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='logs'").get();
|
||||
const hasLegacyMemory = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memory'").get();
|
||||
@@ -748,6 +754,58 @@ class SQLiteClient {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureContextsSchema(): void {
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
const nodeCols = this.db.prepare('PRAGMA table_info(nodes)').all() as Array<{ name: string }>;
|
||||
const nodeColNames = nodeCols.map((column) => column.name);
|
||||
if (!nodeColNames.includes('context_id')) {
|
||||
this.db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
||||
}
|
||||
|
||||
const contextCols = this.db.prepare('PRAGMA table_info(contexts)').all() as Array<{ name: string }>;
|
||||
const contextColNames = contextCols.map((column) => column.name);
|
||||
if (!contextColNames.includes('description')) {
|
||||
this.db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
if (!contextColNames.includes('icon')) {
|
||||
this.db.exec('ALTER TABLE contexts ADD COLUMN icon TEXT;');
|
||||
}
|
||||
if (!contextColNames.includes('created_at')) {
|
||||
this.db.exec("ALTER TABLE contexts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||
}
|
||||
if (!contextColNames.includes('updated_at')) {
|
||||
this.db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||
}
|
||||
|
||||
this.db.exec(`
|
||||
UPDATE contexts
|
||||
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
|
||||
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
|
||||
ON contexts(LOWER(TRIM(name)));
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
|
||||
`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to ensure contexts schema:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private healVectorTablesIfCorrupt(): void {
|
||||
if (this.readOnly || !this.vectorCapability.available) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
CanonicalNodeMetadata,
|
||||
NodeCapturedBy,
|
||||
NodeMetadataState,
|
||||
} from '@/types/database';
|
||||
|
||||
type MetadataLike = CanonicalNodeMetadata | Record<string, unknown> | string | null | undefined;
|
||||
|
||||
export interface BuildCanonicalMetadataInput {
|
||||
existing?: MetadataLike;
|
||||
metadata?: MetadataLike;
|
||||
type?: string | null;
|
||||
state?: NodeMetadataState | null;
|
||||
captured_method?: string | null;
|
||||
captured_by?: NodeCapturedBy | null;
|
||||
source_metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseNodeMetadata(metadata: MetadataLike): CanonicalNodeMetadata {
|
||||
if (!metadata) return {};
|
||||
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata);
|
||||
return isObject(parsed) ? { ...parsed } : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return isObject(metadata) ? { ...metadata } : {};
|
||||
}
|
||||
|
||||
function normalizeSourceMetadata(sourceMetadata: unknown): Record<string, unknown> {
|
||||
return isObject(sourceMetadata) ? { ...sourceMetadata } : {};
|
||||
}
|
||||
|
||||
function normalizedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function buildCanonicalNodeMetadata(input: BuildCanonicalMetadataInput): CanonicalNodeMetadata {
|
||||
const existing = parseNodeMetadata(input.existing);
|
||||
const incoming = parseNodeMetadata(input.metadata);
|
||||
|
||||
const type = normalizedString(input.type) ?? normalizedString(incoming.type) ?? normalizedString(existing.type);
|
||||
const state = input.state ?? (incoming.state as NodeMetadataState | undefined) ?? (existing.state as NodeMetadataState | undefined) ?? 'not_processed';
|
||||
const capturedMethod =
|
||||
normalizedString(input.captured_method)
|
||||
?? normalizedString(incoming.captured_method)
|
||||
?? normalizedString(existing.captured_method);
|
||||
const capturedBy =
|
||||
input.captured_by
|
||||
?? (incoming.captured_by as NodeCapturedBy | undefined)
|
||||
?? (existing.captured_by as NodeCapturedBy | undefined)
|
||||
?? 'human';
|
||||
|
||||
const sourceMetadata = {
|
||||
...normalizeSourceMetadata(existing.source_metadata),
|
||||
...normalizeSourceMetadata(incoming.source_metadata),
|
||||
...normalizeSourceMetadata(input.source_metadata),
|
||||
};
|
||||
|
||||
const merged: CanonicalNodeMetadata = {
|
||||
...existing,
|
||||
...incoming,
|
||||
source_metadata: sourceMetadata,
|
||||
state,
|
||||
captured_by: capturedBy,
|
||||
};
|
||||
|
||||
if (type) {
|
||||
merged.type = type;
|
||||
} else {
|
||||
delete merged.type;
|
||||
}
|
||||
|
||||
if (capturedMethod) {
|
||||
merged.captured_method = capturedMethod;
|
||||
} else {
|
||||
delete merged.captured_method;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function mergeNodeMetadata(existing: MetadataLike, incoming: MetadataLike): CanonicalNodeMetadata {
|
||||
return buildCanonicalNodeMetadata({ existing, metadata: incoming });
|
||||
}
|
||||
|
||||
export function getNodeProcessedState(metadata: MetadataLike): NodeMetadataState {
|
||||
const parsed = parseNodeMetadata(metadata);
|
||||
return parsed.state === 'processed' ? 'processed' : 'not_processed';
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const SEEDED_SKILL_IDS = new Set([
|
||||
'persona',
|
||||
'calibration',
|
||||
'connect',
|
||||
'node-context-enrichment',
|
||||
]);
|
||||
|
||||
const DEPRECATED_SKILL_IDS = new Set([
|
||||
|
||||
@@ -2,46 +2,91 @@ import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
||||
import { normalizeDimensions } from '@/services/database/quality';
|
||||
|
||||
function extractTextFromMessageContent(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== 'object') return '';
|
||||
const candidate = part as Record<string, unknown>;
|
||||
if (typeof candidate.text === 'string') return candidate.text;
|
||||
if (candidate.type === 'text' && typeof candidate.value === 'string') return candidate.value;
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function inferSourceFromContext(params: { title: string; description?: string; source?: string }, context: any): string | undefined {
|
||||
if (typeof params.source === 'string' && params.source.trim()) {
|
||||
return params.source.trim();
|
||||
}
|
||||
|
||||
const messages = Array.isArray(context?.messages) ? context.messages : [];
|
||||
const latestUserMessage = [...messages].reverse().find((message: any) => message?.role === 'user');
|
||||
if (!latestUserMessage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawUserText = extractTextFromMessageContent(latestUserMessage.content).trim();
|
||||
if (!rawUserText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(rawUserText)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = rawUserText.replace(/\r\n/g, '\n').trim();
|
||||
const descriptionLength = typeof params.description === 'string' ? params.description.trim().length : 0;
|
||||
const isSubstantialCapture = normalized.length >= Math.max(280, descriptionLength + 120) || normalized.includes('\n');
|
||||
|
||||
if (!isSubstantialCapture) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export const createNodeTool = tool({
|
||||
description: 'Create node. Description is REQUIRED and must be explicit about what the thing is (podcast, chat summary, idea, etc).',
|
||||
description: 'Create node. Set the primary context explicitly when it is clear; otherwise the server will infer the best-fit context automatically so the node is not left unscoped. Infer a clean title, dimensions, and natural description with best effort. When the node comes from the user\'s own idea, note, or dictated thought, preserve their actual wording in source with only minimal cleanup instead of flattening it into a summary. Do not block creation if the description is incomplete. If the description framing is materially inferred, create the node first and then invite one concise user correction pass.',
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('The title of the node'),
|
||||
description: z.string().max(280).describe('REQUIRED. Explicitly state WHAT this is (e.g. podcast episode, conversation summary, user insight) + WHY it matters for context grounding.'),
|
||||
source: z.string().optional().describe('Raw content for embedding: transcript, article text, book passages, or user thoughts. If omitted, falls back to title + description.'),
|
||||
description: z.string().max(500).optional().describe('Optional natural description. If you have enough context, describe what this is, why it belongs in Brad\'s graph, and its current workflow status in normal prose. Do not use labels like WHAT:, WHY:, or STATUS:.'),
|
||||
source: z.string().optional().describe('Canonical source content for embedding. For external content, store the actual transcript/article/document text. For user-authored ideas or dictated notes, store the user\'s original wording as fully as possible with only minimal cleanup such as obvious whitespace or transcription artifacts. Do not replace raw user thinking with a thin summary.'),
|
||||
link: z.string().optional().describe('A URL link to the source'),
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Use when the node clearly belongs to a known context.'),
|
||||
context_name: z.string().optional().describe('Optional convenience context name. Resolved to a stable context_id before persistence.'),
|
||||
dimensions: z
|
||||
.array(z.string())
|
||||
.max(5)
|
||||
.optional()
|
||||
.describe('Optional dimension tags to apply to this node (0-5 items).'),
|
||||
metadata: z.record(z.any()).optional().describe('Additional metadata like source info, extraction details, etc.')
|
||||
.describe('Optional secondary dimension tags to apply to this node (0-5 items).'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional node metadata. Use canonical keys when known: type, state, captured_method, captured_by, and source_metadata. Source-specific facts belong inside source_metadata.')
|
||||
}),
|
||||
execute: async (params) => {
|
||||
execute: async (params, context) => {
|
||||
console.log('🎯 CreateNode tool called with params:', JSON.stringify(params, null, 2));
|
||||
try {
|
||||
const descriptionError = validateExplicitDescription(params.description);
|
||||
if (descriptionError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${descriptionError} Do not retry with minor rephrasing in the same turn. Rewrite the description so it explicitly names the entity type, such as note, node, person, episode, article, project, test node, or skill, and states why it matters.`,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedDimensions = normalizeDimensions(params.dimensions || [], 5);
|
||||
const canonicalSource = inferSourceFromContext(params, context);
|
||||
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...params, dimensions: trimmedDimensions })
|
||||
body: JSON.stringify({ ...params, source: canonicalSource, dimensions: trimmedDimensions })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -50,20 +95,10 @@ export const createNodeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
// Format the created node for chat display
|
||||
const formattedDisplay = formatNodeForChat({
|
||||
id: result.data.id,
|
||||
title: result.data.title,
|
||||
dimensions: result.data.dimensions || trimmedDimensions
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...result.data,
|
||||
formatted_display: formattedDisplay
|
||||
},
|
||||
message: `Created node ${formattedDisplay} with dimensions: ${result.data.dimensions ? result.data.dimensions.join(', ') : 'none'}`
|
||||
data: result.data,
|
||||
message: `Created: ${formatNodeForChat(result.data)}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -75,5 +110,4 @@ export const createNodeTool = tool({
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const createItemTool = createNodeTool;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { contextService } from '@/services/database/contextService';
|
||||
|
||||
type QueryContextFilters = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
includeNodes?: boolean;
|
||||
};
|
||||
|
||||
function matchesSearch(value: string | null | undefined, search: string): boolean {
|
||||
if (!value) return false;
|
||||
return value.toLowerCase().includes(search);
|
||||
}
|
||||
|
||||
export const queryContextsTool = tool({
|
||||
description: 'List and inspect contexts. Use this to discover available primary scopes before filtering nodes or assigning node context.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
name: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.number().min(1).max(100).default(50).optional(),
|
||||
includeNodes: z.boolean().default(false).optional(),
|
||||
}).optional(),
|
||||
}),
|
||||
execute: async ({ filters = {} }: { filters?: QueryContextFilters }) => {
|
||||
try {
|
||||
const limit = filters.limit || 50;
|
||||
const normalizedName = filters.name?.trim();
|
||||
const normalizedSearch = filters.search?.trim().toLowerCase();
|
||||
|
||||
let contexts = await contextService.listContexts();
|
||||
if (filters.id !== undefined) {
|
||||
contexts = contexts.filter((context) => context.id === filters.id);
|
||||
}
|
||||
if (normalizedName) {
|
||||
contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName.toLowerCase());
|
||||
}
|
||||
if (normalizedSearch) {
|
||||
contexts = contexts.filter((context) =>
|
||||
matchesSearch(context.name, normalizedSearch) ||
|
||||
matchesSearch(context.description, normalizedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
const limitedContexts = contexts.slice(0, limit);
|
||||
const includeNodes = filters.includeNodes === true && limitedContexts.length === 1 && (filters.id !== undefined || Boolean(normalizedName));
|
||||
|
||||
const enriched = await Promise.all(limitedContexts.map(async (context) => {
|
||||
if (!includeNodes) return context;
|
||||
const nodes = await contextService.getNodesForContext(context.id);
|
||||
return {
|
||||
...context,
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
context_id: node.context_id ?? null,
|
||||
updated_at: node.updated_at,
|
||||
})),
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
contexts: enriched,
|
||||
count: enriched.length,
|
||||
total_available: contexts.length,
|
||||
filters_applied: filters,
|
||||
},
|
||||
message: enriched.length === 0 ? 'No contexts found.' : `Found ${enriched.length} context(s).`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to query contexts',
|
||||
data: {
|
||||
contexts: [],
|
||||
count: 0,
|
||||
filters_applied: filters,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
|
||||
type QueryNodeFilters = {
|
||||
dimensions?: string[];
|
||||
contextId?: number;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
createdAfter?: string;
|
||||
@@ -20,6 +21,7 @@ export const queryNodesTool = tool({
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(),
|
||||
contextId: z.number().int().positive().describe('Optional primary context filter.').optional(),
|
||||
search: z.string().describe('Search term to match against node title, description, or source').optional(),
|
||||
limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
|
||||
createdAfter: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'),
|
||||
@@ -82,6 +84,7 @@ export const queryNodesTool = tool({
|
||||
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
|
||||
limit,
|
||||
dimensions: queryFilters.dimensions,
|
||||
contextId: queryFilters.contextId,
|
||||
search: queryFilters.search,
|
||||
searchMode: searchTerm ? 'hybrid' : 'standard',
|
||||
createdAfter: queryFilters.createdAfter,
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { normalizeDimensions, validateExplicitDescription } from '@/services/database/quality';
|
||||
import { normalizeDimensions } from '@/services/database/quality';
|
||||
|
||||
export const updateNodeTool = tool({
|
||||
description: 'Update node fields. Description is REQUIRED on every update and must explicitly state WHAT this is + WHY it matters.',
|
||||
description: 'Update node fields. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless context_id is supplied explicitly. When fixing a user-authored idea node, source should preserve the user\'s original wording as fully as possible. Never block an update because the description is incomplete. If the new description framing is materially inferred, complete the update and then invite one concise user feedback pass.',
|
||||
inputSchema: z.object({
|
||||
id: z.number().describe('The ID of the node to update'),
|
||||
updates: z.object({
|
||||
title: z.string().optional().describe('New title'),
|
||||
description: z.string().max(280).describe('REQUIRED on every update. Explicitly state WHAT this is + WHY it matters. No "discusses/explores".'),
|
||||
source: z.string().optional().describe('Canonical source content for embedding. Use this only to set or correct the raw source text.'),
|
||||
description: z.string().max(500).optional().describe('Optional natural description. Replace the whole field with one clean description when you are improving context. It should read like normal prose, not labels.'),
|
||||
source: z.string().optional().describe('Canonical source content for embedding. Use this to set or correct the raw source text. For user-authored ideas or dictated notes, preserve the user\'s original wording with only minimal cleanup rather than compressing it into a summary.'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
event_date: z.string().optional().describe('When the thing actually happened (ISO 8601). Not when it was added to the graph.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
|
||||
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Primary context ID. Omit to preserve the existing context. Use null only to clear it intentionally.'),
|
||||
dimensions: z.array(z.string()).optional().describe('New secondary dimension tags - completely replaces existing dimensions'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata instead of replacing the full blob. Use canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||
}),
|
||||
execute: async ({ id, updates }) => {
|
||||
@@ -27,27 +28,10 @@ export const updateNodeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
if (!updates.description) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Every node update requires an explicit description (WHAT this is + WHY it matters).',
|
||||
data: null
|
||||
};
|
||||
}
|
||||
const descriptionError = validateExplicitDescription(updates.description);
|
||||
if (descriptionError) {
|
||||
return {
|
||||
success: false,
|
||||
error: descriptionError,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(updates.dimensions)) {
|
||||
updates.dimensions = normalizeDimensions(updates.dimensions, 5);
|
||||
}
|
||||
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -55,7 +39,7 @@ export const updateNodeTool = tool({
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -74,10 +58,9 @@ export const updateNodeTool = tool({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update node',
|
||||
data: null
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const updateItemTool = updateNodeTool;
|
||||
|
||||
@@ -40,6 +40,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
queryDimensions: 'core',
|
||||
getDimension: 'core',
|
||||
queryDimensionNodes: 'core',
|
||||
queryContexts: 'core',
|
||||
searchContentEmbeddings: 'core',
|
||||
|
||||
// Orchestration: Web search and reasoning
|
||||
|
||||
@@ -14,6 +14,7 @@ import { deleteDimensionTool } from '../database/deleteDimension';
|
||||
import { queryDimensionsTool } from '../database/queryDimensions';
|
||||
import { getDimensionTool } from '../database/getDimension';
|
||||
import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
||||
import { queryContextsTool } from '../database/queryContexts';
|
||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||
import { webSearchTool } from '../other/webSearch';
|
||||
import { thinkTool } from '../other/think';
|
||||
@@ -32,6 +33,7 @@ const CORE_TOOLS: Record<string, any> = {
|
||||
queryDimensions: queryDimensionsTool,
|
||||
getDimension: getDimensionTool,
|
||||
queryDimensionNodes: queryDimensionNodesTool,
|
||||
queryContexts: queryContextsTool,
|
||||
searchContentEmbeddings: searchContentEmbeddingsTool,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,20 @@ import { z } from 'zod';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { extractPaper } from '@/services/typescript/extractors/paper';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { validateExplicitDescription } from '@/services/database/quality';
|
||||
|
||||
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
|
||||
const normalizedCandidate = typeof candidate === 'string' ? candidate.trim().replace(/\s+/g, ' ') : '';
|
||||
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
|
||||
return normalizedCandidate.slice(0, 500);
|
||||
}
|
||||
const lead = normalizedCandidate || fallbackLead.trim();
|
||||
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
|
||||
return `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`.slice(0, 500);
|
||||
}
|
||||
|
||||
// AI-powered content analysis
|
||||
async function analyzeContentWithAI(title: string, description: string, contentType: string) {
|
||||
try {
|
||||
const prompt = `Analyze this ${contentType} content and provide classification.
|
||||
@@ -13,56 +24,36 @@ async function analyzeContentWithAI(title: string, description: string, contentT
|
||||
Title: "${title}"
|
||||
Description: "${description}"
|
||||
|
||||
CRITICAL — nodeDescription rules (max 280 chars):
|
||||
1. Say WHAT this literally is: "Paper by…", "Research from…", "Preprint introducing…"
|
||||
2. Name the authors if known from the metadata.
|
||||
3. State the actual finding, method, or contribution — not "a study on X" but what they actually found or built.
|
||||
4. End with why it matters — one concrete phrase about impact or implication.
|
||||
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
|
||||
CRITICAL — nodeDescription rules (max 500 chars):
|
||||
1. Write natural prose.
|
||||
2. Make clear what this literally is.
|
||||
3. State the actual finding, method, or contribution.
|
||||
4. Make clear why it belongs in the graph. If unclear, say so naturally.
|
||||
5. Make the workflow status clear.
|
||||
|
||||
Examples:
|
||||
- Title: "Attention Is All You Need" / Authors: Vaswani et al.
|
||||
GOOD: "Vaswani et al. introduce the Transformer architecture — replaces recurrence with self-attention for sequence modeling. Foundation of every modern LLM."
|
||||
BAD: "This paper discusses a new architecture called the Transformer and explores its applications."
|
||||
|
||||
- Title: "Scaling Laws for Neural Language Models" / Authors: Kaplan et al.
|
||||
GOOD: "Kaplan et al. show that LLM performance scales as a power law with compute, data, and parameters — and compute matters most. The paper that launched the scaling era."
|
||||
BAD: "A study examining how neural language models scale with different factors."
|
||||
|
||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
Respond with ONLY valid JSON:
|
||||
{
|
||||
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
|
||||
"nodeDescription": "<your 280-char description following the rules above>",
|
||||
"tags": ["relevant", "semantic", "tags"],
|
||||
"reasoning": "Brief explanation of classification choices"
|
||||
"enhancedDescription": "A comprehensive summary.",
|
||||
"nodeDescription": "<your natural description>",
|
||||
"reasoning": "Brief explanation"
|
||||
}`;
|
||||
|
||||
const response = await generateText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
prompt,
|
||||
maxOutputTokens: 800
|
||||
});
|
||||
|
||||
let content = response.text || '{}';
|
||||
|
||||
// Clean up the response - remove markdown code blocks if present
|
||||
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
|
||||
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
const result = JSON.parse(content);
|
||||
|
||||
return {
|
||||
enhancedDescription: result.enhancedDescription || description,
|
||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
|
||||
tags: Array.isArray(result.tags) ? result.tags : [],
|
||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
|
||||
reasoning: result.reasoning || 'AI analysis completed'
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
console.warn('Paper analysis fallback (using default description):', message);
|
||||
console.warn('Paper analysis fallback (using default description):', error);
|
||||
return {
|
||||
enhancedDescription: description,
|
||||
nodeDescription: undefined,
|
||||
tags: [],
|
||||
reasoning: 'Fallback description used'
|
||||
};
|
||||
}
|
||||
@@ -73,30 +64,19 @@ export const paperExtractTool = tool({
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The PDF URL to add to inbox'),
|
||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
|
||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
|
||||
}),
|
||||
execute: async ({ url, title, dimensions }) => {
|
||||
try {
|
||||
// Validate PDF URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid URL format - must start with http:// or https://',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: 'Invalid URL format - must start with http:// or https://', data: null };
|
||||
}
|
||||
|
||||
// Check if URL likely points to a PDF
|
||||
if (!url.toLowerCase().includes('.pdf') && !url.includes('arxiv.org')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'URL does not appear to point to a PDF file',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: 'URL does not appear to point to a PDF file', data: null };
|
||||
}
|
||||
|
||||
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||
|
||||
try {
|
||||
const extractionResult = await extractPaper(url);
|
||||
result = {
|
||||
@@ -112,91 +92,72 @@ export const paperExtractTool = tool({
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
result = {
|
||||
success: false,
|
||||
error: error.message || 'TypeScript extraction failed'
|
||||
};
|
||||
result = { success: false, error: error.message || 'TypeScript extraction failed' };
|
||||
}
|
||||
|
||||
if (!result.success || !result.source) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Failed to extract PDF content',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: result.error || 'Failed to extract PDF content', data: null };
|
||||
}
|
||||
|
||||
console.log('🎯 PDF extraction successful, analyzing with AI...');
|
||||
|
||||
// Step 2: AI Analysis for enhanced metadata
|
||||
const aiAnalysis = await analyzeContentWithAI(
|
||||
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
|
||||
result.source.substring(0, 2000) || 'PDF document content',
|
||||
result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`,
|
||||
result.source.substring(0, 2000) || 'PDF document content',
|
||||
'pdf'
|
||||
);
|
||||
|
||||
// Step 3: Create node with extracted content and AI analysis
|
||||
const nodeTitle = title || result.metadata?.title || `PDF: ${new URL(url).pathname.split('/').pop()?.replace('.pdf', '')}`;
|
||||
const enhancedDescription = aiAnalysis?.enhancedDescription || `PDF document from ${new URL(url).hostname}`;
|
||||
|
||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
||||
let trimmedDimensions = suppliedDimensions
|
||||
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
|
||||
.filter(Boolean);
|
||||
const nodeDescription = ensureNodeDescription(
|
||||
aiAnalysis?.nodeDescription,
|
||||
`Research paper or PDF from ${new URL(url).hostname} titled ${nodeTitle}`
|
||||
);
|
||||
const trimmedDimensions = Array.isArray(dimensions) ? dimensions.slice(0, 5) : [];
|
||||
|
||||
trimmedDimensions = trimmedDimensions.slice(0, 5);
|
||||
|
||||
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: nodeTitle,
|
||||
description: aiAnalysis?.nodeDescription,
|
||||
description: nodeDescription,
|
||||
source: result.source,
|
||||
link: url,
|
||||
dimensions: trimmedDimensions,
|
||||
metadata: {
|
||||
source: 'pdf',
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author || result.metadata?.info?.Author,
|
||||
pages: result.metadata?.pages,
|
||||
file_size: result.metadata?.file_size,
|
||||
content_length: result.source.length,
|
||||
extraction_method: result.metadata?.extraction_method || 'python_pdfplumber',
|
||||
ai_analysis: aiAnalysis?.reasoning,
|
||||
enhanced_description: enhancedDescription,
|
||||
refined_at: new Date().toISOString()
|
||||
type: 'pdf',
|
||||
state: 'not_processed',
|
||||
captured_method: 'paper_extract',
|
||||
captured_by: 'agent',
|
||||
source_metadata: {
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author || result.metadata?.info?.Author,
|
||||
pages: result.metadata?.pages,
|
||||
content_length: result.source.length,
|
||||
extraction_method: result.metadata?.extraction_method || 'typescript',
|
||||
ai_analysis: aiAnalysis?.reasoning,
|
||||
enhanced_description: aiAnalysis?.enhancedDescription,
|
||||
refined_at: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const createResult = await createResponse.json();
|
||||
|
||||
if (!createResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: createResult.error || 'Failed to create node',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: createResult.error || 'Failed to create node', data: null };
|
||||
}
|
||||
|
||||
console.log('🎯 PaperExtract completed successfully');
|
||||
|
||||
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
||||
const actualDimensions: string[] = createResult.data?.dimensions || trimmedDimensions || [];
|
||||
const formattedNode = createResult.data?.id
|
||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||
: nodeTitle;
|
||||
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
|
||||
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
|
||||
data: {
|
||||
nodeId: createResult.data?.id,
|
||||
title: nodeTitle,
|
||||
contentLength: result.source.length,
|
||||
url: url,
|
||||
url,
|
||||
dimensions: actualDimensions
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,13 +3,25 @@ import { z } from 'zod';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { extractWebsite } from '@/services/typescript/extractors/website';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { validateExplicitDescription } from '@/services/database/quality';
|
||||
|
||||
interface ExistingDimension {
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
|
||||
const normalizedCandidate = typeof candidate === 'string' ? candidate.trim().replace(/\s+/g, ' ') : '';
|
||||
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
|
||||
return normalizedCandidate.slice(0, 500);
|
||||
}
|
||||
const lead = normalizedCandidate || fallbackLead.trim();
|
||||
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
|
||||
return `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`.slice(0, 500);
|
||||
}
|
||||
|
||||
function inferWebsiteContentType(url: string): 'website' | 'tweet' {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
@@ -23,12 +35,10 @@ function inferWebsiteContentType(url: string): 'website' | 'tweet' {
|
||||
|
||||
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions/popular`);
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions/popular`);
|
||||
if (!response.ok) return [];
|
||||
|
||||
const result = await response.json();
|
||||
if (!Array.isArray(result.data)) return [];
|
||||
|
||||
return result.data
|
||||
.map((dimension: { dimension?: unknown; description?: unknown }) => ({
|
||||
name: typeof dimension.dimension === 'string' ? dimension.dimension.trim() : '',
|
||||
@@ -41,17 +51,11 @@ async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
||||
}
|
||||
}
|
||||
|
||||
function selectExistingDimensions(
|
||||
selected: unknown,
|
||||
existingDimensions: ExistingDimension[],
|
||||
max = 5
|
||||
): string[] {
|
||||
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
|
||||
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
|
||||
|
||||
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
|
||||
const normalized: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const value of selected) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const matched = byLowerName.get(value.trim().toLowerCase());
|
||||
@@ -62,89 +66,53 @@ function selectExistingDimensions(
|
||||
normalized.push(matched);
|
||||
if (normalized.length >= max) break;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// AI-powered content analysis
|
||||
async function analyzeContentWithAI(
|
||||
title: string,
|
||||
description: string,
|
||||
contentType: string,
|
||||
existingDimensions: ExistingDimension[]
|
||||
) {
|
||||
async function analyzeContentWithAI(title: string, description: string, contentType: string, existingDimensions: ExistingDimension[]) {
|
||||
try {
|
||||
const availableDimensionsBlock = existingDimensions.length > 0
|
||||
? existingDimensions
|
||||
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
|
||||
.join('\n')
|
||||
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
|
||||
: '- No existing dimensions available. Return an empty dimensions array.';
|
||||
const prompt = `Analyze this ${contentType} content and provide classification.
|
||||
|
||||
Title: "${title}"
|
||||
Description: "${description}"
|
||||
|
||||
CRITICAL — nodeDescription rules (max 280 chars):
|
||||
1. Say WHAT this literally is using explicit entity words only: "Blog post by…", "Article from…", "Essay arguing…", "Tutorial on…", "Thread by…", "Tweet by…", "Post by…"
|
||||
2. Name the author/site if known from the metadata.
|
||||
3. State the actual claim or thesis — don't paraphrase into vague abstractions.
|
||||
4. End with why it's interesting or important — one concrete phrase.
|
||||
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
|
||||
|
||||
DIMENSION SELECTION (critical):
|
||||
You must select 0-3 dimensions from the list below.
|
||||
Do NOT invent new dimension names.
|
||||
Pick only dimensions that genuinely fit this content.
|
||||
If nothing fits, return an empty array.
|
||||
CRITICAL — nodeDescription rules (max 500 chars):
|
||||
1. Write natural prose, not labels or a checklist.
|
||||
2. Make clear what this literally is using explicit entity words only.
|
||||
3. Name the author/site if known from the metadata.
|
||||
4. State the actual claim or thesis.
|
||||
5. Make clear why it belongs in the graph. If that remains unclear, say so naturally.
|
||||
6. Make workflow status clear.
|
||||
|
||||
Available dimensions:
|
||||
${availableDimensionsBlock}
|
||||
|
||||
Examples:
|
||||
- Title: "Software is eating the world — again" / Author: Andrej Karpathy
|
||||
GOOD: "Karpathy's blog post arguing AI agents make software fluid — they can rip functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
|
||||
BAD: "By Karpathy — discusses the importance of software becoming more fluid and malleable with agents."
|
||||
|
||||
- Title: "The case for slowing down AI" / Site: The Atlantic
|
||||
GOOD: "Atlantic article making the case that AI labs should voluntarily slow capability research until safety catches up. Notable because it cites internal lab disagreements."
|
||||
BAD: "This article explores ideas about slowing down AI development and its implications."
|
||||
|
||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
Respond with ONLY valid JSON:
|
||||
{
|
||||
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
|
||||
"nodeDescription": "<your 280-char description following the rules above>",
|
||||
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
|
||||
"reasoning": "Brief explanation of classification choices"
|
||||
"enhancedDescription": "A comprehensive summary.",
|
||||
"nodeDescription": "<your natural description>",
|
||||
"dimensions": ["existing-dimension-1"],
|
||||
"reasoning": "Brief explanation"
|
||||
}`;
|
||||
|
||||
const response = await generateText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
prompt,
|
||||
maxOutputTokens: 800
|
||||
});
|
||||
|
||||
let content = response.text || '{}';
|
||||
|
||||
// Clean up the response - remove markdown code blocks if present
|
||||
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
|
||||
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
const result = JSON.parse(content);
|
||||
|
||||
return {
|
||||
enhancedDescription: result.enhancedDescription || description,
|
||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
|
||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
|
||||
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
|
||||
reasoning: result.reasoning || 'AI analysis completed'
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
console.warn('Website analysis fallback (using default description):', message);
|
||||
return {
|
||||
enhancedDescription: description,
|
||||
nodeDescription: undefined,
|
||||
dimensions: [],
|
||||
reasoning: 'Fallback description used'
|
||||
};
|
||||
console.warn('Website analysis fallback (using default description):', error);
|
||||
return { enhancedDescription: description, nodeDescription: undefined, dimensions: [], reasoning: 'Fallback description used' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,21 +121,15 @@ export const websiteExtractTool = tool({
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The website URL to add to knowledge base'),
|
||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
|
||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
|
||||
}),
|
||||
execute: async ({ url, title, dimensions }) => {
|
||||
try {
|
||||
// Validate URL format
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid URL format - must start with http:// or https://',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: 'Invalid URL format - must start with http:// or https://', data: null };
|
||||
}
|
||||
|
||||
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||
|
||||
try {
|
||||
const extractionResult = await extractWebsite(url);
|
||||
result = {
|
||||
@@ -184,97 +146,75 @@ export const websiteExtractTool = tool({
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
result = {
|
||||
success: false,
|
||||
error: error.message || 'TypeScript extraction failed'
|
||||
};
|
||||
result = { success: false, error: error.message || 'TypeScript extraction failed' };
|
||||
}
|
||||
|
||||
if (!result.success || !result.source) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Failed to extract website content',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: result.error || 'Failed to extract website content', data: null };
|
||||
}
|
||||
|
||||
console.log('🎯 Website extraction successful, analyzing with AI...');
|
||||
|
||||
// Step 2: AI Analysis for enhanced metadata
|
||||
const existingDimensions = await fetchExistingDimensions();
|
||||
const contentType = inferWebsiteContentType(url);
|
||||
const aiAnalysis = await analyzeContentWithAI(
|
||||
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
||||
result.source.substring(0, 2000) || 'Website content',
|
||||
contentType,
|
||||
result.metadata?.title || `Website: ${new URL(url).hostname}`,
|
||||
result.source.substring(0, 2000) || 'Website content',
|
||||
inferWebsiteContentType(url),
|
||||
existingDimensions
|
||||
);
|
||||
|
||||
// Step 3: Create node with extracted content and AI analysis
|
||||
const nodeTitle = title || result.metadata?.title || `Website: ${new URL(url).hostname}`;
|
||||
const enhancedDescription = aiAnalysis?.enhancedDescription || `Website content from ${new URL(url).hostname}`;
|
||||
|
||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
||||
let trimmedDimensions = suppliedDimensions
|
||||
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
|
||||
.filter(Boolean);
|
||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions.slice(0, 5) : [];
|
||||
const finalDimensions = suppliedDimensions.length > 0 ? suppliedDimensions : (aiAnalysis?.dimensions || []).slice(0, 5);
|
||||
const nodeDescription = ensureNodeDescription(
|
||||
aiAnalysis?.nodeDescription,
|
||||
`Website article from ${result.metadata?.site_name || new URL(url).hostname} titled ${nodeTitle}`
|
||||
);
|
||||
|
||||
trimmedDimensions = trimmedDimensions.slice(0, 5);
|
||||
const finalDimensions = trimmedDimensions.length > 0
|
||||
? trimmedDimensions
|
||||
: (aiAnalysis?.dimensions || []).slice(0, 5);
|
||||
|
||||
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: nodeTitle,
|
||||
description: aiAnalysis?.nodeDescription,
|
||||
description: nodeDescription,
|
||||
source: result.source,
|
||||
link: url,
|
||||
event_date: result.metadata?.published_date || result.metadata?.date || null,
|
||||
dimensions: finalDimensions,
|
||||
metadata: {
|
||||
source: contentType,
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author,
|
||||
published_date: result.metadata?.published_date || result.metadata?.date,
|
||||
content_length: result.source.length,
|
||||
extraction_method: result.metadata?.extraction_method || 'python_beautifulsoup',
|
||||
ai_analysis: aiAnalysis?.reasoning,
|
||||
enhanced_description: enhancedDescription,
|
||||
refined_at: new Date().toISOString()
|
||||
type: inferWebsiteContentType(url),
|
||||
state: 'not_processed',
|
||||
captured_method: 'website_extract',
|
||||
captured_by: 'agent',
|
||||
source_metadata: {
|
||||
hostname: new URL(url).hostname,
|
||||
author: result.metadata?.author,
|
||||
site_name: result.metadata?.site_name,
|
||||
date: result.metadata?.date,
|
||||
og_image: result.metadata?.og_image,
|
||||
extraction_method: result.metadata?.extraction_method,
|
||||
ai_analysis: aiAnalysis?.reasoning,
|
||||
refined_at: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const createResult = await createResponse.json();
|
||||
|
||||
if (!createResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: createResult.error || 'Failed to create node',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: createResult.error || 'Failed to create node', data: null };
|
||||
}
|
||||
|
||||
console.log('🎯 WebsiteExtract completed successfully');
|
||||
|
||||
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
||||
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
|
||||
const formattedNode = createResult.data?.id
|
||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||
: nodeTitle;
|
||||
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
|
||||
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
|
||||
data: {
|
||||
nodeId: createResult.data?.id,
|
||||
title: nodeTitle,
|
||||
contentLength: result.source.length,
|
||||
url: url,
|
||||
url,
|
||||
dimensions: actualDimensions
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,16 +3,33 @@ import { z } from 'zod';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { extractYouTube } from '@/services/typescript/extractors/youtube';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { validateExplicitDescription } from '@/services/database/quality';
|
||||
|
||||
interface ExistingDimension {
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function ensureNodeDescription(candidate: string | undefined, fallbackLead: string): string {
|
||||
const normalizedCandidate = typeof candidate === 'string'
|
||||
? candidate.trim().replace(/\s+/g, ' ')
|
||||
: '';
|
||||
|
||||
if (normalizedCandidate && !validateExplicitDescription(normalizedCandidate)) {
|
||||
return normalizedCandidate.slice(0, 500);
|
||||
}
|
||||
|
||||
const lead = normalizedCandidate || fallbackLead.trim();
|
||||
const suffix = 'It was added via extraction and the exact reason it belongs in the graph is not yet inferred from the available context, and it has not been reviewed yet.';
|
||||
const joined = `${lead}${/[.!?]$/.test(lead) ? ' ' : '. '}${suffix}`;
|
||||
return joined.slice(0, 500);
|
||||
}
|
||||
|
||||
async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/dimensions/popular`);
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/dimensions/popular`);
|
||||
if (!response.ok) return [];
|
||||
|
||||
const result = await response.json();
|
||||
@@ -30,11 +47,7 @@ async function fetchExistingDimensions(): Promise<ExistingDimension[]> {
|
||||
}
|
||||
}
|
||||
|
||||
function selectExistingDimensions(
|
||||
selected: unknown,
|
||||
existingDimensions: ExistingDimension[],
|
||||
max = 5
|
||||
): string[] {
|
||||
function selectExistingDimensions(selected: unknown, existingDimensions: ExistingDimension[], max = 5): string[] {
|
||||
if (!Array.isArray(selected) || existingDimensions.length === 0) return [];
|
||||
|
||||
const byLowerName = new Map(existingDimensions.map((dimension) => [dimension.name.toLowerCase(), dimension.name]));
|
||||
@@ -55,55 +68,39 @@ function selectExistingDimensions(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// AI-powered content analysis
|
||||
async function analyzeContentWithAI(
|
||||
title: string,
|
||||
description: string,
|
||||
contentType: string,
|
||||
existingDimensions: ExistingDimension[]
|
||||
) {
|
||||
async function analyzeContentWithAI(title: string, description: string, contentType: string, existingDimensions: ExistingDimension[]) {
|
||||
try {
|
||||
const availableDimensionsBlock = existingDimensions.length > 0
|
||||
? existingDimensions
|
||||
.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`)
|
||||
.join('\n')
|
||||
? existingDimensions.map((dimension) => `- ${dimension.name}${dimension.description ? `: ${dimension.description}` : ''}`).join('\n')
|
||||
: '- No existing dimensions available. Return an empty dimensions array.';
|
||||
|
||||
const prompt = `Analyze this ${contentType} content and provide classification.
|
||||
|
||||
Title: "${title}"
|
||||
Description: "${description}"
|
||||
|
||||
CRITICAL — nodeDescription rules (max 280 chars):
|
||||
1. Say WHAT this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
|
||||
2. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
|
||||
3. State the actual claim or thesis from the title — don't paraphrase into vague abstractions.
|
||||
4. End with why it's interesting or important — one concrete phrase.
|
||||
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
|
||||
CRITICAL — nodeDescription rules (max 500 chars):
|
||||
1. Write natural prose, not labels or a checklist.
|
||||
2. Make clear what this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
|
||||
3. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
|
||||
4. State the actual claim or thesis from the title.
|
||||
5. Make clear why it belongs in the graph. If that cannot be inferred, say so naturally.
|
||||
6. Make the workflow status clear. If unknown, say naturally that it has not been reviewed yet.
|
||||
7. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for", "insightful for understanding", "relevant to".
|
||||
|
||||
DIMENSION SELECTION (critical):
|
||||
DIMENSION SELECTION:
|
||||
You must select 0-3 dimensions from the list below.
|
||||
Do NOT invent new dimension names.
|
||||
Pick only dimensions that genuinely fit this content.
|
||||
If nothing fits, return an empty array.
|
||||
|
||||
Available dimensions:
|
||||
${availableDimensionsBlock}
|
||||
|
||||
Examples:
|
||||
- Title: "Dario Amodei — We are near the end of the exponential" / Channel: Dwarkesh Patel
|
||||
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what the next phase of AI development looks like."
|
||||
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective."
|
||||
|
||||
- Title: "The spell of language models" / Channel: Andrej Karpathy
|
||||
GOOD: "Karpathy talk on how LLMs work under the hood — tokenization, attention, and why they feel like magic but aren't. Essential mental model for anyone building with LLMs."
|
||||
BAD: "By Andrej Karpathy — explores the nature of language models and their capabilities."
|
||||
|
||||
Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
Respond with ONLY valid JSON:
|
||||
{
|
||||
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
|
||||
"nodeDescription": "<your 280-char description following the rules above>",
|
||||
"dimensions": ["existing-dimension-1", "existing-dimension-2"],
|
||||
"reasoning": "Brief explanation of classification choices"
|
||||
"enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars).",
|
||||
"nodeDescription": "<your natural description>",
|
||||
"dimensions": ["existing-dimension-1"],
|
||||
"reasoning": "Brief explanation"
|
||||
}`;
|
||||
|
||||
const response = await generateText({
|
||||
@@ -112,22 +109,17 @@ Respond with ONLY valid JSON (no markdown, no code blocks):
|
||||
maxOutputTokens: 800
|
||||
});
|
||||
|
||||
let content = response.text || '{}';
|
||||
|
||||
// Clean up the response - remove markdown code blocks if present
|
||||
content = content.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
|
||||
const content = (response.text || '{}').replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
const result = JSON.parse(content);
|
||||
|
||||
return {
|
||||
enhancedDescription: result.enhancedDescription || description,
|
||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
|
||||
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 500) : undefined,
|
||||
dimensions: selectExistingDimensions(result.dimensions, existingDimensions, 5),
|
||||
reasoning: result.reasoning || 'AI analysis completed'
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
console.warn('YouTube analysis fallback (using default description):', message);
|
||||
console.warn('YouTube analysis fallback (using default description):', error);
|
||||
return {
|
||||
enhancedDescription: description,
|
||||
nodeDescription: undefined,
|
||||
@@ -142,29 +134,21 @@ async function summariseTranscript(title: string, transcript: string): Promise<s
|
||||
return null;
|
||||
}
|
||||
|
||||
// Limit transcript length to keep token costs manageable
|
||||
const MAX_CHARS = 16000;
|
||||
let excerpt = transcript.trim();
|
||||
if (excerpt.length > MAX_CHARS) {
|
||||
const head = excerpt.slice(0, MAX_CHARS / 2);
|
||||
const tail = excerpt.slice(-MAX_CHARS / 2);
|
||||
excerpt = `${head}\n[...]\n${tail}`;
|
||||
}
|
||||
|
||||
const prompt = `You are summarising a long-form recording for a knowledge graph entry. Title: "${title}".
|
||||
|
||||
Using the transcript excerpt below, write a concise 3-4 sentence summary covering the main themes, notable claims, and outcomes. If specific terms, frameworks, or memorable lines appear, mention them. Keep the tone factual (no marketing language). If the excerpt appears truncated, note that the summary is based on the portion provided.
|
||||
|
||||
Transcript excerpt:
|
||||
"""
|
||||
${excerpt}
|
||||
"""
|
||||
`;
|
||||
const excerpt = transcript.trim().length > 16000
|
||||
? `${transcript.trim().slice(0, 8000)}\n[...]\n${transcript.trim().slice(-8000)}`
|
||||
: transcript.trim();
|
||||
|
||||
try {
|
||||
const response = await generateText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
prompt,
|
||||
prompt: `You are summarising a long-form recording for a knowledge graph entry. Title: "${title}".
|
||||
|
||||
Using the transcript excerpt below, write a concise 3-4 sentence summary covering the main themes, notable claims, and outcomes. Keep the tone factual.
|
||||
|
||||
Transcript excerpt:
|
||||
"""
|
||||
${excerpt}
|
||||
"""`,
|
||||
maxOutputTokens: 400
|
||||
});
|
||||
return response.text?.trim() || null;
|
||||
@@ -179,23 +163,17 @@ export const youtubeExtractTool = tool({
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The YouTube video URL to add to knowledge base'),
|
||||
title: z.string().optional().describe('Custom title (auto-generated if not provided)'),
|
||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node (locked dimensions first)')
|
||||
dimensions: z.array(z.string()).min(1).max(5).optional().describe('Dimension tags to apply to the created node')
|
||||
}),
|
||||
execute: async ({ url, title, dimensions }) => {
|
||||
console.log('🎯 YouTubeExtract tool called with URL:', url);
|
||||
try {
|
||||
// Validate YouTube URL
|
||||
if (!url.includes('youtube.com') && !url.includes('youtu.be')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid YouTube URL format',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: 'Invalid YouTube URL format', data: null };
|
||||
}
|
||||
|
||||
let result: { success: boolean; source?: string; metadata?: any; error?: string };
|
||||
|
||||
console.log('📝 Using TypeScript yt-dlp extractor');
|
||||
|
||||
try {
|
||||
const extractionResult = await extractYouTube(url);
|
||||
result = {
|
||||
@@ -215,98 +193,81 @@ export const youtubeExtractTool = tool({
|
||||
error: extractionResult.error
|
||||
};
|
||||
} catch (error: any) {
|
||||
result = {
|
||||
success: false,
|
||||
error: error.message || 'TypeScript extraction failed'
|
||||
};
|
||||
result = { success: false, error: error.message || 'TypeScript extraction failed' };
|
||||
}
|
||||
|
||||
if (!result.success || !result.source) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Failed to extract YouTube content',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: result.error || 'Failed to extract YouTube content', data: null };
|
||||
}
|
||||
|
||||
console.log('🎯 YouTube extraction successful, analyzing with AI...');
|
||||
|
||||
// Step 2: AI Analysis for enhanced metadata
|
||||
const existingDimensions = await fetchExistingDimensions();
|
||||
const aiAnalysis = await analyzeContentWithAI(
|
||||
result.metadata?.video_title || 'YouTube Video',
|
||||
`Video by ${result.metadata?.channel_name || 'Unknown Channel'}`,
|
||||
result.metadata?.video_title || 'YouTube Video',
|
||||
`Video by ${result.metadata?.channel_name || 'Unknown Channel'}`,
|
||||
'youtube',
|
||||
existingDimensions
|
||||
);
|
||||
|
||||
// Step 3: Create node with extracted content and AI analysis
|
||||
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
|
||||
const transcriptSummary = await summariseTranscript(nodeTitle, result.source);
|
||||
|
||||
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
|
||||
let trimmedDimensions = suppliedDimensions
|
||||
.map(dim => (typeof dim === 'string' ? dim.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
trimmedDimensions = trimmedDimensions.slice(0, 5);
|
||||
const finalDimensions = trimmedDimensions.length > 0
|
||||
? trimmedDimensions
|
||||
const finalDimensions = suppliedDimensions.slice(0, 5).length > 0
|
||||
? suppliedDimensions.slice(0, 5)
|
||||
: (aiAnalysis?.dimensions || []).slice(0, 5);
|
||||
const nodeDescription = ensureNodeDescription(
|
||||
aiAnalysis?.nodeDescription,
|
||||
transcriptSummary || `YouTube video by ${result.metadata?.channel_name || 'an unknown creator'} about ${nodeTitle}`
|
||||
);
|
||||
|
||||
const createResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes`, {
|
||||
const createResponse = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: nodeTitle,
|
||||
description: aiAnalysis?.nodeDescription,
|
||||
description: nodeDescription,
|
||||
source: result.source,
|
||||
link: url,
|
||||
dimensions: finalDimensions,
|
||||
metadata: {
|
||||
source: 'youtube',
|
||||
video_id: result.metadata?.video_id,
|
||||
channel_name: result.metadata?.channel_name,
|
||||
channel_url: result.metadata?.channel_url,
|
||||
thumbnail_url: result.metadata?.thumbnail_url,
|
||||
transcript_length: result.metadata?.transcript_length,
|
||||
total_segments: result.metadata?.total_segments,
|
||||
language: result.metadata?.language,
|
||||
extraction_method: result.metadata?.extraction_method,
|
||||
ai_analysis: aiAnalysis?.reasoning,
|
||||
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
|
||||
refined_at: new Date().toISOString()
|
||||
type: 'youtube',
|
||||
state: 'not_processed',
|
||||
captured_method: 'youtube_extract',
|
||||
captured_by: 'agent',
|
||||
source_metadata: {
|
||||
video_id: result.metadata?.video_id,
|
||||
channel_name: result.metadata?.channel_name,
|
||||
channel_url: result.metadata?.channel_url,
|
||||
thumbnail_url: result.metadata?.thumbnail_url,
|
||||
transcript_length: result.metadata?.transcript_length,
|
||||
total_segments: result.metadata?.total_segments,
|
||||
language: result.metadata?.language,
|
||||
extraction_method: result.metadata?.extraction_method,
|
||||
ai_analysis: aiAnalysis?.reasoning,
|
||||
summary_origin: transcriptSummary ? 'transcript_summary' : 'metadata_description',
|
||||
refined_at: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const createResult = await createResponse.json();
|
||||
|
||||
if (!createResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: createResult.error || 'Failed to create item',
|
||||
data: null
|
||||
};
|
||||
return { success: false, error: createResult.error || 'Failed to create item', data: null };
|
||||
}
|
||||
|
||||
console.log('🎯 YouTubeExtract completed successfully');
|
||||
|
||||
// Use actual assigned dimensions from API response (includes auto-assigned locked + keywords)
|
||||
const actualDimensions: string[] = createResult.data?.dimensions || finalDimensions || [];
|
||||
const formattedNode = createResult.data?.id
|
||||
? formatNodeForChat({ id: createResult.data.id, title: nodeTitle, dimensions: actualDimensions })
|
||||
: nodeTitle;
|
||||
const dimsDisplay = actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Added ${formattedNode} with dimensions: ${dimsDisplay}`,
|
||||
message: `Added ${formattedNode} with dimensions: ${actualDimensions.length > 0 ? actualDimensions.join(', ') : 'none'}`,
|
||||
data: {
|
||||
nodeId: createResult.data?.id,
|
||||
title: nodeTitle,
|
||||
contentLength: result.source.length,
|
||||
url: url,
|
||||
url,
|
||||
dimensions: actualDimensions
|
||||
}
|
||||
};
|
||||
|
||||
+33
-1
@@ -1,3 +1,32 @@
|
||||
export type NodeMetadataState = 'processed' | 'not_processed';
|
||||
export type NodeCapturedBy = 'human' | 'agent';
|
||||
|
||||
export interface CanonicalNodeMetadata {
|
||||
type?: string;
|
||||
state?: NodeMetadataState;
|
||||
captured_method?: string;
|
||||
captured_by?: NodeCapturedBy;
|
||||
source_metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ContextSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// New Node-based type system replacing rigid Item categorization
|
||||
export interface Node {
|
||||
id: number;
|
||||
@@ -10,10 +39,12 @@ export interface Node {
|
||||
dimensions: string[]; // Flexible dimensions replacing type + stage + segment + tags
|
||||
embedding?: Buffer; // Node-level embedding (BLOB data)
|
||||
chunk?: string; // Deprecated legacy field - do not write
|
||||
metadata?: any; // Flexible metadata storage
|
||||
metadata?: CanonicalNodeMetadata | null; // Flexible metadata storage with canonical contract
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
edge_count?: number; // Derived count of edges, included in some queries
|
||||
context_id?: number | null;
|
||||
context?: Pick<Context, 'id' | 'name' | 'description' | 'icon'> | null;
|
||||
|
||||
// Optional embedding fields
|
||||
embedding_updated_at?: string;
|
||||
@@ -67,6 +98,7 @@ export interface EdgeContext {
|
||||
// New NodeFilters interface replacing rigid ItemFilters
|
||||
export interface NodeFilters {
|
||||
dimensions?: string[]; // Filter by dimensions (replaces stage/type filtering)
|
||||
contextId?: number;
|
||||
search?: string; // Text search in title/content
|
||||
searchMode?: 'standard' | 'hybrid'; // standard = FTS/LIKE, hybrid = add node-vector retrieval
|
||||
chunkStatus?: 'not_chunked' | 'chunking' | 'chunked' | 'error';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const LOCAL_HOST_PATTERN = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0)(:\d+)?(\/.*)?$/i;
|
||||
const BARE_HOST_PATTERN = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/.*)?$/i;
|
||||
|
||||
export function normalizeNodeLink(input?: string | null): string | null {
|
||||
if (typeof input !== 'string') return null;
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: BARE_HOST_PATTERN.test(trimmed) || LOCAL_HOST_PATTERN.test(trimmed)
|
||||
? `https://${trimmed}`
|
||||
: null;
|
||||
|
||||
if (!candidate) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user