feat: sync open-source context and capsule removal
- remove legacy contexts surfaces from the app, MCP bridge, standalone server, and docs - keep getContext and retrieveQueryContext while aligning the simplified graph-only contract - harden dev startup migration behavior and disable the accidental chat surface in open source Generated with Codex
This commit is contained in:
@@ -12,7 +12,6 @@ import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
|
||||
import { normalizeNodeLink } from '@/utils/nodeLink';
|
||||
import NodeSearchModal from './edges/NodeSearchModal';
|
||||
import { getNodeProcessedState, parseNodeMetadata } from '@/services/nodes/metadata';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
|
||||
interface FocusPanelProps {
|
||||
openTabs: number[];
|
||||
@@ -68,9 +67,6 @@ export default function FocusPanel({
|
||||
const [sourceEditMode, setSourceEditMode] = useState(false);
|
||||
const [sourceEditValue, setSourceEditValue] = useState('');
|
||||
const [sourceSaving, setSourceSaving] = useState(false);
|
||||
const [availableContexts, setAvailableContexts] = useState<ContextSummary[]>([]);
|
||||
const [contextSaving, setContextSaving] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const [hoveredSection, setHoveredSection] = useState<HoverSection>(null);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -79,7 +75,6 @@ export default function FocusPanel({
|
||||
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const skipTitleBlurRef = useRef(false);
|
||||
const skipLinkBlurRef = useRef(false);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentNode = activeTab !== null ? nodesData[activeTab] : undefined;
|
||||
const normalizedCurrentLink = normalizeNodeLink(currentNode?.link || null);
|
||||
@@ -105,34 +100,6 @@ export default function FocusPanel({
|
||||
if (sourceEditMode) sourceTextareaRef.current?.focus();
|
||||
}, [sourceEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!contextMenuRef.current?.contains(event.target as globalThis.Node)) {
|
||||
setContextMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
openTabs.forEach((tabId) => {
|
||||
if (!nodesData[tabId] && !loadingNodes.has(tabId)) {
|
||||
@@ -165,7 +132,6 @@ export default function FocusPanel({
|
||||
setDescEditValue('');
|
||||
setSourceEditMode(false);
|
||||
setSourceEditValue('');
|
||||
setContextMenuOpen(false);
|
||||
setEdgeSearchOpen(false);
|
||||
setEdgeEditingId(null);
|
||||
setEdgeEditingValue('');
|
||||
@@ -389,20 +355,6 @@ export default function FocusPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const saveContext = async (value: string) => {
|
||||
if (!activeTab) return;
|
||||
setContextSaving(true);
|
||||
try {
|
||||
await updateNode(activeTab, { context_id: value ? Number(value) : null });
|
||||
setContextMenuOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error saving context:', error);
|
||||
window.alert('Failed to save context. Please try again.');
|
||||
} finally {
|
||||
setContextSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const embedContent = async (nodeId: number) => {
|
||||
setEmbeddingNode(nodeId);
|
||||
try {
|
||||
@@ -1192,60 +1144,6 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">context</div>
|
||||
<div className="prop-value context-prop-value">
|
||||
<div className="context-select-shell" ref={contextMenuRef}>
|
||||
<div className="context-inline-row">
|
||||
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
|
||||
{currentNode.context?.name || 'No context'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="context-inline-button"
|
||||
disabled={contextSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen((prev) => !prev);
|
||||
}}
|
||||
title={currentNode.context ? 'Change context' : 'Add context'}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contextMenuOpen ? (
|
||||
<div className="context-select-menu">
|
||||
<button
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext('');
|
||||
}}
|
||||
>
|
||||
No context
|
||||
</button>
|
||||
{availableContexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext(String(context.id));
|
||||
}}
|
||||
>
|
||||
<span>{context.name}</span>
|
||||
<span className="context-option-count">{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections */}
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">edge</div>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquare, X } from 'lucide-react';
|
||||
import type { ChatPanelProps } from '../panes/types';
|
||||
|
||||
export default function ChatPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpen,
|
||||
}: ChatPanelProps) {
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
title="Open chat"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'var(--rah-accent-green)',
|
||||
color: 'var(--rah-text-inverse)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: 'var(--rah-shadow-floating)',
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--rah-border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--rah-text-soft)', fontSize: 12, fontWeight: 500, letterSpacing: '0.05em' }}>
|
||||
Chat
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close chat"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
color: 'var(--rah-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Chat UI is not included in this open-source parity slice. The graph, MCP, retrieval, and skill surfaces remain available.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RefreshCw,
|
||||
LayoutList,
|
||||
Map,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Table2,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
import type { PaneType } from '../panes/types';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
|
||||
interface LeftToolbarProps {
|
||||
onSearchClick: () => void;
|
||||
@@ -38,8 +36,6 @@ interface LeftToolbarProps {
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
theme: Theme;
|
||||
onThemeToggle: () => void;
|
||||
contexts?: ContextSummary[];
|
||||
onContextQuickSelect?: (contextId: number, contextName: string) => void;
|
||||
}
|
||||
|
||||
const NAV_WIDTH_COLLAPSED = 50;
|
||||
@@ -124,10 +120,7 @@ export default function LeftToolbar({
|
||||
focusedSkill,
|
||||
theme,
|
||||
onThemeToggle,
|
||||
contexts = [],
|
||||
onContextQuickSelect,
|
||||
}: LeftToolbarProps) {
|
||||
const [contextsExpanded, setContextsExpanded] = useState(false);
|
||||
const [paneSelectorOpen, setPaneSelectorOpen] = useState(false);
|
||||
|
||||
const renderPaneGlyph = (count: 1 | 2 | 3, active: boolean) => (
|
||||
@@ -302,77 +295,6 @@ export default function LeftToolbar({
|
||||
activeTone="green"
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '10px' }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
"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. That is optional.</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)',
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import PaneHeader from './PaneHeader';
|
||||
import type { BasePaneProps } from './types';
|
||||
import SkillCard from '@/components/skills/SkillCard';
|
||||
import SkillMarkdown from '@/components/skills/SkillMarkdown';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
|
||||
interface SkillMeta {
|
||||
name: string;
|
||||
@@ -17,12 +18,19 @@ interface Skill extends SkillMeta {
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SkillsPaneProps extends BasePaneProps {
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
onFocusSkill?: (skill: FocusedSkill | null) => void;
|
||||
autoOpenSkillName?: string | null;
|
||||
onAutoOpenHandled?: () => void;
|
||||
}
|
||||
|
||||
export default function SkillsPane({
|
||||
slot,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
}: BasePaneProps) {
|
||||
}: SkillsPaneProps) {
|
||||
const [skills, setSkills] = useState<SkillMeta[]>([]);
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { X, LayoutList, PanelsTopLeft, Map, FileText, Table2, BookOpen } from 'lucide-react';
|
||||
import { X, LayoutList, Map, FileText, Table2, BookOpen } from 'lucide-react';
|
||||
import type { SlotTab, PaneType, SlotId } from './types';
|
||||
|
||||
const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
|
||||
views: LayoutList,
|
||||
contexts: PanelsTopLeft,
|
||||
map: Map,
|
||||
node: FileText,
|
||||
table: Table2,
|
||||
@@ -15,7 +14,6 @@ const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
|
||||
|
||||
const TAB_TYPE_LABELS: Record<PaneType, string> = {
|
||||
views: 'Feed',
|
||||
contexts: 'Contexts',
|
||||
map: 'Map',
|
||||
node: 'Node',
|
||||
table: 'Table',
|
||||
|
||||
@@ -12,9 +12,6 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
}
|
||||
|
||||
export default function ViewsPane({
|
||||
@@ -29,9 +26,6 @@ export default function ViewsPane({
|
||||
refreshToken,
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
}: ViewsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -61,9 +55,6 @@ export default function ViewsPane({
|
||||
refreshToken={refreshToken}
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={onDismissPending}
|
||||
externalContextFilterId={externalContextFilterId}
|
||||
onContextFilterSelect={onContextFilterSelect}
|
||||
onClearExternalContextFilter={onClearExternalContextFilter}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { default as NodePane } from './NodePane';
|
||||
export { default as ContextsPane } from './ContextsPane';
|
||||
export { default as MapPane } from './MapPane';
|
||||
export { default as ViewsPane } from './ViewsPane';
|
||||
export { default as TablePane } from './TablePane';
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FocusedSkill } from '@/types/skills';
|
||||
export type SlotId = 'A' | 'B' | 'C';
|
||||
|
||||
// The pane types
|
||||
export type PaneType = 'node' | 'contexts' | 'map' | 'views' | 'table' | 'skills';
|
||||
export type PaneType = 'node' | 'map' | 'views' | 'table' | 'skills';
|
||||
|
||||
// A single tab within a slot
|
||||
export interface SlotTab {
|
||||
@@ -32,7 +32,6 @@ export function getActiveTab(state: SlotState): SlotTab | undefined {
|
||||
// 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: 'switch-pane-type'; paneType: PaneType }
|
||||
| { type: 'close-pane' };
|
||||
|
||||
@@ -69,13 +68,13 @@ export interface HighlightedPassage {
|
||||
|
||||
export interface ChatPanelProps {
|
||||
isOpen: boolean;
|
||||
isSoloPane?: boolean;
|
||||
slot?: SlotId;
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
onSwapPanes?: (source: SlotId, target: SlotId) => void;
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeContextId?: number | null;
|
||||
activeContextName?: string | null;
|
||||
onClearContext?: () => void;
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
onClearFocusedSkill?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
@@ -99,14 +98,6 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
externalContextFilterId?: number | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
}
|
||||
|
||||
export interface ContextsPaneProps extends BasePaneProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
}
|
||||
|
||||
// TablePane specific props
|
||||
@@ -128,7 +119,6 @@ export interface PaneHeaderProps {
|
||||
// Labels for pane types
|
||||
export const PANE_LABELS: Record<PaneType, string> = {
|
||||
node: 'Nodes',
|
||||
contexts: 'Contexts',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
table: 'Table',
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
interface CapsuleData {
|
||||
userProfile: string;
|
||||
agentProfile: string;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export default function ContextViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [capsule, setCapsule] = useState<CapsuleData | null>(null);
|
||||
const [loadingNodes, setLoadingNodes] = useState(true);
|
||||
const [loadingCapsule, setLoadingCapsule] = useState(true);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
const loadNodes = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/nodes?sortBy=edges&limit=5');
|
||||
const payload = await res.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoadingNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCapsule = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/rah/memory');
|
||||
const payload = await res.json();
|
||||
setCapsule(payload.data?.capsule ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setCapsule(null);
|
||||
} finally {
|
||||
setLoadingCapsule(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadNodes();
|
||||
void loadCapsule();
|
||||
}, []);
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!confirm('Reset the context capsule to neutral defaults?')) return;
|
||||
try {
|
||||
setResetting(true);
|
||||
await fetch('/api/rah/memory', { method: 'DELETE' });
|
||||
await loadCapsule();
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>
|
||||
RA-H now carries one compact context capsule into every conversation. It stays neutral by default,
|
||||
updates only on meaningful changes, and sits alongside hub nodes rather than replacing them.
|
||||
</p>
|
||||
|
||||
<div style={capsuleHeaderStyle}>
|
||||
<div>
|
||||
<div style={labelStyle}>Context Capsule</div>
|
||||
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
style={resetButtonStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset Capsule'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingCapsule ? (
|
||||
<div style={mutedStyle}>Loading capsule...</div>
|
||||
) : capsule ? (
|
||||
<div style={capsuleGridStyle}>
|
||||
<CapsuleSection
|
||||
title="User"
|
||||
value={capsule.userProfile}
|
||||
footer={capsule.lastUpdatedAt ? `Updated ${new Date(capsule.lastUpdatedAt).toLocaleString()}` : 'Never updated'}
|
||||
/>
|
||||
<CapsuleSection title="Agent" value={capsule.agentProfile} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={mutedStyle}>Capsule unavailable.</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...labelStyle, marginTop: 28 }}>Hub Nodes</div>
|
||||
<div style={subLabelStyle}>
|
||||
Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation.
|
||||
</div>
|
||||
|
||||
{loadingNodes ? (
|
||||
<div style={mutedStyle}>Loading hub nodes...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={mutedStyle}>No connected nodes yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} style={nodeCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
|
||||
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
|
||||
</div>
|
||||
{node.description && (
|
||||
<div style={nodeDescriptionStyle}>{node.description}</div>
|
||||
)}
|
||||
{node.context?.name && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<span style={contextTagStyle}>{node.context.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapsuleSection({
|
||||
title,
|
||||
value,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
footer?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>{title}</div>
|
||||
<div style={capsuleBodyStyle}>{value}</div>
|
||||
{footer && <div style={capsuleFooterStyle}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5, maxWidth: 780 };
|
||||
const capsuleHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 12 };
|
||||
const capsuleGridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 };
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
minHeight: 140,
|
||||
};
|
||||
const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)', marginBottom: 8 };
|
||||
const subLabelStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginTop: 10 };
|
||||
const capsuleBodyStyle: CSSProperties = { fontSize: 13, lineHeight: 1.6, color: 'var(--settings-subtext)', whiteSpace: 'pre-wrap' };
|
||||
const capsuleFooterStyle: CSSProperties = { fontSize: 11, color: 'var(--settings-muted)', marginTop: 12 };
|
||||
const resetButtonStyle: CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--settings-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--settings-text)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
const nodeCardStyle: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 6,
|
||||
};
|
||||
const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)' };
|
||||
const nodeDescriptionStyle: CSSProperties = { fontSize: 12, lineHeight: 1.5, color: 'var(--settings-subtext)', marginTop: 8 };
|
||||
const edgeCountStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
const contextTagStyle: CSSProperties = {
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
background: 'var(--settings-chip-bg)',
|
||||
color: 'var(--settings-subtext)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
};
|
||||
@@ -325,24 +325,7 @@ export default function DatabaseViewer() {
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
|
||||
)}
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>—</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||||
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ApiKeysViewer from './ApiKeysViewer';
|
||||
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
||||
import ContextViewer from './ContextViewer';
|
||||
|
||||
export type SettingsTab = 'apikeys' | 'context' | 'agents';
|
||||
export type SettingsTab = 'apikeys' | 'agents';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,10 +14,10 @@ interface SettingsModalProps {
|
||||
}
|
||||
|
||||
const DEFAULT_TAB: SettingsTab = 'apikeys';
|
||||
const TAB_ORDER: SettingsTab[] = ['apikeys', 'context', 'agents'];
|
||||
const TAB_ORDER: SettingsTab[] = ['apikeys', 'agents'];
|
||||
|
||||
const TAB_LABELS: Record<SettingsTab, string> = {
|
||||
apikeys: 'API Keys',
|
||||
context: 'Context',
|
||||
agents: 'Agents',
|
||||
};
|
||||
|
||||
@@ -31,11 +30,9 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
@@ -50,6 +47,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
return createPortal(
|
||||
<div style={backdropStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Sidebar */}
|
||||
<div style={sidebarStyle}>
|
||||
<div style={logoStyle}>Settings</div>
|
||||
|
||||
@@ -70,36 +68,27 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={footerStyle}>
|
||||
<div style={footerLabelStyle}>Local Mode</div>
|
||||
<p style={footerTextStyle}>
|
||||
This build runs entirely on your machine. Add your API key here to unlock descriptions,
|
||||
embeddings, and agent-assisted workflows.
|
||||
</p>
|
||||
<div style={userSectionStyle}>
|
||||
<div style={userLabelStyle}>Local Mode</div>
|
||||
<div style={userEmailStyle}>Bring your own API keys for descriptions, embeddings, and agent workflows.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={contentStyle}>
|
||||
<div style={headerStyle}>
|
||||
<h2 style={titleStyle}>{TAB_LABELS[activeTab]}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={closeBtnStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--settings-text)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--settings-muted)';
|
||||
}}
|
||||
title="Close (ESC)"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--settings-text)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--settings-muted)'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={contentAreaStyle}>
|
||||
{activeTab === 'apikeys' && <ApiKeysViewer />}
|
||||
{activeTab === 'context' && <ContextViewer />}
|
||||
{activeTab === 'agents' && <ExternalAgentsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,6 +98,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
);
|
||||
}
|
||||
|
||||
// Styles
|
||||
const backdropStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
@@ -133,7 +123,7 @@ const modalStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const sidebarStyle: CSSProperties = {
|
||||
width: '220px',
|
||||
width: '200px',
|
||||
background: 'var(--settings-sidebar-bg)',
|
||||
borderRight: '1px solid var(--settings-border)',
|
||||
display: 'flex',
|
||||
@@ -146,6 +136,7 @@ const logoStyle: CSSProperties = {
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--settings-text)',
|
||||
letterSpacing: '-0.01em',
|
||||
};
|
||||
|
||||
const navStyle: CSSProperties = {
|
||||
@@ -164,8 +155,7 @@ const navItemStyle: CSSProperties = {
|
||||
borderRadius: '8px',
|
||||
};
|
||||
|
||||
const footerStyle: CSSProperties = {
|
||||
marginTop: 'auto',
|
||||
const userSectionStyle: CSSProperties = {
|
||||
padding: '16px 20px',
|
||||
borderTop: '1px solid var(--settings-border)',
|
||||
display: 'flex',
|
||||
@@ -173,19 +163,31 @@ const footerStyle: CSSProperties = {
|
||||
gap: '8px',
|
||||
};
|
||||
|
||||
const footerLabelStyle: CSSProperties = {
|
||||
const userLabelStyle: CSSProperties = {
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'var(--settings-muted)',
|
||||
};
|
||||
|
||||
const footerTextStyle: CSSProperties = {
|
||||
const userEmailStyle: CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: 'var(--settings-subtext)',
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
const signOutBtnStyle: CSSProperties = {
|
||||
marginTop: '4px',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
color: 'var(--settings-text)',
|
||||
border: '1px solid var(--settings-border-strong)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease, border-color 0.15s ease',
|
||||
};
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
@@ -211,13 +213,13 @@ const titleStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const closeBtnStyle: CSSProperties = {
|
||||
background: 'transparent',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--settings-muted)',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '24px',
|
||||
lineHeight: 1,
|
||||
padding: '4px 8px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.15s ease',
|
||||
};
|
||||
|
||||
|
||||
@@ -322,30 +322,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
{node.id}
|
||||
</span>
|
||||
|
||||
<span style={{ minWidth: 0 }}>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>—</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ minWidth: 0, fontSize: '11px', color: 'var(--rah-text-muted)' }}>—</span>
|
||||
|
||||
<span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
|
||||
{node.edge_count ?? 0}
|
||||
|
||||
@@ -133,27 +133,6 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with Context */}
|
||||
{node.context?.name && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#888'
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -146,21 +146,6 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
}}>
|
||||
{processedState}
|
||||
</span>
|
||||
{node.context?.name && (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-base)'
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
|
||||
import type { ContextSummary, Node } from '@/types/database';
|
||||
import { ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
|
||||
@@ -29,9 +29,6 @@ interface ViewsOverlayProps {
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
toolbarHost?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
@@ -216,15 +213,9 @@ export default function ViewsOverlay({
|
||||
refreshToken = 0,
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId = null,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
toolbarHost,
|
||||
}: ViewsOverlayProps) {
|
||||
|
||||
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||
const [contextsLoading, setContextsLoading] = useState(true);
|
||||
|
||||
// Sort order (persisted)
|
||||
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
|
||||
|
||||
@@ -245,22 +236,6 @@ export default function ViewsOverlay({
|
||||
? 'not_processed'
|
||||
: 'all';
|
||||
|
||||
const fetchContexts = useCallback(async () => {
|
||||
setContextsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/contexts');
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch contexts');
|
||||
}
|
||||
setContexts(data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching contexts:', error);
|
||||
} finally {
|
||||
setContextsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyProcessedFilter = useCallback((nodes: Node[]) => {
|
||||
if (processedFilter === 'all') return nodes;
|
||||
return nodes.filter((node) => getNodeProcessedState(node.metadata) === processedFilter);
|
||||
@@ -273,7 +248,7 @@ export default function ViewsOverlay({
|
||||
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
|
||||
? 'updated'
|
||||
: sortOrder;
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch nodes');
|
||||
@@ -301,45 +276,28 @@ export default function ViewsOverlay({
|
||||
} finally {
|
||||
setFilteredNodesLoading(false);
|
||||
}
|
||||
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||
}, [sortOrder, customOrder, applyProcessedFilter]);
|
||||
|
||||
// Fetch contexts on mount
|
||||
useEffect(() => {
|
||||
fetchContexts();
|
||||
}, [fetchContexts]);
|
||||
|
||||
// Fetch nodes on mount and when sort/refreshToken/context filter change
|
||||
// Fetch nodes on mount and when sort/refreshToken change
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
|
||||
}
|
||||
fetchAllNodes();
|
||||
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]);
|
||||
|
||||
// Refresh contexts when data changes
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
fetchContexts();
|
||||
}
|
||||
}, [refreshToken, fetchContexts]);
|
||||
}, [fetchAllNodes, refreshToken, sortOrder]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
const contextPickerRef = useRef<HTMLDivElement>(null);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [showContextPicker, setShowContextPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowContextPicker(false);
|
||||
}
|
||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowSortDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showContextPicker, showSortDropdown]);
|
||||
}, [showSortDropdown]);
|
||||
|
||||
// Reorder handlers
|
||||
const handleReorderDrop = useCallback((dropIdx: number) => {
|
||||
@@ -615,107 +573,7 @@ export default function ViewsOverlay({
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
|
||||
{externalContextFilterId ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
padding: '3px 8px',
|
||||
background: 'rgba(34, 197, 94, 0.06)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
borderRadius: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#5a9'
|
||||
}}
|
||||
>
|
||||
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
|
||||
<button
|
||||
onClick={() => onClearExternalContextFilter?.()}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#5a9',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ position: 'relative' }} ref={contextPickerRef}>
|
||||
<button
|
||||
onClick={() => setShowContextPicker(!showContextPicker)}
|
||||
title="Context filter"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<Filter size={11} />
|
||||
Context
|
||||
</button>
|
||||
|
||||
{showContextPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '10px',
|
||||
padding: '6px',
|
||||
minWidth: '220px',
|
||||
maxHeight: '320px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClearExternalContextFilter?.();
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
All contexts
|
||||
</button>
|
||||
{contextsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading contexts...
|
||||
</div>
|
||||
) : contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(context.id, context.name);
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
<span>{context.name}</span>
|
||||
<span style={pickerCountStyle}>{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }} />
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Use for structured review, QA, cleanup, or governance checks acros
|
||||
|
||||
1. Node quality: duplicates, vague descriptions, missing dates, weak titles.
|
||||
2. Edge quality: missing links, weak explanations, wrong directionality.
|
||||
3. Context quality: drift, overlap, low-signal buckets, or contexts being overused where stronger node metadata should carry the meaning.
|
||||
3. Graph structure quality: weak clustering, redundant organizational nodes, or cases where stronger titles, descriptions, metadata, and edges should carry the meaning.
|
||||
4. Skill quality: trigger clarity, overlap, dead/unused skills.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -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 nodes, changed priorities, and explicit deltas. Contexts are secondary when clearly useful."
|
||||
success_criteria: "Graph reflects current reality: updated nodes, 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 the strongest active nodes first and use contexts only as a secondary check when they already exist and are clearly relevant.
|
||||
1. Review the strongest active nodes first.
|
||||
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.
|
||||
|
||||
@@ -13,7 +13,7 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
4. Search before create to avoid duplicates.
|
||||
5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
|
||||
6. Use event dates when known (when it happened, not when saved).
|
||||
7. Leave context blank by default. Only apply context when the user explicitly wants it or when one obvious existing context is clearly useful. One node gets at most one primary context, and leaving context blank is valid.
|
||||
7. Work graph-first. Do not rely on a separate context layer for ordinary reads or writes.
|
||||
8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms.
|
||||
9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
|
||||
|
||||
@@ -24,9 +24,6 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
- `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 idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
|
||||
- `link`: external source URL only.
|
||||
- Normal writes should omit context entirely unless the user explicitly wants one.
|
||||
- If context is intentionally provided, prefer `context_name`.
|
||||
- Treat numeric `context_id` as an internal implementation detail, not a normal agent-facing field.
|
||||
- `metadata`: use the canonical node metadata contract when metadata is needed:
|
||||
- `type`
|
||||
- `state` (`processed` or `not_processed`)
|
||||
@@ -48,7 +45,7 @@ It must still make three things clear:
|
||||
2. Why — why it is in the graph; what Brad is interested in; what it connects to
|
||||
3. Status — where it sits in his workflow (queued, in progress, processed, unknown)
|
||||
|
||||
If the agent has graph context (context capsule, focused nodes, recent connected nodes, or an explicit active context), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal.
|
||||
If the agent has graph context (focused nodes, recent connected nodes, or retrieved supporting nodes), it should infer the why from that context and write it naturally. Do not let the service auto-generate a weak context-free description when you already have enough signal.
|
||||
|
||||
If the why genuinely cannot be inferred, say that naturally. Do not use labels like `WHAT:`, `WHY:`, or `STATUS:` and do not substitute vague filler like `insightful for understanding` or `relevant to Brad's work`.
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: Node Context Enrichment
|
||||
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with context review and edge suggestions."
|
||||
description: "Use to rewrite thin node descriptions into natural prose that still makes what, why, and status clear, with graph review and edge suggestions."
|
||||
---
|
||||
|
||||
# Node Context Enrichment
|
||||
|
||||
Use this when a node already exists but its description is thin, generic, or missing personal context.
|
||||
Use this when a node already exists but its description is thin, generic, or missing useful graph framing.
|
||||
|
||||
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.
|
||||
This skill should not silently rewrite and move on when framing is inferred. If the enrichment depends on interpretation, update the node and then explicitly invite the user to correct or refine that framing.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -17,21 +17,19 @@ Replace weak descriptions with a single clean natural description that captures:
|
||||
2. Why it is in Brad's graph
|
||||
3. Status in Brad's workflow
|
||||
|
||||
Also review whether the node needs a clearer context assignment or obvious edge suggestions.
|
||||
Also review whether the node needs obvious edge suggestions.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Load the node and inspect title, description, source, link, metadata, context, and nearby edges.
|
||||
2. Search for adjacent context before rewriting:
|
||||
- the node's primary context and its anchor node when present
|
||||
1. Load the node and inspect title, description, source, link, metadata, and nearby edges.
|
||||
2. Search for adjacent graph context before rewriting:
|
||||
- recently connected project or belief nodes
|
||||
- related nodes with overlapping titles, creators, or source themes
|
||||
3. Infer the best available "why" from that context.
|
||||
3. Infer the best available "why" from that graph 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 whether the current context is actually useful. Keep it, clear it, or suggest a better explicit context only when confidence is high.
|
||||
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:
|
||||
5. Suggest 1-3 high-signal edges when obvious.
|
||||
6. Update the node once the description is strong enough to be useful.
|
||||
7. 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
|
||||
@@ -49,7 +47,7 @@ Every rewritten description must naturally cover:
|
||||
2. Why
|
||||
- why Brad saved it
|
||||
- what project, belief, question, or theme it connects to
|
||||
- if genuinely unknown, say that naturally without inventing context
|
||||
- if genuinely unknown, say that naturally without inventing graph framing
|
||||
3. Status
|
||||
- queued, in progress, processed, not yet reviewed, saved for later, etc.
|
||||
- if unknown, say naturally that it has not been reviewed yet
|
||||
@@ -68,14 +66,13 @@ Use batch enrichment when cleaning up many nodes with the same failure mode.
|
||||
3. Enrich each node individually. Do not reuse boilerplate "why" text across unrelated nodes.
|
||||
4. Return a compact summary of:
|
||||
- nodes updated
|
||||
- contexts to review
|
||||
- edge suggestions not yet created
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- No filler phrases like `insightful for understanding`, `relevant to`, or `important for`.
|
||||
- No generic summaries that only restate the topic.
|
||||
- No invented certainty. If context is weak, say so explicitly.
|
||||
- No invented certainty. If graph evidence is weak, say so explicitly.
|
||||
- Prefer one compact 3-sentence description over bloated prose.
|
||||
|
||||
## Output Pattern
|
||||
@@ -83,6 +80,6 @@ Use batch enrichment when cleaning up many nodes with the same failure mode.
|
||||
For each node:
|
||||
|
||||
- New description
|
||||
- Context recommendation: keep / clear / set
|
||||
- Framing note: what graph context influenced the rewrite, if any
|
||||
- Edge suggestions: source -> target with explicit explanation
|
||||
- One short invitation for user feedback when contextual framing was inferred
|
||||
- One short invitation for user feedback when framing was inferred
|
||||
|
||||
@@ -7,7 +7,7 @@ description: "Use for new-user setup, empty or near-empty graphs, or major reset
|
||||
|
||||
## Your Job
|
||||
|
||||
Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and bootstrap the context capsule when durable cross-session facts become clear.
|
||||
Three things: help the user understand the basic structure of the system, help them start building useful graph data in it, and turn early useful context into strong nodes and edges.
|
||||
|
||||
Adapt to the user.
|
||||
|
||||
@@ -30,28 +30,6 @@ Only bring up setup details if the user actually needs them:
|
||||
- **Anthropic** — mainly relevant for compatible runtime paths and local/dev setups.
|
||||
4. If they are not ready to configure anything yet, keep onboarding. They can still learn the structure and add manual content.
|
||||
|
||||
## Capsule Bootstrap
|
||||
|
||||
As part of onboarding, also bootstrap the context capsule on the user's behalf.
|
||||
|
||||
When the user gives durable identity, preference, worldview, project, or agent-behavior information:
|
||||
|
||||
1. Call `readSkill('context-capsule')`
|
||||
2. Call `readCapsule`
|
||||
3. When you have enough confidence, call `writeCapsule`
|
||||
|
||||
Use the capsule for:
|
||||
- preferred name
|
||||
- agent name or greeting preference
|
||||
- interaction style
|
||||
- major projects
|
||||
- major interests
|
||||
- explicit worldview or belief signals
|
||||
- what the user is using RA-H for
|
||||
|
||||
Do not write the capsule for tentative, one-off, or low-confidence statements.
|
||||
Keep it compressed and canonical. Replace stale instructions instead of preserving contradictory history.
|
||||
|
||||
## Explain the System First
|
||||
|
||||
Before asking anything, orient the user. Be direct, not salesy:
|
||||
@@ -60,7 +38,6 @@ Before asking anything, orient the user. Be direct, not salesy:
|
||||
|
||||
Explain the structure in simple terms:
|
||||
|
||||
- **Contexts** — an optional soft organization layer. These are broad areas like health, job, life, or research. A node can belong to one context when it is explicit and useful, but context is not required.
|
||||
- **Nodes** — individual things. A project, idea, person, source, belief, decision, or topic. Each node must have a clear description of what it is and why it matters.
|
||||
- **Edges** — explicit connections between things. Each edge must clearly explain the relationship.
|
||||
- **Metadata and edges** — secondary structure that makes nodes more useful once the core artifact is clear.
|
||||
@@ -71,7 +48,7 @@ Then say:
|
||||
|
||||
Also explain one practical thing early:
|
||||
|
||||
> "You do not need to perfectly design this up front. We want a few concrete nodes, clear contexts when they matter, and a few clean edges so the graph becomes useful quickly."
|
||||
> "You do not need to perfectly design this up front. We want a few concrete nodes and a few clean edges so the graph becomes useful quickly."
|
||||
|
||||
## Interview Flow
|
||||
|
||||
@@ -101,7 +78,6 @@ Keep it conversational. Use these buckets and adapt based on what the user gives
|
||||
Work these in naturally when they are relevant:
|
||||
|
||||
- **First node creation** — explain that a node is one concrete thing worth keeping: a project, source, person, belief, decision, or idea.
|
||||
- **Contexts** — explain that contexts are optional helpers, not required setup. If the user has none, that is fine.
|
||||
- **MCP connection** — if the user mentions Claude Code or external agents, offer a quick setup path and point them to the MCP docs/skill flow rather than reciting a giant config block immediately.
|
||||
- **What to do after setup** — once the graph has a few solid nodes, the next useful move is usually one of:
|
||||
- connect related nodes with explicit edges
|
||||
@@ -113,9 +89,9 @@ Work these in naturally when they are relevant:
|
||||
Do your best to build the graph as useful context emerges.
|
||||
|
||||
- Add nodes when the user mentions concrete things worth keeping.
|
||||
- Assign a context only when it is an obvious match to one of the user's existing contexts. Prefer leaving context empty over low-confidence guessing.
|
||||
- Surface likely edges when relationships are clear enough to explain well, but create them only after the user confirms.
|
||||
- Explain what you're adding in plain language so the user understands the structure as it develops.
|
||||
- During normal conversation outside explicit onboarding capture, do not keep asking to save every useful statement. Only suggest a save when the context is unusually durable and valuable, and keep the prompt brief.
|
||||
|
||||
When the graph is empty or nearly empty, bias toward creating a small, clean starter set rather than over-modeling everything.
|
||||
|
||||
@@ -125,22 +101,16 @@ Before writing anything, call `readSkill('db-operations')` for full quality stan
|
||||
|
||||
- Search before creating — avoid duplicates from day one
|
||||
- Every description must be concrete: what it IS and why it matters to them, not what it "explores" or "discusses"
|
||||
- Contexts are optional and should only be used for an obvious existing match; otherwise leave them empty
|
||||
- Every edge needs an explicit explanation sentence, and agent-driven edge creation should only happen after confirmation
|
||||
|
||||
## Propose Before Writing
|
||||
|
||||
When there is enough context, summarize the proposed structure before touching the database:
|
||||
|
||||
> "Here's what I'm planning to create: [list contexts with one-line rationale], [list starter nodes], [list key edges]. Does this look right? Anything to adjust?"
|
||||
> "Here's what I'm planning to create: [list starter nodes], [list key edges]. Does this look right? Anything to adjust?"
|
||||
|
||||
Write only after confirmation.
|
||||
|
||||
If onboarding also surfaced durable cross-session facts, include the capsule update in the proposal:
|
||||
|
||||
> "I also plan to bootstrap your context capsule with your name, interaction preferences, and current priorities so future chats start grounded. Sound right?"
|
||||
> "I also plan to bootstrap your context capsule with your name and interaction preferences so future chats start grounded. Sound right?"
|
||||
|
||||
For very early setup, include the first actionable next step too:
|
||||
|
||||
> "After this starter pass, the best next move will be [add a source / connect these nodes / capture another active project]."
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface QuickAddInput {
|
||||
rawInput: string;
|
||||
mode?: QuickAddMode;
|
||||
description?: string;
|
||||
contextId?: number | null;
|
||||
}
|
||||
|
||||
export interface QuickAddResult {
|
||||
@@ -200,7 +199,7 @@ function isCreateNodeResponse(value: unknown): value is CreateNodeResponse {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string, contextId?: number | null): Promise<string> {
|
||||
async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: string, task: string): Promise<string> {
|
||||
const { toolName, execute } = EXTRACTION_TOOL_MAP[type];
|
||||
if (!execute) {
|
||||
throw new Error(`Tool ${toolName} does not have an execute function`);
|
||||
@@ -269,7 +268,6 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
refined_at: capturedAt,
|
||||
},
|
||||
},
|
||||
context_id: contextId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -296,7 +294,7 @@ async function handleExtractionQuickAdd(type: ExtractionQuickAddType, url: strin
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string, contextId?: number | null): Promise<string> {
|
||||
async function handleNoteQuickAdd(rawInput: string, task: string, userDescription?: string): Promise<string> {
|
||||
const content = rawInput.trim();
|
||||
if (!content) {
|
||||
throw new Error('Input is required to create a note');
|
||||
@@ -307,7 +305,6 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
const nodePayload: Record<string, unknown> = {
|
||||
title,
|
||||
source: content,
|
||||
context_id: contextId,
|
||||
metadata: {
|
||||
type: 'note',
|
||||
state: 'not_processed',
|
||||
@@ -357,7 +354,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChatTranscriptQuickAdd(rawInput: string, task: string, contextId?: number | null): Promise<string> {
|
||||
async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Promise<string> {
|
||||
const transcript = rawInput.trim();
|
||||
if (!transcript) {
|
||||
throw new Error('Input is required to import a chat transcript');
|
||||
@@ -424,7 +421,6 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
|
||||
title,
|
||||
description: nodeDescription,
|
||||
source: transcript,
|
||||
context_id: contextId,
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
@@ -451,7 +447,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string, cont
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueQuickAdd({ rawInput, mode, description, contextId }: QuickAddInput): Promise<QuickAddResult> {
|
||||
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
|
||||
const inputType = detectInputType(rawInput, mode);
|
||||
const task = buildTaskPrompt(inputType, rawInput);
|
||||
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -468,11 +464,11 @@ export async function enqueueQuickAdd({ rawInput, mode, description, contextId }
|
||||
try {
|
||||
let summary: string;
|
||||
if (inputType === 'note') {
|
||||
summary = await handleNoteQuickAdd(rawInput, task, description, contextId);
|
||||
summary = await handleNoteQuickAdd(rawInput, task, description);
|
||||
} else if (inputType === 'chat') {
|
||||
summary = await handleChatTranscriptQuickAdd(rawInput, task, contextId);
|
||||
summary = await handleChatTranscriptQuickAdd(rawInput, task);
|
||||
} else {
|
||||
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task, contextId);
|
||||
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
|
||||
}
|
||||
|
||||
console.log(`[QuickAdd] Completed: ${task}`);
|
||||
|
||||
@@ -123,13 +123,6 @@ export function summarizeToolExecution(toolName: string, args: any, result: any)
|
||||
return 'No edges found.';
|
||||
}
|
||||
|
||||
if (toolName === 'writeContext') {
|
||||
const formatted = ensureString(result.data?.formatted_display);
|
||||
if (formatted) {
|
||||
return `Saved context as ${formatted}.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.data?.formatted_display) {
|
||||
return ensureString(result.data.formatted_display) || fallback;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function extractBearerToken(headerValue: string | null | undefined): string | null {
|
||||
if (!headerValue) return null;
|
||||
const parts = headerValue.split(' ');
|
||||
if (parts.length !== 2 || !/^Bearer$/i.test(parts[0] || '')) {
|
||||
return null;
|
||||
}
|
||||
return parts[1] || null;
|
||||
}
|
||||
|
||||
export function getCurrentSupabaseToken(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInternalAuthHeaders(
|
||||
headers: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
return { ...headers };
|
||||
}
|
||||
|
||||
export function applyRequestSupabaseAuth(_request: NextRequest): () => void {
|
||||
return () => {};
|
||||
}
|
||||
@@ -8,23 +8,6 @@ export interface AutoContextSummary {
|
||||
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;
|
||||
@@ -67,128 +50,11 @@ function fetchAutoContextRows(limit: number): AutoContextSummary[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function getHubNodes(limit = 5): AutoContextSummary[] {
|
||||
export function getHubNodes(limit = 10): AutoContextSummary[] {
|
||||
return fetchAutoContextRows(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',
|
||||
'Contexts are optional soft hints. Use them when they are explicit and useful, but rely primarily on title, description, source, edges, and recency.',
|
||||
'',
|
||||
];
|
||||
|
||||
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 only as an optional waypoint when that context is already clearly relevant.',
|
||||
'',
|
||||
];
|
||||
|
||||
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 {
|
||||
export function buildHubNodesBlock(limit = 10): string | null {
|
||||
const summaries = getHubNodes(limit);
|
||||
if (summaries.length === 0) {
|
||||
return null;
|
||||
@@ -210,10 +76,10 @@ export function buildHubNodesBlock(limit = 5): string | null {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function getAutoContextSummaries(limit = 5): AutoContextSummary[] {
|
||||
export function getAutoContextSummaries(limit = 10): AutoContextSummary[] {
|
||||
return getHubNodes(limit);
|
||||
}
|
||||
|
||||
export function buildAutoContextBlock(limit = 5): string | null {
|
||||
return buildContextsBlock(limit);
|
||||
export function buildAutoContextBlock(limit = 10): string | null {
|
||||
return buildHubNodesBlock(limit);
|
||||
}
|
||||
|
||||
@@ -463,7 +463,6 @@ export class ChunkService {
|
||||
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
sqlite.disableFtsTable('chunks', 'chunks_fts query failed during chunk search', error);
|
||||
console.warn('[ChunkSearch] FTS chunk search failed, falling back to LIKE:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { getSQLiteClient } from './sqlite-client';
|
||||
import type { Context, ContextSummary, Node } from '@/types/database';
|
||||
import { nodeService } from './nodes';
|
||||
|
||||
type ContextRow = Context;
|
||||
export const MAX_CONTEXTS_PER_ACCOUNT = 10;
|
||||
|
||||
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 contextCountRow = sqlite.query<{ count: number }>(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM contexts
|
||||
`).rows[0];
|
||||
const existingCount = Number(contextCountRow?.count ?? 0);
|
||||
if (existingCount >= MAX_CONTEXTS_PER_ACCOUNT) {
|
||||
throw new Error(`Context limit reached. Maximum ${MAX_CONTEXTS_PER_ACCOUNT} contexts are allowed per account.`);
|
||||
}
|
||||
|
||||
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') &&
|
||||
input.context_id !== undefined;
|
||||
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();
|
||||
@@ -4,7 +4,6 @@ import type { DatabaseIntegrityReport } from './sqlite-client';
|
||||
export { nodeService, NodeService } from './nodes';
|
||||
export { chunkService, ChunkService } from './chunks';
|
||||
export { edgeService, EdgeService } from './edges';
|
||||
export { contextService, ContextService } from './contextService';
|
||||
// export { HelperService } from './helpers'; // Removed - migrated to JSON-based service
|
||||
|
||||
// Types
|
||||
|
||||
@@ -2,12 +2,10 @@ import { getSQLiteClient } from './sqlite-client';
|
||||
import { Node, NodeFilters } from '@/types/database';
|
||||
import { eventBroadcaster } from '../events';
|
||||
import { EmbeddingService } from '@/services/embeddings';
|
||||
import { scoreNodeSearchMatch } from './searchRanking';
|
||||
import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking';
|
||||
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
|
||||
|
||||
type NodeRow = Node & {
|
||||
context_json: string | null;
|
||||
};
|
||||
type NodeRow = Node;
|
||||
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
|
||||
|
||||
function sanitizeFtsQuery(input: string): string {
|
||||
@@ -21,35 +19,7 @@ function sanitizeFtsQuery(input: string): string {
|
||||
}
|
||||
|
||||
function extractRelaxedSearchTerms(query: string): string[] {
|
||||
const stopWords = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'find',
|
||||
'for', 'from', 'hello', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on',
|
||||
'or', 'recent', 'stuff', 'term', 'that', 'the', 'this', 'to', 'with', 'you'
|
||||
]);
|
||||
|
||||
const rawTerms = query
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(term => term.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const expanded = new Set<string>();
|
||||
|
||||
for (const term of rawTerms) {
|
||||
if (!stopWords.has(term) && term.length >= 3) {
|
||||
expanded.add(term);
|
||||
}
|
||||
|
||||
const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean);
|
||||
for (const part of alphaParts) {
|
||||
if (!stopWords.has(part) && part.length >= 3) {
|
||||
expanded.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(expanded).slice(0, 8);
|
||||
return getHighSignalSearchTerms(query).slice(0, 8);
|
||||
}
|
||||
|
||||
function reciprocalRankFuse<T extends { id: number }>(
|
||||
@@ -90,7 +60,6 @@ export class NodeService {
|
||||
eventAfter,
|
||||
eventBefore,
|
||||
chunkStatus,
|
||||
contextId,
|
||||
} = filters;
|
||||
|
||||
if (search?.trim()) {
|
||||
@@ -112,8 +81,6 @@ 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;
|
||||
}
|
||||
@@ -131,7 +98,6 @@ export class NodeService {
|
||||
eventAfter,
|
||||
eventBefore,
|
||||
chunkStatus,
|
||||
contextId,
|
||||
} = filters;
|
||||
|
||||
if (search?.trim()) {
|
||||
@@ -143,14 +109,9 @@ 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.context_id,
|
||||
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,
|
||||
n.created_at, n.updated_at,
|
||||
(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[] = [];
|
||||
@@ -182,11 +143,6 @@ 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) {
|
||||
// For search queries, prioritize by relevance: exact title → starts with → contains in title → description → source
|
||||
@@ -243,13 +199,8 @@ 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.context_id,
|
||||
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
|
||||
n.created_at, n.updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE n.id = ?
|
||||
`;
|
||||
const result = sqlite.query<NodeRow>(query, [id]);
|
||||
@@ -275,7 +226,6 @@ export class NodeService {
|
||||
event_date,
|
||||
chunk_status,
|
||||
metadata = {},
|
||||
context_id,
|
||||
} = nodeData;
|
||||
const canonicalMetadata = buildCanonicalNodeMetadata({ metadata });
|
||||
const now = new Date().toISOString();
|
||||
@@ -284,8 +234,8 @@ export class NodeService {
|
||||
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, context_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
title,
|
||||
description ?? null,
|
||||
@@ -294,7 +244,6 @@ export class NodeService {
|
||||
event_date ?? null,
|
||||
JSON.stringify(canonicalMetadata),
|
||||
chunk_status ?? null,
|
||||
context_id ?? null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
@@ -353,10 +302,6 @@ 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);
|
||||
@@ -419,11 +364,9 @@ export class NodeService {
|
||||
}
|
||||
|
||||
private mapNodeRow(row: NodeRow): Node {
|
||||
const { context_json, ...baseRow } = row;
|
||||
return {
|
||||
...baseRow,
|
||||
metadata: baseRow.metadata ? (typeof baseRow.metadata === 'string' ? JSON.parse(baseRow.metadata) : baseRow.metadata) : null,
|
||||
context: context_json ? JSON.parse(context_json) : null,
|
||||
...row,
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,7 +376,6 @@ export class NodeService {
|
||||
createdBefore,
|
||||
eventAfter,
|
||||
eventBefore,
|
||||
contextId,
|
||||
} = filters;
|
||||
|
||||
const clauses: string[] = [];
|
||||
@@ -443,8 +385,6 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -517,7 +457,8 @@ export class NodeService {
|
||||
|
||||
return Number(result.rows[0]?.total ?? 0);
|
||||
} catch (error) {
|
||||
sqlite.disableFtsTable('nodes', 'nodes_fts query failed during count search', error);
|
||||
sqlite.getIntegrityReport(true);
|
||||
console.warn('[NodeSearch] FTS count failed, falling back to LIKE count:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +487,7 @@ export class NodeService {
|
||||
): NodeSearchRow[] {
|
||||
const ftsQuery = sanitizeFtsQuery(search);
|
||||
if (!ftsQuery) return [];
|
||||
|
||||
if (!sqlite.canUseFtsTable('nodes')) return [];
|
||||
|
||||
const { clauses, params } = this.buildNodeFilterClauses(filters);
|
||||
@@ -561,15 +503,10 @@ export class NodeService {
|
||||
)
|
||||
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.context_id,
|
||||
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,
|
||||
n.created_at, n.updated_at,
|
||||
fm.rank
|
||||
FROM fts_matches fm
|
||||
JOIN nodes n ON n.id = fm.rowid
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
${whereClauses}
|
||||
ORDER BY fm.rank
|
||||
LIMIT ?
|
||||
@@ -577,7 +514,8 @@ export class NodeService {
|
||||
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
sqlite.disableFtsTable('nodes', 'nodes_fts query failed during node search', error);
|
||||
sqlite.getIntegrityReport(true);
|
||||
console.warn('[NodeSearch] FTS search failed, falling back to LIKE:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -593,13 +531,8 @@ 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.context_id,
|
||||
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
|
||||
n.created_at, n.updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams = [...params];
|
||||
@@ -641,13 +574,8 @@ 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.context_id,
|
||||
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
|
||||
n.created_at, n.updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams = [...params];
|
||||
@@ -718,15 +646,10 @@ export class NodeService {
|
||||
)
|
||||
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.context_id,
|
||||
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,
|
||||
n.created_at, n.updated_at,
|
||||
(1.0 / (1.0 + vm.distance)) AS similarity
|
||||
FROM vector_matches vm
|
||||
JOIN nodes n ON n.id = vm.node_id
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
${whereClauses}
|
||||
ORDER BY vm.distance
|
||||
LIMIT ?
|
||||
@@ -769,6 +692,14 @@ export class NodeService {
|
||||
return updatedNodes;
|
||||
}
|
||||
|
||||
async getAllDimensions(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDimensionStats(): Promise<{dimension: string, count: number}[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ export interface DatabaseEvent {
|
||||
| 'AGENT_DELEGATION_CREATED'
|
||||
| 'AGENT_DELEGATION_UPDATED'
|
||||
| 'GUIDE_UPDATED'
|
||||
| 'SKILL_UPDATED'
|
||||
| 'QUICK_ADD_COMPLETED'
|
||||
| 'QUICK_ADD_FAILED'
|
||||
| 'CONNECTION_ESTABLISHED';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { contextService } from '@/services/database/contextService';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { countHighSignalQueryTermMatches, getHighSignalSearchTerms, scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
import type { Node } from '@/types/database';
|
||||
@@ -6,8 +5,6 @@ import type { Node } from '@/types/database';
|
||||
export interface DirectNodeLookupInput {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
context_name?: string;
|
||||
contextId?: number;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
eventAfter?: string;
|
||||
@@ -20,7 +17,6 @@ export interface DirectNodeLookupResult {
|
||||
filtersApplied: {
|
||||
search?: string;
|
||||
limit: number;
|
||||
context_name?: string;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
eventAfter?: string;
|
||||
@@ -28,41 +24,6 @@ export interface DirectNodeLookupResult {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContextName(value: string | undefined): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
async function resolveSearchContext(input: DirectNodeLookupInput): Promise<{ contextId?: number; context_name?: string }> {
|
||||
const normalizedName = normalizeContextName(input.context_name);
|
||||
if (normalizedName) {
|
||||
const context = await contextService.getContextByName(normalizedName);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookup received unknown context_name "${normalizedName}"; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof input.contextId === 'number') {
|
||||
const context = await contextService.getContextById(input.contextId);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookup received invalid legacy contextId ${input.contextId}; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function hasStrongAnchorMatch(nodes: Node[], searchTerm: string): boolean {
|
||||
if (!searchTerm || nodes.length === 0) return false;
|
||||
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
|
||||
@@ -87,11 +48,9 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedContext = await resolveSearchContext(input);
|
||||
const effectiveFilters = {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
contextId: resolvedContext.contextId,
|
||||
searchMode: 'standard' as const,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
@@ -102,7 +61,6 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
|
||||
let safeNodes = await nodeService.getNodes(effectiveFilters);
|
||||
|
||||
const hadExtraFilters = Boolean(
|
||||
effectiveFilters.contextId !== undefined ||
|
||||
effectiveFilters.createdAfter ||
|
||||
effectiveFilters.createdBefore ||
|
||||
effectiveFilters.eventAfter ||
|
||||
@@ -130,7 +88,6 @@ export async function directNodeLookup(input: DirectNodeLookupInput): Promise<Di
|
||||
filtersApplied: {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
context_name: resolvedContext.context_name,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
eventAfter: input.eventAfter,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { chunkService } from '@/services/database/chunks';
|
||||
import { contextService } from '@/services/database/contextService';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { countHighSignalQueryTermMatches, scoreNodeSearchMatch } from '@/services/database/searchRanking';
|
||||
@@ -33,7 +32,6 @@ export interface QueryContextResult {
|
||||
mode: 'skip' | 'focused' | 'query';
|
||||
reason: string;
|
||||
focused_node_id: number | null;
|
||||
active_context_id: number | null;
|
||||
nodes: RetrievedContextNode[];
|
||||
chunks: RetrievedContextChunk[];
|
||||
}
|
||||
@@ -41,7 +39,6 @@ export interface QueryContextResult {
|
||||
export interface RetrieveQueryContextInput {
|
||||
query: string;
|
||||
focused_node_id?: number | null;
|
||||
active_context_id?: number | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -104,7 +101,7 @@ function truncateText(value: string | null | undefined, maxLength = 180): string
|
||||
function queryTermCount(query: string): number {
|
||||
return normalizeWhitespace(query)
|
||||
.split(' ')
|
||||
.map((term) => term.trim())
|
||||
.map(term => term.trim())
|
||||
.filter(Boolean)
|
||||
.length;
|
||||
}
|
||||
@@ -161,7 +158,7 @@ function extractHighSignalTerms(query: string): string[] {
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map((term) => singularizeTerm(term.trim()))
|
||||
.map(term => singularizeTerm(term.trim()))
|
||||
.filter(Boolean);
|
||||
|
||||
const seen = new Set<string>();
|
||||
@@ -184,8 +181,7 @@ function isLikelyUserNoteRecallQuery(query: string): boolean {
|
||||
|
||||
const explicitRecall = USER_RECALL_PATTERN.test(normalized);
|
||||
const firstPersonRecall = FIRST_PERSON_PATTERN.test(normalized) && /\bwhat\b/i.test(normalized);
|
||||
const explicitLookup = LOOKUP_PATTERN.test(normalized)
|
||||
&& (FIRST_PERSON_PATTERN.test(normalized) || /\b(created|saved|wrote|added)\b/i.test(normalized));
|
||||
const explicitLookup = LOOKUP_PATTERN.test(normalized) && (FIRST_PERSON_PATTERN.test(normalized) || /\b(created|saved|wrote|added)\b/i.test(normalized));
|
||||
const firstPersonShareRecall = FIRST_PERSON_SHARE_PATTERN.test(normalized);
|
||||
const noteHint = NOTE_HINT_PATTERN.test(normalized);
|
||||
const recentHint = RECENT_REFERENCE_PATTERN.test(normalized);
|
||||
@@ -211,8 +207,8 @@ function buildRecallSearchVariants(query: string): string[] {
|
||||
const terms = extractHighSignalTerms(query);
|
||||
if (terms.length === 0) return [];
|
||||
|
||||
const topicalTerms = terms.filter((term) => !NOTE_TERMS.has(term));
|
||||
const noteTerms = terms.filter((term) => NOTE_TERMS.has(term));
|
||||
const topicalTerms = terms.filter(term => !NOTE_TERMS.has(term));
|
||||
const noteTerms = terms.filter(term => NOTE_TERMS.has(term));
|
||||
const phraseVariants = extractRecallPhraseVariants(query);
|
||||
|
||||
const variants: string[] = [];
|
||||
@@ -281,8 +277,8 @@ function scoreRecallMatch(node: Node, query: string): number {
|
||||
if (normalizedSource.includes(normalizedPhrase)) score += 900;
|
||||
}
|
||||
|
||||
const titleTermMatches = terms.filter((term) => normalizedTitle.includes(term)).length;
|
||||
const totalTermMatches = terms.filter((term) => combined.includes(term)).length;
|
||||
const titleTermMatches = terms.filter(term => normalizedTitle.includes(term)).length;
|
||||
const totalTermMatches = terms.filter(term => combined.includes(term)).length;
|
||||
|
||||
score += titleTermMatches * 250;
|
||||
score += totalTermMatches * 120;
|
||||
@@ -298,7 +294,7 @@ function scoreRecallMatch(node: Node, query: string): number {
|
||||
}
|
||||
|
||||
function hasStrongRecallMatch(nodes: Node[], query: string): boolean {
|
||||
return nodes.some((node) => scoreRecallMatch(node, query) >= 1800);
|
||||
return nodes.some(node => scoreRecallMatch(node, query) >= 1800);
|
||||
}
|
||||
|
||||
function isLikelyUserAuthoredNote(node: Node): boolean {
|
||||
@@ -345,7 +341,7 @@ export function isFocusedSourceRequest(query: string): boolean {
|
||||
export function shouldRetrieveForQuery(query: string): boolean {
|
||||
const trimmed = normalizeWhitespace(query);
|
||||
if (!trimmed) return false;
|
||||
if (LOW_SIGNAL_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
|
||||
if (LOW_SIGNAL_PATTERNS.some(pattern => pattern.test(trimmed))) return false;
|
||||
|
||||
if (isFocusedSourceRequest(trimmed)) return true;
|
||||
if (SOURCE_DETAIL_PATTERN.test(trimmed)) return true;
|
||||
@@ -413,7 +409,6 @@ function rankRetrievedNodes(nodes: RetrievedContextNode[]): RetrievedContextNode
|
||||
export async function retrieveQueryContext(input: RetrieveQueryContextInput): Promise<QueryContextResult> {
|
||||
const query = normalizeWhitespace(input.query || '');
|
||||
const focusedNodeId = input.focused_node_id ?? null;
|
||||
const requestedActiveContextId = input.active_context_id ?? null;
|
||||
const limit = Math.min(Math.max(input.limit ?? 6, 1), 12);
|
||||
const shouldRetrieve = shouldRetrieveForQuery(query);
|
||||
|
||||
@@ -424,16 +419,11 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr
|
||||
mode: 'skip',
|
||||
reason: 'Query is too lightweight or conversational to justify retrieval.',
|
||||
focused_node_id: focusedNodeId,
|
||||
active_context_id: requestedActiveContextId,
|
||||
nodes: [],
|
||||
chunks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const activeContextId = requestedActiveContextId
|
||||
? (await contextService.getContextById(requestedActiveContextId))?.id ?? null
|
||||
: null;
|
||||
|
||||
const focusedRequest = isFocusedSourceRequest(query);
|
||||
const nodesById = new Map<number, RetrievedContextNode>();
|
||||
|
||||
@@ -470,19 +460,6 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr
|
||||
});
|
||||
});
|
||||
|
||||
if (activeContextId && !strongRecallMatch) {
|
||||
const contextMatches = query
|
||||
? await nodeService.getNodes({ search: query, contextId: activeContextId, limit: Math.max(limit, 4) })
|
||||
: [];
|
||||
contextMatches.forEach((node, index) => {
|
||||
addNodeWithReason(nodesById, node, {
|
||||
kind: 'context_hint',
|
||||
reason: 'Also matched inside the active context.',
|
||||
searchRank: directQueryMatches.length + index,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!strongRecallMatch) {
|
||||
const rankedSeedNodes = rankRetrievedNodes(Array.from(nodesById.values())).slice(0, Math.max(3, limit));
|
||||
for (const seed of rankedSeedNodes.slice(0, 3)) {
|
||||
@@ -518,7 +495,6 @@ export async function retrieveQueryContext(input: RetrieveQueryContextInput): Pr
|
||||
? 'Direct node retrieval query: search the graph directly first and broaden only if needed.'
|
||||
: 'Substantive query: search the graph directly, then pull additional supporting context if helpful.',
|
||||
focused_node_id: focusedNodeId,
|
||||
active_context_id: activeContextId,
|
||||
nodes: finalNodes,
|
||||
chunks: chunks.map((chunk) => {
|
||||
const owner = finalNodes.find((node) => node.id === chunk.node_id) || directQueryMatches.find((node) => node.id === chunk.node_id);
|
||||
|
||||
@@ -19,7 +19,6 @@ interface NodeRecord {
|
||||
title: string;
|
||||
source: string | null;
|
||||
description: string | null;
|
||||
context_name: string | null;
|
||||
embedding?: Buffer | null;
|
||||
embedding_updated_at?: string | null;
|
||||
embedding_text?: string | null;
|
||||
@@ -57,7 +56,6 @@ export class NodeEmbedder {
|
||||
|
||||
Title: ${node.title}
|
||||
Source: ${node.source || 'No source'}
|
||||
Context: ${node.context_name || 'none'}
|
||||
|
||||
Focus on the main concepts, key relationships, and practical implications.`;
|
||||
|
||||
@@ -103,7 +101,7 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
||||
node.title,
|
||||
node.source || '',
|
||||
node.description,
|
||||
node.context_name
|
||||
null
|
||||
);
|
||||
|
||||
// Add AI analysis if source exists
|
||||
@@ -173,29 +171,26 @@ Focus on the main concepts, key relationships, and practical implications.`;
|
||||
if (nodeId) {
|
||||
// Single node
|
||||
query = `
|
||||
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
|
||||
SELECT n.id, n.title, n.source, n.description,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE n.id = ?
|
||||
`;
|
||||
params = [nodeId];
|
||||
} else if (forceReEmbed) {
|
||||
// All nodes
|
||||
query = `
|
||||
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
|
||||
SELECT n.id, n.title, n.source, n.description,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
ORDER BY n.id
|
||||
`;
|
||||
} else {
|
||||
// Only nodes without embeddings
|
||||
query = `
|
||||
SELECT n.id, n.title, n.source, n.description, c.name as context_name,
|
||||
SELECT n.id, n.title, n.source, n.description,
|
||||
n.embedding, n.embedding_updated_at
|
||||
FROM nodes n
|
||||
LEFT JOIN contexts c ON c.id = n.context_id
|
||||
WHERE n.embedding IS NULL OR n.embedding_updated_at IS NULL
|
||||
ORDER BY n.id
|
||||
`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
|
||||
|
||||
function extractTextFromMessageContent(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
@@ -56,14 +57,13 @@ function inferSourceFromContext(params: { title: string; description?: string; s
|
||||
}
|
||||
|
||||
export const createNodeTool = tool({
|
||||
description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. Only set context when the user explicitly wants one and it is clear and useful; when that happens, use context_name rather than a numeric ID. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. 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.',
|
||||
description: 'Create a node after you have already decided this should be a net-new write. If the user explicitly asked to save or import something and duplicate/update checks are complete, write immediately. If you are only suggesting a save, propose the node first and wait for confirmation. Focus on a clean title, a strong natural description that says what the thing is, preserved source text, and the right metadata. 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(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_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'),
|
||||
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.')
|
||||
}).passthrough(),
|
||||
execute: async (params, context) => {
|
||||
@@ -74,12 +74,12 @@ export const createNodeTool = tool({
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ ...params, source: canonicalSource ?? params.source })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -88,6 +88,7 @@ export const createNodeTool = tool({
|
||||
};
|
||||
}
|
||||
|
||||
// Format the created node for chat display
|
||||
const formattedDisplay = formatNodeForChat({
|
||||
id: result.data.id,
|
||||
title: result.data.title,
|
||||
@@ -97,9 +98,9 @@ export const createNodeTool = tool({
|
||||
success: true,
|
||||
data: {
|
||||
...result.data,
|
||||
formatted_display: formattedDisplay,
|
||||
formatted_display: formattedDisplay
|
||||
},
|
||||
message: `Created: ${formattedDisplay}`
|
||||
message: `Created node ${formattedDisplay}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -111,4 +112,5 @@ export const createNodeTool = tool({
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const createItemTool = createNodeTool;
|
||||
|
||||
@@ -36,8 +36,6 @@ export const getNodesByIdTool = tool({
|
||||
title: node.title,
|
||||
link: node.link,
|
||||
event_date: node.event_date ?? null,
|
||||
context_id: node.context_id ?? null,
|
||||
context: node.context ?? null,
|
||||
chunk_status: node.chunk_status || 'unknown',
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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().describe('Exact context ID lookup.').optional(),
|
||||
name: z.string().describe('Exact context name lookup.').optional(),
|
||||
search: z.string().describe('Case-insensitive search across context names and descriptions.').optional(),
|
||||
limit: z.number().min(1).max(100).default(50).describe('Maximum number of contexts to return.').optional(),
|
||||
includeNodes: z.boolean().default(false).describe('Include the node list for an exact single-context lookup.').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 canIncludeNodes =
|
||||
filters.includeNodes === true &&
|
||||
limitedContexts.length === 1 &&
|
||||
(filters.id !== undefined || Boolean(normalizedName));
|
||||
|
||||
const contextsWithNodes = await Promise.all(
|
||||
limitedContexts.map(async (context) => {
|
||||
if (!canIncludeNodes) 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,
|
||||
context_id: node.context_id ?? null,
|
||||
context: node.context ?? null,
|
||||
updated_at: node.updated_at,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const message = contextsWithNodes.length === 0
|
||||
? 'No contexts found.'
|
||||
: `Found ${contextsWithNodes.length} context${contextsWithNodes.length === 1 ? '' : 's'}:\n${contextsWithNodes.map((context) => `• ${context.name} (#${context.id}, ${context.count} nodes)`).join('\n')}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
contexts: contextsWithNodes,
|
||||
count: contextsWithNodes.length,
|
||||
total_available: contexts.length,
|
||||
filters_applied: filters,
|
||||
},
|
||||
message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('QueryContexts tool error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to query contexts',
|
||||
data: {
|
||||
contexts: [],
|
||||
count: 0,
|
||||
filters_applied: filters,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -84,7 +84,6 @@ export const queryEdgeTool = tool({
|
||||
id: connection.connected_node.id,
|
||||
title: connection.connected_node.title,
|
||||
description: truncateText(connection.connected_node.description, 140),
|
||||
context: connection.connected_node.context ?? null,
|
||||
formatted_display: formattedNode
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import { directNodeLookup } from '@/services/retrieval/directNodeLookup';
|
||||
|
||||
type QueryNodeFilters = {
|
||||
contextId?: number;
|
||||
context_name?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
createdAfter?: string;
|
||||
@@ -15,10 +13,9 @@ type QueryNodeFilters = {
|
||||
};
|
||||
|
||||
export const queryNodesTool = tool({
|
||||
description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead. Leave context blank by default. If the user explicitly wants a context filter, use context_name rather than a numeric ID.',
|
||||
description: 'Find specific existing nodes in the graph by searching title, description, and source. Use this first when the user is trying to locate a node they already created or a specific existing podcast, article, idea, person, project, or note. For broader current-turn grounding of a substantive question, use retrieveQueryContext instead.',
|
||||
inputSchema: z.object({
|
||||
filters: z.object({
|
||||
context_name: z.string().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.').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.'),
|
||||
@@ -33,8 +30,6 @@ export const queryNodesTool = tool({
|
||||
const result = await directNodeLookup({
|
||||
search: filters.search,
|
||||
limit: filters.limit,
|
||||
context_name: filters.context_name,
|
||||
contextId: filters.contextId,
|
||||
createdAfter: filters.createdAfter,
|
||||
createdBefore: filters.createdBefore,
|
||||
eventAfter: filters.eventAfter,
|
||||
|
||||
@@ -7,15 +7,13 @@ export const retrieveQueryContextTool = tool({
|
||||
inputSchema: z.object({
|
||||
query: z.string().min(1).describe('The raw user query for this turn.'),
|
||||
focused_node_id: z.number().int().positive().nullable().optional().describe('Optional currently focused node ID.'),
|
||||
active_context_id: z.number().int().positive().nullable().optional().describe('Optional active context ID as a soft hint.'),
|
||||
limit: z.number().int().min(1).max(12).optional().describe('Maximum number of nodes to return.'),
|
||||
}),
|
||||
execute: async ({ query, focused_node_id, active_context_id, limit }) => {
|
||||
execute: async ({ query, focused_node_id, limit }) => {
|
||||
try {
|
||||
const result = await retrieveQueryContext({
|
||||
query,
|
||||
focused_node_id: focused_node_id ?? null,
|
||||
active_context_id: active_context_id ?? null,
|
||||
limit,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { getInternalAuthHeaders } from '@/services/auth/internalAuth';
|
||||
|
||||
export const updateNodeTool = tool({
|
||||
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. Context is preserved unless the user explicitly wants it changed. When that happens, prefer context_name rather than a numeric ID. Use clear_context only when the user explicitly wants the context removed. 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.',
|
||||
description: 'Update node fields when the existing node is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates should proceed once the target node is clear; if you are only proposing a change, ask first. Use this to enrich or correct nodes without losing canonical source content. 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({
|
||||
@@ -12,8 +13,6 @@ export const updateNodeTool = tool({
|
||||
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.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'),
|
||||
clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'),
|
||||
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.')
|
||||
}).passthrough().describe('Object containing the fields to update. Derived analysis should be stored in a separate linked node, not appended to the source node.')
|
||||
}),
|
||||
@@ -30,12 +29,12 @@ export const updateNodeTool = tool({
|
||||
// Call the nodes API endpoint
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: getInternalAuthHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getInternalApiBaseUrl } from '@/services/runtime/apiBase';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
|
||||
export const writeContextTool = tool({
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture. Never call it unless the user has clearly said yes.',
|
||||
inputSchema: z.object({
|
||||
title: z.string().min(1).max(160).describe('Clear proposed node title.'),
|
||||
description: z.string().min(1).max(500).describe('Natural description of what this context is and why it matters.'),
|
||||
source: z.string().optional().describe('Optional source or verbatim user wording to preserve.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata patch.'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Reject the write otherwise.'),
|
||||
}).passthrough(),
|
||||
execute: async ({ title, description, source, context_name, metadata, confirmed_by_user, ...legacyParams }) => {
|
||||
if (!confirmed_by_user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'writeContext requires explicit user confirmation before writing to the graph.',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
source: source?.trim() || undefined,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
context_id: typeof legacyParams.context_id === 'number' || legacyParams.context_id === null
|
||||
? legacyParams.context_id
|
||||
: undefined,
|
||||
metadata: {
|
||||
captured_by: 'human',
|
||||
captured_method: 'write_context',
|
||||
...(metadata || {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Failed to write context node',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const formattedDisplay = formatNodeForChat({
|
||||
id: result.data.id,
|
||||
title: result.data.title,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...result.data,
|
||||
formatted_display: formattedDisplay,
|
||||
},
|
||||
message: `Saved context as ${formattedDisplay}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to write context node',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -38,8 +38,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
retrieveQueryContext: 'core',
|
||||
getNodesById: 'core',
|
||||
queryEdge: 'core',
|
||||
queryContexts: 'core',
|
||||
searchContentEmbeddings: 'core',
|
||||
listSkills: 'core',
|
||||
readSkill: 'core',
|
||||
|
||||
// Orchestration: Web search and reasoning (orchestrator only)
|
||||
webSearch: 'orchestration',
|
||||
@@ -47,7 +48,6 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
|
||||
// Execution: Write operations and extraction (workers only)
|
||||
createNode: 'execution',
|
||||
writeContext: 'execution',
|
||||
updateNode: 'execution',
|
||||
deleteNode: 'execution',
|
||||
createEdge: 'execution',
|
||||
@@ -56,6 +56,8 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
youtubeExtract: 'execution',
|
||||
websiteExtract: 'execution',
|
||||
paperExtract: 'execution',
|
||||
writeSkill: 'execution',
|
||||
deleteSkill: 'execution'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,8 @@ import { queryNodesTool } from '../database/queryNodes';
|
||||
import { retrieveQueryContextTool } from '../database/retrieveQueryContext';
|
||||
import { getNodesByIdTool } from '../database/getNodesById';
|
||||
import { queryEdgeTool } from '../database/queryEdge';
|
||||
import { queryContextsTool } from '../database/queryContexts';
|
||||
import { createNodeTool } from '../database/createNode';
|
||||
import { updateNodeTool } from '../database/updateNode';
|
||||
import { writeContextTool } from '../database/writeContext';
|
||||
import { deleteNodeTool } from '../database/deleteNode';
|
||||
import { createEdgeTool } from '../database/createEdge';
|
||||
import { updateEdgeTool } from '../database/updateEdge';
|
||||
@@ -17,17 +15,22 @@ import { youtubeExtractTool } from '../other/youtubeExtract';
|
||||
import { websiteExtractTool } from '../other/websiteExtract';
|
||||
import { paperExtractTool } from '../other/paperExtract';
|
||||
import { sqliteQueryTool } from '../other/sqliteQuery';
|
||||
import { listSkillsTool } from '../skills/listSkills';
|
||||
import { readSkillTool } from '../skills/readSkill';
|
||||
import { writeSkillTool } from '../skills/writeSkill';
|
||||
import { deleteSkillTool } from '../skills/deleteSkill';
|
||||
import { logEvalToolCall } from '@/services/evals/evalsLogger';
|
||||
|
||||
// Read tools (graph queries)
|
||||
// Read tools (graph queries + skills)
|
||||
const CORE_TOOLS: Record<string, any> = {
|
||||
sqliteQuery: sqliteQueryTool,
|
||||
queryNodes: queryNodesTool,
|
||||
retrieveQueryContext: retrieveQueryContextTool,
|
||||
getNodesById: getNodesByIdTool,
|
||||
queryEdge: queryEdgeTool,
|
||||
queryContexts: queryContextsTool,
|
||||
searchContentEmbeddings: searchContentEmbeddingsTool,
|
||||
listSkills: listSkillsTool,
|
||||
readSkill: readSkillTool,
|
||||
};
|
||||
|
||||
// Utility tools
|
||||
@@ -39,7 +42,6 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
|
||||
// Write tools (includes extraction)
|
||||
const EXECUTION_TOOLS: Record<string, any> = {
|
||||
createNode: createNodeTool,
|
||||
writeContext: writeContextTool,
|
||||
updateNode: updateNodeTool,
|
||||
deleteNode: deleteNodeTool,
|
||||
createEdge: createEdgeTool,
|
||||
@@ -47,6 +49,8 @@ const EXECUTION_TOOLS: Record<string, any> = {
|
||||
youtubeExtract: youtubeExtractTool,
|
||||
websiteExtract: websiteExtractTool,
|
||||
paperExtract: paperExtractTool,
|
||||
writeSkill: writeSkillTool,
|
||||
deleteSkill: deleteSkillTool,
|
||||
};
|
||||
|
||||
export const TOOL_SETS = {
|
||||
|
||||
@@ -45,7 +45,7 @@ function isReadOnlyQuery(sql: string): boolean {
|
||||
}
|
||||
|
||||
export const sqliteQueryTool = tool({
|
||||
description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, contexts, edges, chunks, logs, and migration snapshots. Use PRAGMA table_info(tablename) for schema.',
|
||||
description: 'Execute read-only SQL queries (SELECT/WITH/PRAGMA). Tables: nodes, edges, chunks, logs, chats, voice_usage, and migration snapshots. Use PRAGMA table_info(tablename) for schema.',
|
||||
|
||||
inputSchema: z.object({
|
||||
sql: z.string().describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { deleteSkill } from '@/services/skills/skillService';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
|
||||
export const deleteSkillTool = tool({
|
||||
description: 'Delete a skill by name.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('The name of the skill to delete'),
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
try {
|
||||
const result = deleteSkill(name);
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { name },
|
||||
message: `Skill "${name}" deleted`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete skill',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { listSkills } from '@/services/skills/skillService';
|
||||
|
||||
export const listSkillsTool = tool({
|
||||
description: 'List all available skills with their names and descriptions.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
const skills = listSkills();
|
||||
return {
|
||||
success: true,
|
||||
data: skills,
|
||||
message: `Found ${skills.length} skills`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list skills',
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { readSkill } from '@/services/skills/skillService';
|
||||
|
||||
export const readSkillTool = tool({
|
||||
description: 'Read a skill by name. Returns the full markdown content with instructions.',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('The name of the skill to read'),
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
try {
|
||||
const skill = readSkill(name);
|
||||
if (!skill) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Skill "${name}" not found`,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: skill,
|
||||
message: `Loaded skill: ${skill.name}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read skill',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { writeSkill } from '@/services/skills/skillService';
|
||||
import { eventBroadcaster } from '@/services/events';
|
||||
|
||||
export const writeSkillTool = tool({
|
||||
description: 'Write or update a skill. Content should be full markdown with YAML frontmatter (name, description).',
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('The name of the skill to write'),
|
||||
content: z.string().describe('Full markdown content including YAML frontmatter'),
|
||||
}),
|
||||
execute: async ({ name, content }) => {
|
||||
try {
|
||||
const result = writeSkill(name, content);
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { name },
|
||||
message: `Skill "${name}" saved`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to write skill',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+8
-20
@@ -10,23 +10,6 @@ export interface CanonicalNodeMetadata {
|
||||
[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;
|
||||
@@ -42,8 +25,6 @@ export interface Node {
|
||||
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;
|
||||
@@ -96,7 +77,6 @@ export interface EdgeContext {
|
||||
|
||||
// New NodeFilters interface replacing rigid ItemFilters
|
||||
export interface NodeFilters {
|
||||
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';
|
||||
@@ -148,3 +128,11 @@ export interface DatabaseError {
|
||||
code?: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export interface Dimension {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
is_priority: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user