sync: Workflows P2 features from private repo
- 5 bundled workflows (prep, research, connect, integrate, survey) - quickLink tool for fast edge creation - queryDimensionNodes tool for dimension queries - Workflow orchestration tools (list, get, edit) - Dimension awareness with active dimension tracking - Unified Workflows tab in Agent Panel - MCP server updates for workflow execution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
3951d9daf4
commit
f9271aeeb4
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export interface DimensionContext {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isPriority: boolean;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
const db = getSQLiteClient();
|
||||
|
||||
// Get dimension metadata
|
||||
const dimension = db.query<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_priority: number;
|
||||
}>(
|
||||
'SELECT name, description, is_priority FROM dimensions WHERE name = ?',
|
||||
[decodedName]
|
||||
).rows[0];
|
||||
|
||||
if (!dimension) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Dimension not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Count nodes in this dimension (via node_dimensions join table)
|
||||
const countResult = db.query<{ count: number }>(
|
||||
`SELECT COUNT(DISTINCT node_id) as count FROM node_dimensions WHERE dimension = ?`,
|
||||
[decodedName]
|
||||
).rows[0];
|
||||
|
||||
const context: DimensionContext = {
|
||||
name: dimension.name,
|
||||
description: dimension.description,
|
||||
isPriority: dimension.is_priority === 1,
|
||||
nodeCount: countResult?.count || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: context,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension context:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch dimension context' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
import { AgentDelegationService } from '@/services/agents/delegation';
|
||||
import { WiseRAHExecutor } from '@/services/agents/wiseRAHExecutor';
|
||||
import { getAutoContextSummaries } from '@/services/context/autoContext';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { workflowKey, nodeId, userContext } = body;
|
||||
|
||||
// Validate workflowKey
|
||||
if (!workflowKey || typeof workflowKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'workflowKey is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch workflow definition
|
||||
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Workflow '${workflowKey}' not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflow.enabled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Workflow '${workflowKey}' is disabled` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate node requirement
|
||||
if (workflow.requiresFocusedNode && !nodeId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Workflow '${workflowKey}' requires a nodeId` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent re-running same workflow on same node within 1 hour
|
||||
if (nodeId) {
|
||||
const db = getSQLiteClient();
|
||||
const recentRuns = db.query<{ id: number }>(
|
||||
`SELECT id FROM chats
|
||||
WHERE json_extract(metadata, '$.workflow_key') = ?
|
||||
AND json_extract(metadata, '$.workflow_node_id') = ?
|
||||
AND datetime(created_at) > datetime('now', '-1 hour')
|
||||
LIMIT 1`,
|
||||
[workflowKey, nodeId]
|
||||
).rows;
|
||||
|
||||
if (recentRuns.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' already ran on node ${nodeId} within the last hour`
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build context
|
||||
let contextLines: string[] = [];
|
||||
if (nodeId) {
|
||||
const db = getSQLiteClient();
|
||||
const stmt = db.prepare('SELECT * FROM nodes WHERE id = ?');
|
||||
const node = stmt.get(nodeId) as any;
|
||||
if (!node) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Node ${nodeId} not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
contextLines = [
|
||||
`Focused Node: [NODE:${node.id}:"${node.title}"]`,
|
||||
node.description ? `Description: ${node.description}` : null,
|
||||
node.content ? `Content: ${node.content}` : null,
|
||||
node.link ? `Link: ${node.link}` : null,
|
||||
].filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
if (userContext) {
|
||||
contextLines.push(`User Context: ${userContext}`);
|
||||
}
|
||||
|
||||
// Add auto-context summaries
|
||||
const autoContextSummaries = getAutoContextSummaries(6);
|
||||
if (autoContextSummaries.length > 0) {
|
||||
contextLines.push('Background Context (Top Hubs):');
|
||||
autoContextSummaries.forEach((summary) => {
|
||||
contextLines.push(
|
||||
`[NODE:${summary.id}:"${summary.title}"] (${summary.edgeCount} edges)`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const task = `Execute workflow: ${workflow.displayName}
|
||||
|
||||
${workflow.instructions}
|
||||
|
||||
${nodeId ? `Target Node ID: ${nodeId}` : 'No specific node targeted (general workflow)'}`;
|
||||
|
||||
// Create delegation
|
||||
const delegation = AgentDelegationService.createDelegation({
|
||||
task,
|
||||
context: contextLines,
|
||||
expectedOutcome: workflow.expectedOutcome,
|
||||
agentType: 'wise-rah',
|
||||
supabaseToken: null,
|
||||
});
|
||||
|
||||
// Fire-and-forget execution
|
||||
void WiseRAHExecutor.execute({
|
||||
sessionId: delegation.sessionId,
|
||||
task,
|
||||
context: contextLines,
|
||||
expectedOutcome: workflow.expectedOutcome,
|
||||
workflowKey,
|
||||
workflowNodeId: nodeId,
|
||||
}).catch((error) => {
|
||||
console.error('[/api/workflows/execute] Execution failed:', error);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
delegationId: delegation.sessionId,
|
||||
workflowKey,
|
||||
nodeId: nodeId || null,
|
||||
status: 'executing',
|
||||
message: `Workflow '${workflow.displayName}' started`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error executing workflow:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to execute workflow' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+693
-2
@@ -118,6 +118,223 @@ const searchNodesOutputSchema = {
|
||||
)
|
||||
};
|
||||
|
||||
// rah_update_node schemas
|
||||
const updateNodeInputSchema = {
|
||||
id: z.number().int().positive().describe('The ID of the node to update'),
|
||||
updates: z.object({
|
||||
title: z.string().optional().describe('New title'),
|
||||
content: z.string().optional().describe('Content to APPEND (not replace)'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||
metadata: z.record(z.any()).optional().describe('New metadata (replaces existing)')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
|
||||
const updateNodeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
nodeId: z.number(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_get_nodes schemas
|
||||
const getNodesInputSchema = {
|
||||
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load')
|
||||
};
|
||||
|
||||
const getNodesOutputSchema = {
|
||||
count: z.number(),
|
||||
nodes: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
content: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
updated_at: z.string()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_create_edge schemas
|
||||
const createEdgeInputSchema = {
|
||||
sourceId: z.number().int().positive().describe('Source node ID'),
|
||||
targetId: z.number().int().positive().describe('Target node ID'),
|
||||
type: z.string().optional().describe('Edge type/label'),
|
||||
weight: z.number().min(0).max(1).optional().describe('Edge weight 0-1')
|
||||
};
|
||||
|
||||
const createEdgeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
edgeId: z.number(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_query_edges schemas
|
||||
const queryEdgesInputSchema = {
|
||||
nodeId: z.number().int().positive().optional().describe('Find edges connected to this node'),
|
||||
limit: z.number().min(1).max(50).optional().describe('Max edges to return')
|
||||
};
|
||||
|
||||
const queryEdgesOutputSchema = {
|
||||
count: z.number(),
|
||||
edges: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
source_id: z.number(),
|
||||
target_id: z.number(),
|
||||
type: z.string().nullable(),
|
||||
weight: z.number().nullable()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_update_edge schemas
|
||||
const updateEdgeInputSchema = {
|
||||
id: z.number().int().positive().describe('Edge ID to update'),
|
||||
type: z.string().optional().describe('New edge type/label'),
|
||||
weight: z.number().min(0).max(1).optional().describe('New edge weight 0-1')
|
||||
};
|
||||
|
||||
const updateEdgeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_create_dimension schemas
|
||||
const createDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name'),
|
||||
description: z.string().max(500).optional().describe('Dimension description'),
|
||||
isPriority: z.boolean().optional().describe('Lock dimension for auto-assignment')
|
||||
};
|
||||
|
||||
const createDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_update_dimension schemas
|
||||
const updateDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New name (for renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description'),
|
||||
isPriority: z.boolean().optional().describe('Lock/unlock dimension')
|
||||
};
|
||||
|
||||
const updateDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_delete_dimension schemas
|
||||
const deleteDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name to delete')
|
||||
};
|
||||
|
||||
const deleteDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_search_embeddings schemas
|
||||
const searchEmbeddingsInputSchema = {
|
||||
query: z.string().min(1).describe('Semantic search query'),
|
||||
limit: z.number().min(1).max(20).optional().describe('Max results')
|
||||
};
|
||||
|
||||
const searchEmbeddingsOutputSchema = {
|
||||
count: z.number(),
|
||||
results: z.array(
|
||||
z.object({
|
||||
nodeId: z.number(),
|
||||
title: z.string(),
|
||||
chunkPreview: z.string(),
|
||||
similarity: z.number()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_list_workflows schemas
|
||||
const listWorkflowsOutputSchema = {
|
||||
count: z.number(),
|
||||
workflows: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
displayName: z.string(),
|
||||
description: z.string().nullable(),
|
||||
enabled: z.boolean()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_get_workflow schemas
|
||||
const getWorkflowInputSchema = {
|
||||
workflowKey: z.string().describe('Workflow key to fetch (e.g., "integrate", "prep")')
|
||||
};
|
||||
|
||||
const getWorkflowOutputSchema = {
|
||||
success: z.boolean(),
|
||||
workflow: z.object({
|
||||
key: z.string(),
|
||||
displayName: z.string(),
|
||||
description: z.string().nullable(),
|
||||
instructions: z.string(),
|
||||
enabled: z.boolean()
|
||||
}).nullable(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_execute_workflow schemas
|
||||
const executeWorkflowInputSchema = {
|
||||
workflowKey: z.string().describe('Workflow key to execute (e.g., "integrate")'),
|
||||
nodeId: z.number().int().positive().optional().describe('Target node ID for the workflow')
|
||||
};
|
||||
|
||||
const executeWorkflowOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_extract_url schemas
|
||||
const extractUrlInputSchema = {
|
||||
url: z.string().url().describe('URL of the webpage to extract content from')
|
||||
};
|
||||
|
||||
const extractUrlOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
chunk: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
// rah_extract_youtube schemas
|
||||
const extractYoutubeInputSchema = {
|
||||
url: z.string().describe('YouTube video URL to extract transcript from')
|
||||
};
|
||||
|
||||
const extractYoutubeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
channel: z.string(),
|
||||
transcript: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
// rah_extract_pdf schemas
|
||||
const extractPdfInputSchema = {
|
||||
url: z.string().url().describe('URL of the PDF file to extract content from')
|
||||
};
|
||||
|
||||
const extractPdfOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
chunk: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
async function resolveBaseUrl() {
|
||||
try {
|
||||
const value = await baseUrlResolver();
|
||||
@@ -164,7 +381,7 @@ async function callRaHApi(pathname, options = {}) {
|
||||
}
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah.add_node',
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the local RA-H knowledge base.',
|
||||
@@ -211,7 +428,7 @@ mcpServer.registerTool(
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah.search_nodes',
|
||||
'rah_search_nodes',
|
||||
{
|
||||
title: 'Search RA-H nodes',
|
||||
description: 'Find existing RA-H entries that mention a topic before adding new ones.',
|
||||
@@ -255,6 +472,480 @@ mcpServer.registerTool(
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.',
|
||||
inputSchema: updateNodeInputSchema,
|
||||
outputSchema: updateNodeOutputSchema
|
||||
},
|
||||
async ({ id, updates }) => {
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
const node = result.node || result.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated node #${id}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
nodeId: node?.id || id,
|
||||
message: result.message || `Updated node #${id}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_get_nodes',
|
||||
{
|
||||
title: 'Get RA-H nodes by ID',
|
||||
description: 'Load full node records by their IDs.',
|
||||
inputSchema: getNodesInputSchema,
|
||||
outputSchema: getNodesOutputSchema
|
||||
},
|
||||
async ({ nodeIds }) => {
|
||||
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'No valid node IDs provided.');
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
const result = await callRaHApi(`/api/nodes/${id}`, { method: 'GET' });
|
||||
if (result.node) {
|
||||
nodes.push({
|
||||
id: result.node.id,
|
||||
title: result.node.title,
|
||||
content: result.node.content ?? null,
|
||||
link: result.node.link ?? null,
|
||||
dimensions: result.node.dimensions || [],
|
||||
updated_at: result.node.updated_at
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip missing nodes
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }],
|
||||
structuredContent: {
|
||||
count: nodes.length,
|
||||
nodes
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_create_edge',
|
||||
{
|
||||
title: 'Create RA-H edge',
|
||||
description: 'Create a connection between two nodes.',
|
||||
inputSchema: createEdgeInputSchema,
|
||||
outputSchema: createEdgeOutputSchema
|
||||
},
|
||||
async ({ sourceId, targetId, type, weight }) => {
|
||||
const payload = {
|
||||
source_id: sourceId,
|
||||
target_id: targetId,
|
||||
type: type || 'related',
|
||||
weight: weight ?? 0.5
|
||||
};
|
||||
|
||||
const result = await callRaHApi('/api/edges', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const edge = result.edge || result.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created edge from #${sourceId} to #${targetId}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
edgeId: edge?.id || 0,
|
||||
message: result.message || `Created edge from #${sourceId} to #${targetId}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_query_edges',
|
||||
{
|
||||
title: 'Query RA-H edges',
|
||||
description: 'Find connections between nodes.',
|
||||
inputSchema: queryEdgesInputSchema,
|
||||
outputSchema: queryEdgesOutputSchema
|
||||
},
|
||||
async ({ nodeId, limit = 25 }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (nodeId) params.set('nodeId', String(nodeId));
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 50)));
|
||||
|
||||
const result = await callRaHApi(`/api/edges?${params.toString()}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const edges = Array.isArray(result.data) ? result.data : [];
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${edges.length} edge(s).` }],
|
||||
structuredContent: {
|
||||
count: edges.length,
|
||||
edges: edges.map(e => ({
|
||||
id: e.id,
|
||||
source_id: e.source_id,
|
||||
target_id: e.target_id,
|
||||
type: e.type ?? null,
|
||||
weight: e.weight ?? null
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_update_edge',
|
||||
{
|
||||
title: 'Update RA-H edge',
|
||||
description: 'Update an existing edge connection.',
|
||||
inputSchema: updateEdgeInputSchema,
|
||||
outputSchema: updateEdgeOutputSchema
|
||||
},
|
||||
async ({ id, type, weight }) => {
|
||||
const payload = {};
|
||||
if (type !== undefined) payload.type = type;
|
||||
if (weight !== undefined) payload.weight = weight;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'At least one field (type or weight) must be provided.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/edges/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated edge #${id}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: result.message || `Updated edge #${id}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_create_dimension',
|
||||
{
|
||||
title: 'Create RA-H dimension',
|
||||
description: 'Create a new dimension/tag for organizing nodes.',
|
||||
inputSchema: createDimensionInputSchema,
|
||||
outputSchema: createDimensionOutputSchema
|
||||
},
|
||||
async ({ name, description, isPriority }) => {
|
||||
const payload = { name };
|
||||
if (description) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Created dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_update_dimension',
|
||||
{
|
||||
title: 'Update RA-H dimension',
|
||||
description: 'Update dimension properties (rename, description, lock/unlock).',
|
||||
inputSchema: updateDimensionInputSchema,
|
||||
outputSchema: updateDimensionOutputSchema
|
||||
},
|
||||
async ({ name, newName, description, isPriority }) => {
|
||||
const payload = {};
|
||||
if (newName) {
|
||||
payload.currentName = name;
|
||||
payload.newName = newName;
|
||||
} else {
|
||||
payload.name = name;
|
||||
}
|
||||
if (description !== undefined) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || newName || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Updated dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_delete_dimension',
|
||||
{
|
||||
title: 'Delete RA-H dimension',
|
||||
description: 'Delete a dimension and remove it from all nodes.',
|
||||
inputSchema: deleteDimensionInputSchema,
|
||||
outputSchema: deleteDimensionOutputSchema
|
||||
},
|
||||
async ({ name }) => {
|
||||
const result = await callRaHApi(`/api/dimensions?name=${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Deleted dimension: ${name}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: `Deleted dimension: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_search_embeddings',
|
||||
{
|
||||
title: 'Semantic search RA-H',
|
||||
description: 'Search node content using semantic similarity (vector search).',
|
||||
inputSchema: searchEmbeddingsInputSchema,
|
||||
outputSchema: searchEmbeddingsOutputSchema
|
||||
},
|
||||
async ({ query, limit = 10 }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', query);
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 20)));
|
||||
|
||||
const result = await callRaHApi(`/api/nodes/search?${params.toString()}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const results = Array.isArray(result.data) ? result.data : [];
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${results.length} semantically similar result(s).` }],
|
||||
structuredContent: {
|
||||
count: results.length,
|
||||
results: results.map(r => ({
|
||||
nodeId: r.node_id || r.nodeId || r.id,
|
||||
title: r.title || 'Untitled',
|
||||
chunkPreview: (r.chunk || r.content || '').slice(0, 200),
|
||||
similarity: r.similarity || r.score || 0
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_list_workflows',
|
||||
{
|
||||
title: 'List RA-H workflows',
|
||||
description: 'List all available workflows.',
|
||||
inputSchema: {},
|
||||
outputSchema: listWorkflowsOutputSchema
|
||||
},
|
||||
async () => {
|
||||
const result = await callRaHApi('/api/workflows', { method: 'GET' });
|
||||
const workflows = Array.isArray(result.data) ? result.data : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${workflows.length} workflow(s).` }],
|
||||
structuredContent: {
|
||||
count: workflows.length,
|
||||
workflows: workflows.map(w => ({
|
||||
key: w.key,
|
||||
displayName: w.displayName,
|
||||
description: w.description || null,
|
||||
enabled: w.enabled
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_get_workflow',
|
||||
{
|
||||
title: 'Get RA-H workflow',
|
||||
description: 'Fetch a workflow definition by key (e.g., "integrate", "prep"). Returns instructions that can be followed.',
|
||||
inputSchema: getWorkflowInputSchema,
|
||||
outputSchema: getWorkflowOutputSchema
|
||||
},
|
||||
async ({ workflowKey }) => {
|
||||
try {
|
||||
const result = await callRaHApi(`/api/workflows/${encodeURIComponent(workflowKey)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const workflow = result.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Workflow "${workflowKey}": ${workflow?.displayName || 'Unknown'}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
workflow: workflow ? {
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description || null,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled
|
||||
} : null,
|
||||
message: `Fetched workflow "${workflowKey}"`
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Workflow "${workflowKey}" not found.` }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
workflow: null,
|
||||
message: `Workflow "${workflowKey}" not found`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_execute_workflow',
|
||||
{
|
||||
title: 'Execute RA-H workflow',
|
||||
description: 'Execute a predefined workflow (e.g., "integrate" for connection discovery).',
|
||||
inputSchema: executeWorkflowInputSchema,
|
||||
outputSchema: executeWorkflowOutputSchema
|
||||
},
|
||||
async ({ workflowKey, nodeId }) => {
|
||||
const payload = { workflowKey };
|
||||
if (nodeId) payload.nodeId = nodeId;
|
||||
|
||||
const result = await callRaHApi('/api/workflows/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Workflow "${workflowKey}" initiated.` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: result.message || `Workflow "${workflowKey}" initiated.`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_extract_url',
|
||||
{
|
||||
title: 'Extract URL content',
|
||||
description: 'Extract content from a webpage URL. Returns title, content, and metadata for creating nodes.',
|
||||
inputSchema: extractUrlInputSchema,
|
||||
outputSchema: extractUrlOutputSchema
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await callRaHApi('/api/extract/url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const summary = `Extracted content from: ${result.title || 'webpage'}`;
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled',
|
||||
content: result.content || '',
|
||||
chunk: result.chunk || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_extract_youtube',
|
||||
{
|
||||
title: 'Extract YouTube transcript',
|
||||
description: 'Extract transcript from a YouTube video. Returns title, channel, transcript, and metadata.',
|
||||
inputSchema: extractYoutubeInputSchema,
|
||||
outputSchema: extractYoutubeOutputSchema
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await callRaHApi('/api/extract/youtube', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const summary = `Extracted transcript from: ${result.title || 'YouTube video'}`;
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled',
|
||||
channel: result.channel || 'Unknown',
|
||||
transcript: result.transcript || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
mcpServer.registerTool(
|
||||
'rah_extract_pdf',
|
||||
{
|
||||
title: 'Extract PDF content',
|
||||
description: 'Extract content from a PDF file URL. Returns title, content, and metadata for creating nodes.',
|
||||
inputSchema: extractPdfInputSchema,
|
||||
outputSchema: extractPdfOutputSchema
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await callRaHApi('/api/extract/pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const summary = `Extracted content from: ${result.title || 'PDF document'}`;
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled PDF',
|
||||
content: result.content || '',
|
||||
chunk: result.chunk || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
async function readRequestBody(req) {
|
||||
if (req.method !== 'POST') return undefined;
|
||||
try {
|
||||
|
||||
@@ -67,6 +67,223 @@ const searchNodesOutputSchema = {
|
||||
)
|
||||
};
|
||||
|
||||
// rah_update_node schemas
|
||||
const updateNodeInputSchema = {
|
||||
id: z.number().int().positive().describe('The ID of the node to update'),
|
||||
updates: z.object({
|
||||
title: z.string().optional().describe('New title'),
|
||||
content: z.string().optional().describe('Content to APPEND (not replace)'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
|
||||
metadata: z.record(z.any()).optional().describe('New metadata (replaces existing)')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
|
||||
const updateNodeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
nodeId: z.number(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_get_nodes schemas
|
||||
const getNodesInputSchema = {
|
||||
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('List of node IDs to load')
|
||||
};
|
||||
|
||||
const getNodesOutputSchema = {
|
||||
count: z.number(),
|
||||
nodes: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
content: z.string().nullable(),
|
||||
link: z.string().nullable(),
|
||||
dimensions: z.array(z.string()),
|
||||
updated_at: z.string()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_create_edge schemas
|
||||
const createEdgeInputSchema = {
|
||||
sourceId: z.number().int().positive().describe('Source node ID'),
|
||||
targetId: z.number().int().positive().describe('Target node ID'),
|
||||
type: z.string().optional().describe('Edge type/label'),
|
||||
weight: z.number().min(0).max(1).optional().describe('Edge weight 0-1')
|
||||
};
|
||||
|
||||
const createEdgeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
edgeId: z.number(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_query_edges schemas
|
||||
const queryEdgesInputSchema = {
|
||||
nodeId: z.number().int().positive().optional().describe('Find edges connected to this node'),
|
||||
limit: z.number().min(1).max(50).optional().describe('Max edges to return')
|
||||
};
|
||||
|
||||
const queryEdgesOutputSchema = {
|
||||
count: z.number(),
|
||||
edges: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
from_node_id: z.number(),
|
||||
to_node_id: z.number(),
|
||||
type: z.string().nullable(),
|
||||
weight: z.number().nullable()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_update_edge schemas
|
||||
const updateEdgeInputSchema = {
|
||||
id: z.number().int().positive().describe('Edge ID to update'),
|
||||
type: z.string().optional().describe('New edge type/label'),
|
||||
weight: z.number().min(0).max(1).optional().describe('New edge weight 0-1')
|
||||
};
|
||||
|
||||
const updateEdgeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_create_dimension schemas
|
||||
const createDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name'),
|
||||
description: z.string().max(500).optional().describe('Dimension description'),
|
||||
isPriority: z.boolean().optional().describe('Lock dimension for auto-assignment')
|
||||
};
|
||||
|
||||
const createDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_update_dimension schemas
|
||||
const updateDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Current dimension name'),
|
||||
newName: z.string().optional().describe('New name (for renaming)'),
|
||||
description: z.string().max(500).optional().describe('New description'),
|
||||
isPriority: z.boolean().optional().describe('Lock/unlock dimension')
|
||||
};
|
||||
|
||||
const updateDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
dimension: z.string(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_delete_dimension schemas
|
||||
const deleteDimensionInputSchema = {
|
||||
name: z.string().min(1).describe('Dimension name to delete')
|
||||
};
|
||||
|
||||
const deleteDimensionOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_search_embeddings schemas
|
||||
const searchEmbeddingsInputSchema = {
|
||||
query: z.string().min(1).describe('Semantic search query'),
|
||||
limit: z.number().min(1).max(20).optional().describe('Max results')
|
||||
};
|
||||
|
||||
const searchEmbeddingsOutputSchema = {
|
||||
count: z.number(),
|
||||
results: z.array(
|
||||
z.object({
|
||||
nodeId: z.number(),
|
||||
title: z.string(),
|
||||
chunkPreview: z.string(),
|
||||
similarity: z.number()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_list_workflows schemas
|
||||
const listWorkflowsOutputSchema = {
|
||||
count: z.number(),
|
||||
workflows: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
displayName: z.string(),
|
||||
description: z.string().nullable(),
|
||||
enabled: z.boolean()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
// rah_get_workflow schemas
|
||||
const getWorkflowInputSchema = {
|
||||
workflowKey: z.string().describe('Workflow key to fetch (e.g., "integrate", "prep")')
|
||||
};
|
||||
|
||||
const getWorkflowOutputSchema = {
|
||||
success: z.boolean(),
|
||||
workflow: z.object({
|
||||
key: z.string(),
|
||||
displayName: z.string(),
|
||||
description: z.string().nullable(),
|
||||
instructions: z.string(),
|
||||
enabled: z.boolean()
|
||||
}).nullable(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_execute_workflow schemas
|
||||
const executeWorkflowInputSchema = {
|
||||
workflowKey: z.string().describe('Workflow key to execute (e.g., "integrate")'),
|
||||
nodeId: z.number().int().positive().optional().describe('Target node ID for the workflow')
|
||||
};
|
||||
|
||||
const executeWorkflowOutputSchema = {
|
||||
success: z.boolean(),
|
||||
message: z.string()
|
||||
};
|
||||
|
||||
// rah_extract_url schemas
|
||||
const extractUrlInputSchema = {
|
||||
url: z.string().url().describe('URL of the webpage to extract content from')
|
||||
};
|
||||
|
||||
const extractUrlOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
chunk: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
// rah_extract_youtube schemas
|
||||
const extractYoutubeInputSchema = {
|
||||
url: z.string().describe('YouTube video URL to extract transcript from')
|
||||
};
|
||||
|
||||
const extractYoutubeOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
channel: z.string(),
|
||||
transcript: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
// rah_extract_pdf schemas
|
||||
const extractPdfInputSchema = {
|
||||
url: z.string().url().describe('URL of the PDF file to extract content from')
|
||||
};
|
||||
|
||||
const extractPdfOutputSchema = {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
chunk: z.string(),
|
||||
metadata: z.record(z.any())
|
||||
};
|
||||
|
||||
const server = new McpServer(serverInfo, { instructions });
|
||||
|
||||
function logError(...args) {
|
||||
@@ -227,6 +444,480 @@ server.registerTool(
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. Content is APPENDED (not replaced). Dimensions are replaced.',
|
||||
inputSchema: updateNodeInputSchema,
|
||||
outputSchema: updateNodeOutputSchema
|
||||
},
|
||||
async ({ id, updates }) => {
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
throw new Error('At least one field must be provided in updates.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
const node = result.node || result.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated node #${id}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
nodeId: node?.id || id,
|
||||
message: result.message || `Updated node #${id}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_get_nodes',
|
||||
{
|
||||
title: 'Get RA-H nodes by ID',
|
||||
description: 'Load full node records by their IDs.',
|
||||
inputSchema: getNodesInputSchema,
|
||||
outputSchema: getNodesOutputSchema
|
||||
},
|
||||
async ({ nodeIds }) => {
|
||||
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) {
|
||||
throw new Error('No valid node IDs provided.');
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
const result = await callRaHApi(`/api/nodes/${id}`, { method: 'GET' });
|
||||
if (result.node) {
|
||||
nodes.push({
|
||||
id: result.node.id,
|
||||
title: result.node.title,
|
||||
content: result.node.content ?? null,
|
||||
link: result.node.link ?? null,
|
||||
dimensions: result.node.dimensions || [],
|
||||
updated_at: result.node.updated_at
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip missing nodes
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }],
|
||||
structuredContent: {
|
||||
count: nodes.length,
|
||||
nodes
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_create_edge',
|
||||
{
|
||||
title: 'Create RA-H edge',
|
||||
description: 'Create a connection between two nodes.',
|
||||
inputSchema: createEdgeInputSchema,
|
||||
outputSchema: createEdgeOutputSchema
|
||||
},
|
||||
async ({ sourceId, targetId, type, weight }) => {
|
||||
const payload = {
|
||||
from_node_id: sourceId,
|
||||
to_node_id: targetId,
|
||||
type: type || 'related',
|
||||
weight: weight ?? 0.5
|
||||
};
|
||||
|
||||
const result = await callRaHApi('/api/edges', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const edge = result.edge || result.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created edge from #${sourceId} to #${targetId}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
edgeId: edge?.id || 0,
|
||||
message: result.message || `Created edge from #${sourceId} to #${targetId}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_query_edges',
|
||||
{
|
||||
title: 'Query RA-H edges',
|
||||
description: 'Find connections between nodes.',
|
||||
inputSchema: queryEdgesInputSchema,
|
||||
outputSchema: queryEdgesOutputSchema
|
||||
},
|
||||
async ({ nodeId, limit = 25 }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (nodeId) params.set('nodeId', String(nodeId));
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 50)));
|
||||
|
||||
const result = await callRaHApi(`/api/edges?${params.toString()}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const edges = Array.isArray(result.data) ? result.data : [];
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${edges.length} edge(s).` }],
|
||||
structuredContent: {
|
||||
count: edges.length,
|
||||
edges: edges.map(e => ({
|
||||
id: e.id,
|
||||
from_node_id: e.from_node_id,
|
||||
to_node_id: e.to_node_id,
|
||||
type: e.type ?? e.source ?? null,
|
||||
weight: e.weight ?? null
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_update_edge',
|
||||
{
|
||||
title: 'Update RA-H edge',
|
||||
description: 'Update an existing edge connection.',
|
||||
inputSchema: updateEdgeInputSchema,
|
||||
outputSchema: updateEdgeOutputSchema
|
||||
},
|
||||
async ({ id, type, weight }) => {
|
||||
const payload = {};
|
||||
if (type !== undefined) payload.type = type;
|
||||
if (weight !== undefined) payload.weight = weight;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
throw new Error('At least one field (type or weight) must be provided.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/edges/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated edge #${id}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: result.message || `Updated edge #${id}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_create_dimension',
|
||||
{
|
||||
title: 'Create RA-H dimension',
|
||||
description: 'Create a new dimension/tag for organizing nodes.',
|
||||
inputSchema: createDimensionInputSchema,
|
||||
outputSchema: createDimensionOutputSchema
|
||||
},
|
||||
async ({ name, description, isPriority }) => {
|
||||
const payload = { name };
|
||||
if (description) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Created dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Created dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_update_dimension',
|
||||
{
|
||||
title: 'Update RA-H dimension',
|
||||
description: 'Update dimension properties (rename, description, lock/unlock).',
|
||||
inputSchema: updateDimensionInputSchema,
|
||||
outputSchema: updateDimensionOutputSchema
|
||||
},
|
||||
async ({ name, newName, description, isPriority }) => {
|
||||
const payload = {};
|
||||
if (newName) {
|
||||
payload.currentName = name;
|
||||
payload.newName = newName;
|
||||
} else {
|
||||
payload.name = name;
|
||||
}
|
||||
if (description !== undefined) payload.description = description;
|
||||
if (isPriority !== undefined) payload.isPriority = isPriority;
|
||||
|
||||
const result = await callRaHApi('/api/dimensions', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const dim = result.data?.dimension || newName || name;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Updated dimension: ${dim}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
dimension: dim,
|
||||
message: `Updated dimension: ${dim}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_delete_dimension',
|
||||
{
|
||||
title: 'Delete RA-H dimension',
|
||||
description: 'Delete a dimension and remove it from all nodes.',
|
||||
inputSchema: deleteDimensionInputSchema,
|
||||
outputSchema: deleteDimensionOutputSchema
|
||||
},
|
||||
async ({ name }) => {
|
||||
const result = await callRaHApi(`/api/dimensions?name=${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Deleted dimension: ${name}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: `Deleted dimension: ${name}`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_search_embeddings',
|
||||
{
|
||||
title: 'Semantic search RA-H',
|
||||
description: 'Search node content using semantic similarity (vector search).',
|
||||
inputSchema: searchEmbeddingsInputSchema,
|
||||
outputSchema: searchEmbeddingsOutputSchema
|
||||
},
|
||||
async ({ query, limit = 10 }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', query);
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 20)));
|
||||
|
||||
const result = await callRaHApi(`/api/nodes/search?${params.toString()}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const results = Array.isArray(result.data) ? result.data : [];
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${results.length} semantically similar result(s).` }],
|
||||
structuredContent: {
|
||||
count: results.length,
|
||||
results: results.map(r => ({
|
||||
nodeId: r.node_id || r.nodeId || r.id,
|
||||
title: r.title || 'Untitled',
|
||||
chunkPreview: (r.chunk || r.content || '').slice(0, 200),
|
||||
similarity: r.similarity || r.score || 0
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_list_workflows',
|
||||
{
|
||||
title: 'List RA-H workflows',
|
||||
description: 'List all available workflows.',
|
||||
inputSchema: {},
|
||||
outputSchema: listWorkflowsOutputSchema
|
||||
},
|
||||
async () => {
|
||||
const result = await callRaHApi('/api/workflows', { method: 'GET' });
|
||||
const workflows = Array.isArray(result.data) ? result.data : [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${workflows.length} workflow(s).` }],
|
||||
structuredContent: {
|
||||
count: workflows.length,
|
||||
workflows: workflows.map(w => ({
|
||||
key: w.key,
|
||||
displayName: w.displayName,
|
||||
description: w.description || null,
|
||||
enabled: w.enabled
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_get_workflow',
|
||||
{
|
||||
title: 'Get RA-H workflow',
|
||||
description: 'Fetch a workflow definition by key (e.g., "integrate", "prep"). Returns instructions that can be followed.',
|
||||
inputSchema: getWorkflowInputSchema,
|
||||
outputSchema: getWorkflowOutputSchema
|
||||
},
|
||||
async ({ workflowKey }) => {
|
||||
try {
|
||||
const result = await callRaHApi(`/api/workflows/${encodeURIComponent(workflowKey)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const workflow = result.data;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Workflow "${workflowKey}": ${workflow?.displayName || 'Unknown'}` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
workflow: workflow ? {
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description || null,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled
|
||||
} : null,
|
||||
message: `Fetched workflow "${workflowKey}"`
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Workflow "${workflowKey}" not found.` }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
workflow: null,
|
||||
message: `Workflow "${workflowKey}" not found`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_execute_workflow',
|
||||
{
|
||||
title: 'Execute RA-H workflow',
|
||||
description: 'Execute a predefined workflow (e.g., "integrate" for connection discovery).',
|
||||
inputSchema: executeWorkflowInputSchema,
|
||||
outputSchema: executeWorkflowOutputSchema
|
||||
},
|
||||
async ({ workflowKey, nodeId }) => {
|
||||
const payload = { workflowKey };
|
||||
if (nodeId) payload.nodeId = nodeId;
|
||||
|
||||
const result = await callRaHApi('/api/workflows/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Workflow "${workflowKey}" initiated.` }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
message: result.message || `Workflow "${workflowKey}" initiated.`
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_extract_url',
|
||||
{
|
||||
title: 'Extract URL content',
|
||||
description: 'Extract content from a webpage URL. Returns title, content, and metadata for creating nodes.',
|
||||
inputSchema: extractUrlInputSchema,
|
||||
outputSchema: extractUrlOutputSchema
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await callRaHApi('/api/extract/url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const summary = `Extracted content from: ${result.title || 'webpage'}`;
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled',
|
||||
content: result.content || '',
|
||||
chunk: result.chunk || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_extract_youtube',
|
||||
{
|
||||
title: 'Extract YouTube transcript',
|
||||
description: 'Extract transcript from a YouTube video. Returns title, channel, transcript, and metadata.',
|
||||
inputSchema: extractYoutubeInputSchema,
|
||||
outputSchema: extractYoutubeOutputSchema
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await callRaHApi('/api/extract/youtube', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const summary = `Extracted transcript from: ${result.title || 'YouTube video'}`;
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled',
|
||||
channel: result.channel || 'Unknown',
|
||||
transcript: result.transcript || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'rah_extract_pdf',
|
||||
{
|
||||
title: 'Extract PDF content',
|
||||
description: 'Extract content from a PDF file URL. Returns title, content, and metadata for creating nodes.',
|
||||
inputSchema: extractPdfInputSchema,
|
||||
outputSchema: extractPdfOutputSchema
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await callRaHApi('/api/extract/pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const summary = `Extracted content from: ${result.title || 'PDF document'}`;
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
title: result.title || 'Untitled PDF',
|
||||
content: result.content || '',
|
||||
chunk: result.chunk || '',
|
||||
metadata: result.metadata || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -11,14 +11,15 @@ import { Node } from '@/types/database';
|
||||
interface AgentsPanelProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
type ActiveTab = 'ra-h' | string; // 'ra-h' or delegation sessionId
|
||||
type ActiveTab = 'ra-h' | 'workflows' | string; // 'ra-h', 'workflows', or delegation sessionId
|
||||
type Mode = 'quickadd' | 'session';
|
||||
|
||||
export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick, onCollapse }: AgentsPanelProps) {
|
||||
export default function AgentsPanel({ openTabsData, activeTabId, activeDimension, onNodeClick, onCollapse }: AgentsPanelProps) {
|
||||
const [delegationsMap, setDelegationsMap] = useState<Record<string, AgentDelegation>>({});
|
||||
const [activeAgentTab, setActiveAgentTab] = useState<ActiveTab>('ra-h');
|
||||
const [mode, setMode] = useState<Mode>('quickadd');
|
||||
@@ -184,6 +185,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick, on
|
||||
};
|
||||
|
||||
const handleDelegationClick = (sessionId: string) => {
|
||||
setMode('session');
|
||||
setActiveAgentTab(sessionId);
|
||||
};
|
||||
|
||||
@@ -419,76 +421,37 @@ export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick, on
|
||||
onClick={() => setActiveAgentTab('ra-h')}
|
||||
className={`agent-tab ${activeAgentTab === 'ra-h' ? 'active' : ''}`}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: rahMode === 'easy' ? '#22c55e' : '#f97316' }}>
|
||||
{(rahMode === 'easy' ? <Zap size={12} strokeWidth={2.4} /> : <Flame size={12} strokeWidth={2.4} />)}
|
||||
<span>RA-H · {rahMode === 'easy' ? 'Easy' : 'Hard'}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: '#22c55e' }}>
|
||||
<Zap size={12} strokeWidth={2.4} />
|
||||
<span>RA-H</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Delegation Tabs */}
|
||||
{orderedDelegations.map((delegation) => {
|
||||
const isActive = activeAgentTab === delegation.sessionId;
|
||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
||||
|
||||
// Status colors based on agent type
|
||||
const statusColor = isWiseRAH
|
||||
? (delegation.status === 'in_progress' ? '#8b5cf6' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#a78bfa')
|
||||
: (delegation.status === 'in_progress' ? '#8bd450' :
|
||||
delegation.status === 'completed' ? '#6b6b6b' :
|
||||
delegation.status === 'failed' ? '#ff6b6b' : '#5c9aff');
|
||||
|
||||
const shortId = delegation.sessionId.split('_').pop() || '';
|
||||
const tabLabel = isWiseRAH
|
||||
? `WISE RA-H · ${shortId.slice(0, 6)}`
|
||||
: `MINI · ${shortId.slice(0, 6)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={delegation.sessionId}
|
||||
className={`agent-tab-wrapper ${isWiseRAH ? 'wise' : 'mini'} ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
{/* Workflows Tab */}
|
||||
{orderedDelegations.length > 0 && (
|
||||
<button
|
||||
onClick={() => setActiveAgentTab(delegation.sessionId)}
|
||||
className="agent-tab"
|
||||
onClick={() => setActiveAgentTab('workflows')}
|
||||
className={`agent-tab ${activeAgentTab === 'workflows' || (activeAgentTab !== 'ra-h' && activeAgentTab !== 'workflows') ? 'active' : ''}`}
|
||||
>
|
||||
<span className="status-dot" style={{ background: statusColor }} />
|
||||
<span className="agent-tab-label">{tabLabel}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span className="status-dot" style={{
|
||||
background: orderedDelegations.some(d => d.status === 'in_progress') ? '#22c55e' : '#6b6b6b',
|
||||
animation: orderedDelegations.some(d => d.status === 'in_progress') ? 'pulse 2s infinite' : 'none'
|
||||
}} />
|
||||
<span>Workflows</span>
|
||||
<span style={{
|
||||
background: '#1f1f1f',
|
||||
color: '#a8a8a8',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{orderedDelegations.length}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
console.log(`[AgentsPanel] User clicked close button for delegation ${delegation.sessionId}`);
|
||||
|
||||
// Delete from database
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${delegation.sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete delegation ${delegation.sessionId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from UI state
|
||||
setDelegationsMap((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setDelegationMessages((prev) => {
|
||||
const { [delegation.sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
if (activeAgentTab === delegation.sessionId) {
|
||||
setActiveAgentTab('ra-h');
|
||||
}
|
||||
}}
|
||||
className="agent-tab-close"
|
||||
title="Close tab"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -516,6 +479,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick, on
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={rahMessages}
|
||||
@@ -524,20 +488,54 @@ export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick, on
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show delegation chat when delegation tab is active */}
|
||||
{selectedDelegation && activeAgentTab !== 'ra-h' && (
|
||||
{/* Workflows list view */}
|
||||
{activeAgentTab === 'workflows' && (
|
||||
<div style={{ height: '100%', display: 'block' }}>
|
||||
<RAHChat
|
||||
<WorkflowsListView
|
||||
delegations={orderedDelegations}
|
||||
onSelectDelegation={(sessionId) => setActiveAgentTab(sessionId)}
|
||||
onDeleteDelegation={async (sessionId) => {
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete delegation ${sessionId}:`, error);
|
||||
}
|
||||
setDelegationsMap((prev) => {
|
||||
const { [sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setDelegationMessages((prev) => {
|
||||
const { [sessionId]: _ignored, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show delegation detail when a specific delegation is selected */}
|
||||
{selectedDelegation && activeAgentTab !== 'ra-h' && activeAgentTab !== 'workflows' && (
|
||||
<div style={{ height: '100%', display: 'block' }}>
|
||||
{/* Show summary view if completed/failed with no messages */}
|
||||
{(selectedDelegation.status === 'completed' || selectedDelegation.status === 'failed')
|
||||
&& getDelegationMessages(selectedDelegation.sessionId).length === 0 ? (
|
||||
<DelegationSummaryView
|
||||
delegation={selectedDelegation}
|
||||
onBack={() => setActiveAgentTab('workflows')}
|
||||
/>
|
||||
) : (
|
||||
<DelegationDetailView
|
||||
delegation={selectedDelegation}
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={orderedDelegations}
|
||||
messages={getDelegationMessages(selectedDelegation.sessionId)}
|
||||
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
|
||||
mode="easy"
|
||||
delegationMode={true}
|
||||
delegationSessionId={selectedDelegation.sessionId}
|
||||
onBack={() => setActiveAgentTab('workflows')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -638,7 +636,468 @@ export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick, on
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary view for completed delegations with no messages
|
||||
function DelegationSummaryView({ delegation, onBack }: { delegation: AgentDelegation; onBack?: () => void }) {
|
||||
const isSuccess = delegation.status === 'completed';
|
||||
const statusColor = isSuccess ? '#22c55e' : '#ff6b6b';
|
||||
const statusLabel = isSuccess ? 'Completed' : 'Failed';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a',
|
||||
padding: '24px'
|
||||
}}>
|
||||
{/* Header with back button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '24px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '1px solid #1a1a1a'
|
||||
}}>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor
|
||||
}} />
|
||||
<span style={{
|
||||
color: statusColor,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
marginLeft: 'auto'
|
||||
}}>
|
||||
{new Date(delegation.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Task */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Task
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{delegation.summary && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Result
|
||||
</div>
|
||||
<div style={{
|
||||
color: isSuccess ? '#a8a8a8' : '#fca5a5',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No summary fallback */}
|
||||
{!delegation.summary && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
No details available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Workflows list view - shows all delegations in a nice list
|
||||
function WorkflowsListView({
|
||||
delegations,
|
||||
onSelectDelegation,
|
||||
onDeleteDelegation
|
||||
}: {
|
||||
delegations: AgentDelegation[];
|
||||
onSelectDelegation: (sessionId: string) => void;
|
||||
onDeleteDelegation: (sessionId: string) => void;
|
||||
}) {
|
||||
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
|
||||
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
|
||||
|
||||
const getStatusInfo = (delegation: AgentDelegation) => {
|
||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
||||
let color = '#6b6b6b';
|
||||
let label = 'Queued';
|
||||
|
||||
if (delegation.status === 'in_progress') {
|
||||
color = isWiseRAH ? '#8b5cf6' : '#22c55e';
|
||||
label = 'Running';
|
||||
} else if (delegation.status === 'completed') {
|
||||
color = '#22c55e';
|
||||
label = 'Done';
|
||||
} else if (delegation.status === 'failed') {
|
||||
color = '#ff6b6b';
|
||||
label = 'Failed';
|
||||
}
|
||||
|
||||
return { color, label };
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
Workflows
|
||||
</span>
|
||||
{activeDelegations.length > 0 && (
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{activeDelegations.length} running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '12px'
|
||||
}}>
|
||||
{delegations.length === 0 ? (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
No workflows yet. Use Quick Capture or ask RA-H to run a workflow.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* Active workflows first */}
|
||||
{activeDelegations.map((delegation) => {
|
||||
const { color, label } = getStatusInfo(delegation);
|
||||
return (
|
||||
<WorkflowCard
|
||||
key={delegation.sessionId}
|
||||
delegation={delegation}
|
||||
statusColor={color}
|
||||
statusLabel={label}
|
||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider if both active and completed exist */}
|
||||
{activeDelegations.length > 0 && completedDelegations.length > 0 && (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: '#1f1f1f',
|
||||
margin: '8px 0'
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Completed workflows */}
|
||||
{completedDelegations.map((delegation) => {
|
||||
const { color, label } = getStatusInfo(delegation);
|
||||
return (
|
||||
<WorkflowCard
|
||||
key={delegation.sessionId}
|
||||
delegation={delegation}
|
||||
statusColor={color}
|
||||
statusLabel={label}
|
||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual workflow card
|
||||
function WorkflowCard({
|
||||
delegation,
|
||||
statusColor,
|
||||
statusLabel,
|
||||
onSelect,
|
||||
onDelete
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
statusColor: string;
|
||||
statusLabel: string;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const isActive = delegation.status === 'in_progress' || delegation.status === 'queued';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: '#151515',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
}}
|
||||
>
|
||||
{/* Top row: status + time + delete */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
animation: isActive ? 'pulse 2s infinite' : 'none'
|
||||
}} />
|
||||
<span style={{
|
||||
color: statusColor,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#555',
|
||||
fontSize: '11px',
|
||||
marginLeft: 'auto'
|
||||
}}>
|
||||
{new Date(delegation.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#444',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#ff6b6b'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#444'}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task description */}
|
||||
<div style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
|
||||
{/* Summary preview if completed */}
|
||||
{delegation.summary && delegation.status === 'completed' && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
marginTop: '8px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{delegation.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Delegation detail view with back button
|
||||
|
||||
function DelegationDetailView({
|
||||
delegation,
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
onNodeClick,
|
||||
delegations,
|
||||
messages,
|
||||
setMessages,
|
||||
onBack
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations: AgentDelegation[];
|
||||
messages: any[];
|
||||
setMessages: (updater: (prev: any[]) => any[]) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Back button header */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: '#0a0a0a'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#a8a8a8'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#666'}
|
||||
>
|
||||
← Workflows
|
||||
</button>
|
||||
<span style={{
|
||||
color: '#555',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
|
|
||||
</span>
|
||||
<span style={{
|
||||
color: delegation.status === 'in_progress' ? '#22c55e' : '#666',
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{delegation.status === 'in_progress' ? 'Running' : delegation.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* RAHChat for streaming */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={delegations}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
mode="easy"
|
||||
delegationMode={true}
|
||||
delegationSessionId={delegation.sessionId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const createVoiceRequestId = () =>
|
||||
interface RAHChatProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations?: AgentDelegation[];
|
||||
messages?: ChatMessage[];
|
||||
@@ -37,6 +38,7 @@ interface RAHChatProps {
|
||||
export default function RAHChat({
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
onNodeClick,
|
||||
delegations = [],
|
||||
messages: externalMessages,
|
||||
@@ -124,7 +126,6 @@ export default function RAHChat({
|
||||
await handler();
|
||||
}
|
||||
},
|
||||
getApiKeys: () => apiKeyService.getStoredKeys(),
|
||||
});
|
||||
|
||||
useVoiceInterruption({
|
||||
|
||||
@@ -26,6 +26,7 @@ interface SendParams {
|
||||
history: ChatMessage[];
|
||||
openTabs: any[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
currentView?: 'nodes' | 'memory';
|
||||
sessionId: string;
|
||||
mode: 'easy' | 'hard';
|
||||
@@ -36,7 +37,6 @@ interface UseSSEChatOptions {
|
||||
beforeRequest?: () => boolean;
|
||||
onRequestError?: (error: unknown, response?: Response) => boolean | void;
|
||||
onStreamComplete?: () => void | Promise<void>;
|
||||
getApiKeys?: () => { openai?: string; anthropic?: string } | undefined;
|
||||
}
|
||||
|
||||
export function useSSEChat(
|
||||
@@ -44,7 +44,7 @@ export function useSSEChat(
|
||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void,
|
||||
options: UseSSEChatOptions = {}
|
||||
) {
|
||||
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete, getApiKeys } = options;
|
||||
const { getAuthToken, beforeRequest, onRequestError, onStreamComplete } = options;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -53,7 +53,7 @@ export function useSSEChat(
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
|
||||
const send = async ({ text, history, openTabs, activeTabId, currentView, sessionId, mode }: SendParams) => {
|
||||
const send = async ({ text, history, openTabs, activeTabId, activeDimension, currentView, sessionId, mode }: SendParams) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
if (beforeRequest && !beforeRequest()) return;
|
||||
@@ -97,10 +97,10 @@ export function useSSEChat(
|
||||
.map((m) => ({ role: m.role, parts: [{ type: 'text', text: m.content }] })),
|
||||
openTabs,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
currentView,
|
||||
sessionId,
|
||||
mode,
|
||||
apiKeys: getApiKeys?.(),
|
||||
mode
|
||||
}),
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ Mission:
|
||||
|
||||
Operating principles:
|
||||
- Handle analysis, planning, and writes yourself; do not delegate.
|
||||
- Use quickLink to instantly find and link related nodes (fast, no reasoning needed).
|
||||
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
|
||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs). Derived idea nodes should not have links.
|
||||
- When referencing stored content, quote verbatim text in quotes and include the node citation.
|
||||
|
||||
@@ -11,6 +11,7 @@ When to ask the user:
|
||||
|
||||
Execution approach:
|
||||
- Handle planning, analysis, and writes directly—do not delegate to mini ra-h during normal conversations.
|
||||
- Use quickLink to instantly find and link related nodes (fast, no AI reasoning needed—just database search).
|
||||
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
|
||||
- When creating nodes derived from existing content (ideas, insights, summaries), do NOT include the 'link' field. The 'link' field is ONLY for nodes that directly represent external content (YouTube videos, websites, PDFs).
|
||||
- Treat "this conversation/paper/video" as the active focused node.
|
||||
|
||||
@@ -15,6 +15,8 @@ Available tools:
|
||||
- webSearch — external research when necessary
|
||||
- think — internal planning/reflection (use once per workflow unless plan changes)
|
||||
- updateNode — append content to nodes (tool handles appending automatically)
|
||||
- createEdge — create connections between nodes
|
||||
- quickLink — instantly find and link related nodes (fast database search, no reasoning)
|
||||
- Dimension management: createDimension, updateDimension, lockDimension, unlockDimension, deleteDimension — manage dimensions to organize knowledge base structure
|
||||
</tools>
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export const CONNECT_WORKFLOW_INSTRUCTIONS = `You are executing the CONNECT workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Quick link: find explicitly related nodes and create edges. Fast text search only - no slow embedding search.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. READ NODE
|
||||
Call getNodesById for the focused node. Note the title, type, and key names/entities mentioned.
|
||||
|
||||
2. QUICK SEARCH
|
||||
Call queryNodes ONCE with the most specific entity (person name, project name, company, tool).
|
||||
- Use search parameter with the exact name
|
||||
- Set limit: 10
|
||||
|
||||
DO NOT call searchContentEmbeddings - use queryNodes only for speed.
|
||||
|
||||
3. CREATE EDGES
|
||||
From the search results, pick 2-4 nodes that are clearly related.
|
||||
Call createEdge for each:
|
||||
- from_node_id: focused node ID
|
||||
- to_node_id: related node ID
|
||||
- context: { explanation: "brief reason" }
|
||||
|
||||
4. DONE
|
||||
Reply: "Linked [title] → [list of connected node titles]"
|
||||
|
||||
RULES
|
||||
- Total tool calls ≤ 5 (1 read + 1 search + up to 3 edges)
|
||||
- Use queryNodes only - NO searchContentEmbeddings
|
||||
- Only link nodes with clear, explicit relationships
|
||||
- Skip if no good matches found`;
|
||||
@@ -1,71 +1,63 @@
|
||||
export const INTEGRATE_WORKFLOW_INSTRUCTIONS = `You are executing the INTEGRATE workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Find meaningful connections across the user's knowledge graph and append an Integration Analysis to the node.
|
||||
Find meaningful connections across the user's knowledge graph, create edges, and append an Integration Analysis.
|
||||
|
||||
YOU HAVE DIRECT WRITE ACCESS via updateNode. The tool automatically appends – you cannot overwrite existing content.
|
||||
YOU HAVE DIRECT WRITE ACCESS via updateNode (appends only) and createEdge (creates graph connections).
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
0. PLAN (MANDATORY)
|
||||
- Call think to outline your approach for steps 1–4
|
||||
- Focus on what entities/concepts to extract and how to search for them
|
||||
|
||||
1. RETRIEVE & GROUND THE NODE
|
||||
1. RETRIEVE & UNDERSTAND
|
||||
- Call getNodesById for the focused node
|
||||
- Identify what type of thing this is (person, project, paper, idea, video, tweet, technique, etc.)
|
||||
- Extract key entities: specific names, projects, concepts, techniques mentioned
|
||||
- Summarize the core insight in one sentence
|
||||
- Identify: what type of thing is this? (person, project, paper, idea, video, etc.)
|
||||
- Extract key entities: names, projects, concepts, techniques
|
||||
- Note the core insight in one sentence
|
||||
|
||||
2. SEARCH THE DATABASE FOR CONNECTIONS
|
||||
Search the ENTIRE database:
|
||||
2. SEARCH FOR CONNECTIONS
|
||||
Search the database using entities from step 1:
|
||||
|
||||
a) Obvious structural connections:
|
||||
- If names mentioned → queryNodes to find existing nodes about those people
|
||||
- If projects mentioned → queryNodes to find those project nodes
|
||||
- If specific techniques/tools mentioned → search for those exact terms
|
||||
a) Structural connections:
|
||||
- Names mentioned → queryNodes to find nodes about those people
|
||||
- Projects/tools mentioned → queryNodes to find those nodes
|
||||
|
||||
b) Thematic connections:
|
||||
- Use searchContentEmbeddings with key concepts from step 1
|
||||
- Use searchContentEmbeddings with key concepts
|
||||
- Look for shared themes, complementary ideas, contradictions
|
||||
- Prefer nodes with high-signal relevance over weak matches
|
||||
|
||||
- Aim for 3–8 strong connections, not 20 weak ones
|
||||
- Check existing edges with queryEdge to avoid duplicating connections
|
||||
Target: 3-5 strong connections (quality over quantity)
|
||||
|
||||
3. CONTEXTUALIZE WITH TOP NODES
|
||||
Review the BACKGROUND CONTEXT (top nodes by edge count):
|
||||
- Why might this node matter given the user's focus areas?
|
||||
- Does it advance any themes visible in their most connected nodes?
|
||||
- Keep this brief – 1–2 sentences maximum
|
||||
3. CREATE EDGES
|
||||
For each connection found, call createEdge:
|
||||
- from_node_id: the focused node ID
|
||||
- to_node_id: the connected node ID
|
||||
- context: { explanation: "why this connection matters" }
|
||||
|
||||
4. APPEND INTEGRATION ANALYSIS
|
||||
Call updateNode ONCE with ONLY the new section (do NOT include existing content):
|
||||
The tool handles duplicates gracefully - if edge exists, it returns an error and you continue.
|
||||
|
||||
Create 3-5 edges total.
|
||||
|
||||
4. DOCUMENT IN CONTENT
|
||||
Call updateNode ONCE with ONLY this new section:
|
||||
|
||||
---
|
||||
## Integration Analysis
|
||||
|
||||
[2–3 sentences: what this node is, why it matters, core insight]
|
||||
[2-3 sentences: what this is, why it matters, core insight]
|
||||
|
||||
**Database Connections:**
|
||||
- [NODE:123:"Title"] — [why: authorship/shared concept/dependency/contradiction]
|
||||
- [NODE:456:"Title"] — [why: ...]
|
||||
- [continue for 3–8 connections found in step 2]
|
||||
**Connections:**
|
||||
- [NODE:123:"Title"] — [why connected]
|
||||
- [NODE:456:"Title"] — [why connected]
|
||||
[list all edges created in step 3]
|
||||
|
||||
**Relevance:** [1–2 sentences connecting to user's top nodes/focus areas]
|
||||
|
||||
CRITICAL: Send ONLY this new section. The tool will automatically append it to existing content.
|
||||
|
||||
After ONE successful updateNode call, IMMEDIATELY move to step 5. Do NOT call updateNode again.
|
||||
CRITICAL: Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
5. RETURN SUMMARY
|
||||
Reply with: Task / Actions / Result / Nodes / Follow-up (≤120 words)
|
||||
Reply with: Task / Actions / Result / Nodes / Follow-up (≤100 words)
|
||||
|
||||
CRITICAL RULES
|
||||
- Search the FULL database, not just top nodes
|
||||
- Use entities from step 1 to guide searches in step 2
|
||||
- Call updateNode EXACTLY ONCE - after success, move to step 5 immediately
|
||||
- Keep total tool calls ≤ 18 (be efficient)
|
||||
- Adapt to any node type – don't assume it's always a paper or video
|
||||
RULES
|
||||
- Keep total tool calls ≤ 12
|
||||
- Create edges BEFORE documenting (step 3 before step 4)
|
||||
- Call updateNode exactly once
|
||||
- Adapt to any node type
|
||||
|
||||
The goal: integrate this node into the knowledge graph through meaningful, database-wide connections.`;
|
||||
OPTIONAL: Call think at any point if you need to plan your approach.`;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export const PREP_WORKFLOW_INSTRUCTIONS = `You are executing the PREP workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Quick summary to help the user decide if this content is worth deeper engagement.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. READ THE NODE
|
||||
- Call getNodesById for the focused node
|
||||
- Understand what this is and extract the core message
|
||||
|
||||
2. APPEND BRIEF
|
||||
Call updateNode ONCE with ONLY this section:
|
||||
|
||||
---
|
||||
## Brief
|
||||
|
||||
**What:** [One sentence - what is this?]
|
||||
|
||||
**Gist:** [2-3 sentences - the core message or takeaway]
|
||||
|
||||
**Why it matters:** [1-2 sentences - relevance or implications]
|
||||
|
||||
CRITICAL: Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
3. RETURN SUMMARY
|
||||
Reply with a one-line confirmation: "Prepped [title] - [gist in <10 words]"
|
||||
|
||||
RULES
|
||||
- Keep total tool calls ≤ 3
|
||||
- Call updateNode exactly once
|
||||
- Be concise - this is a quick prep, not deep analysis`;
|
||||
@@ -0,0 +1,41 @@
|
||||
export const RESEARCH_WORKFLOW_INSTRUCTIONS = `You are executing the RESEARCH workflow for the currently focused node.
|
||||
|
||||
MISSION
|
||||
Conduct background research on the topic/person/concept and append findings to the node.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. READ & IDENTIFY
|
||||
- Call getNodesById for the focused node
|
||||
- Identify: what needs researching? (person's background, concept origins, recent developments, etc.)
|
||||
|
||||
2. WEB RESEARCH
|
||||
- Call webSearch with targeted queries (1-2 searches)
|
||||
- Focus on: background context, recent news, authoritative sources
|
||||
- Extract the most relevant findings
|
||||
|
||||
3. APPEND RESEARCH
|
||||
Call updateNode ONCE with ONLY this section:
|
||||
|
||||
---
|
||||
## Research Notes
|
||||
|
||||
**Background:** [2-3 sentences of context]
|
||||
|
||||
**Key Findings:**
|
||||
- [Finding 1]
|
||||
- [Finding 2]
|
||||
- [Finding 3]
|
||||
|
||||
**Sources:** [Brief attribution]
|
||||
|
||||
CRITICAL: Send ONLY the new section. The tool appends automatically.
|
||||
|
||||
4. RETURN SUMMARY
|
||||
Reply with: "Researched [topic] - [key insight in <15 words]"
|
||||
|
||||
RULES
|
||||
- Keep total tool calls ≤ 5
|
||||
- Call updateNode exactly once
|
||||
- Focus on factual background, not opinion
|
||||
- Cite sources when possible`;
|
||||
@@ -0,0 +1,47 @@
|
||||
export const SURVEY_WORKFLOW_INSTRUCTIONS = `You are executing the SURVEY workflow for the currently active dimension.
|
||||
|
||||
MISSION
|
||||
Analyze the active dimension to identify patterns, themes, gaps, and insights across all nodes within it.
|
||||
|
||||
PREREQUISITE: This workflow requires an active dimension. Check ACTIVE DIMENSION section in context.
|
||||
|
||||
WORKFLOW STEPS
|
||||
|
||||
1. RETRIEVE DIMENSION NODES
|
||||
- Call queryDimensionNodes with the active dimension name
|
||||
- Set limit to 50 to get a comprehensive view
|
||||
- Note the total count and most connected nodes
|
||||
|
||||
2. ANALYZE THEMES
|
||||
- Call searchContentEmbeddings with key concepts from the dimension
|
||||
- Identify recurring themes, patterns, and relationships
|
||||
- Note any gaps or underrepresented areas
|
||||
|
||||
3. DOCUMENT FINDINGS
|
||||
Call updateDimension to update the dimension description with:
|
||||
|
||||
**Survey Summary** (append to existing description)
|
||||
|
||||
**Node Count:** [X nodes]
|
||||
**Top Hubs:** [List 3-5 most connected nodes with [NODE:id:"title"]]
|
||||
|
||||
**Themes:**
|
||||
- [Theme 1 - brief description]
|
||||
- [Theme 2 - brief description]
|
||||
|
||||
**Gaps/Opportunities:**
|
||||
- [Identified gap or area for expansion]
|
||||
|
||||
**Connections to Other Dimensions:**
|
||||
- [Any cross-dimension patterns observed]
|
||||
|
||||
4. RETURN SUMMARY
|
||||
Reply with: "Surveyed [dimension] — [X nodes, key insight in <15 words]"
|
||||
|
||||
RULES
|
||||
- Keep total tool calls ≤ 5
|
||||
- Focus on patterns across the dimension, not individual node details
|
||||
- Identify both strengths (dense areas) and gaps (sparse areas)
|
||||
- Reference specific nodes using [NODE:id:"title"] format
|
||||
|
||||
If no active dimension is set, inform the user to open a dimension folder first.`;
|
||||
@@ -8,6 +8,14 @@ import { buildAutoContextBlock } from '@/services/context/autoContext';
|
||||
export interface NodeContext {
|
||||
nodes: Node[];
|
||||
activeNodeId: number | null;
|
||||
activeDimension?: string | null;
|
||||
}
|
||||
|
||||
export interface DimensionContext {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isPriority: boolean;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
export interface ContextBuilderOptions {
|
||||
@@ -151,6 +159,40 @@ export function buildFocusedNodesBlock(
|
||||
return contextString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches dimension context from API or database
|
||||
*/
|
||||
async function fetchDimensionContext(dimensionName: string): Promise<DimensionContext | null> {
|
||||
try {
|
||||
// In server context, we can call the API internally or query directly
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/dimensions/${encodeURIComponent(dimensionName)}/context`);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch dimension context:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dimension context block for the system prompt
|
||||
*/
|
||||
function buildDimensionContextBlock(dimension: DimensionContext | null): string {
|
||||
if (!dimension) return '';
|
||||
|
||||
return `
|
||||
=== ACTIVE DIMENSION ===
|
||||
Dimension: "${dimension.name}"
|
||||
Description: ${dimension.description || 'No description'}
|
||||
Node Count: ${dimension.nodeCount} nodes
|
||||
Priority: ${dimension.isPriority ? 'Yes (locked)' : 'No'}
|
||||
|
||||
Note: Use queryDimensionNodes tool to retrieve actual nodes in this dimension.
|
||||
===================================
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds system prompt as cacheable blocks (Anthropic prompt caching)
|
||||
*/
|
||||
@@ -212,6 +254,15 @@ export async function buildSystemPromptBlocks(
|
||||
});
|
||||
}
|
||||
|
||||
// Add dimension context if an active dimension is provided
|
||||
if (nodeContext.activeDimension) {
|
||||
const dimensionContext = await fetchDimensionContext(nodeContext.activeDimension);
|
||||
const dimensionBlock = buildDimensionContextBlock(dimensionContext);
|
||||
if (dimensionBlock.trim().length > 0) {
|
||||
blocks.push({ type: 'text', text: dimensionBlock });
|
||||
}
|
||||
}
|
||||
|
||||
const focusBlock = buildFocusedNodesBlock(nodeContext, options);
|
||||
blocks.push({ type: 'text', text: focusBlock });
|
||||
|
||||
|
||||
@@ -1,20 +1,68 @@
|
||||
import { INTEGRATE_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/integrate';
|
||||
import { PREP_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/prep';
|
||||
import { RESEARCH_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/research';
|
||||
import { CONNECT_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/connect';
|
||||
import { SURVEY_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/survey';
|
||||
import type { WorkflowDefinition } from './types';
|
||||
import { listUserWorkflows, loadUserWorkflow } from './workflowFileService';
|
||||
|
||||
// Bundled default workflows (always available as fallback)
|
||||
const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
|
||||
'integrate': {
|
||||
'prep': {
|
||||
id: 1,
|
||||
key: 'prep',
|
||||
displayName: 'Prep',
|
||||
description: 'Quick summary to decide if content is worth deeper engagement',
|
||||
instructions: PREP_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Brief section appended with what/gist/why it matters',
|
||||
},
|
||||
'research': {
|
||||
id: 2,
|
||||
key: 'research',
|
||||
displayName: 'Research',
|
||||
description: 'Background research on topic, person, or concept',
|
||||
instructions: RESEARCH_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Research notes appended with background and key findings',
|
||||
},
|
||||
'connect': {
|
||||
id: 3,
|
||||
key: 'connect',
|
||||
displayName: 'Connect',
|
||||
description: 'Find and create edges to related nodes',
|
||||
instructions: CONNECT_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: '3-5 edges created to related nodes',
|
||||
},
|
||||
'integrate': {
|
||||
id: 4,
|
||||
key: 'integrate',
|
||||
displayName: 'Integrate',
|
||||
description: 'Deep analysis and connection-building for focused node',
|
||||
description: 'Full analysis, connection discovery, and documentation',
|
||||
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: true,
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created',
|
||||
}
|
||||
expectedOutcome: 'Integration analysis appended; 3-5 edges created',
|
||||
},
|
||||
'survey': {
|
||||
id: 5,
|
||||
key: 'survey',
|
||||
displayName: 'Survey',
|
||||
description: 'Analyze dimension patterns, themes, and gaps',
|
||||
instructions: SURVEY_WORKFLOW_INSTRUCTIONS,
|
||||
enabled: true,
|
||||
requiresFocusedNode: false, // Requires active dimension, not focused node
|
||||
primaryActor: 'oracle',
|
||||
expectedOutcome: 'Dimension description updated with survey findings',
|
||||
},
|
||||
};
|
||||
|
||||
// Set of bundled workflow keys (for UI to know which can be "reset to default")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { formatNodeForChat } from '../infrastructure/nodeFormatter';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
export const queryDimensionNodesTool = tool({
|
||||
description: 'Query all nodes within a specific dimension. Returns nodes sorted by edge count (most connected first).',
|
||||
inputSchema: z.object({
|
||||
dimension: z.string().describe('The dimension name to query nodes from'),
|
||||
limit: z.number().optional().default(20).describe('Maximum number of nodes to return (default: 20)'),
|
||||
offset: z.number().optional().default(0).describe('Number of nodes to skip for pagination'),
|
||||
includeContent: z.boolean().optional().default(false).describe('Include truncated content preview (default: false)'),
|
||||
}),
|
||||
execute: async ({ dimension, limit = 20, offset = 0, includeContent = false }) => {
|
||||
try {
|
||||
// Query nodes with this dimension
|
||||
const nodes = await nodeService.getNodes({
|
||||
dimensions: [dimension],
|
||||
limit,
|
||||
offset,
|
||||
sortBy: 'edges',
|
||||
});
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
dimension,
|
||||
nodes: [],
|
||||
total: 0,
|
||||
message: `No nodes found in dimension "${dimension}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const formattedNodes = nodes.map((node: Node) => {
|
||||
const formatted: Record<string, unknown> = {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
label: formatNodeForChat({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
}),
|
||||
edgeCount: node.edge_count || 0,
|
||||
dimensions: node.dimensions || [],
|
||||
};
|
||||
|
||||
if (includeContent && node.content) {
|
||||
// Truncate to ~100 chars
|
||||
formatted.contentPreview = node.content.length > 100
|
||||
? node.content.substring(0, 100) + '...'
|
||||
: node.content;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dimension,
|
||||
nodes: formattedNodes,
|
||||
total: nodes.length,
|
||||
hasMore: nodes.length >= limit,
|
||||
message: `Found ${nodes.length} nodes in dimension "${dimension}"`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('queryDimensionNodes error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to query dimension nodes',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { nodeService } from '@/services/database/nodes';
|
||||
import { edgeService } from '@/services/database/edges';
|
||||
|
||||
/**
|
||||
* Quick Link Tool - Creates edges to related nodes in ONE operation
|
||||
* No agent loop, no LLM reasoning - just fast database operations
|
||||
*/
|
||||
export const quickLinkTool = tool({
|
||||
description: 'Instantly find and link related nodes based on title/content matches. Fast - no AI reasoning, just database search and edge creation.',
|
||||
inputSchema: z.object({
|
||||
node_id: z.number().describe('The node ID to find connections for'),
|
||||
max_edges: z.number().optional().default(3).describe('Maximum edges to create (default 3)'),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
const { node_id, max_edges = 3 } = params;
|
||||
try {
|
||||
// 1. Get the source node
|
||||
const sourceNode = await nodeService.getNodeById(node_id);
|
||||
if (!sourceNode) {
|
||||
return { success: false, error: `Node ${node_id} not found`, edgesCreated: 0 };
|
||||
}
|
||||
|
||||
// 2. Extract search terms from title (split on common separators)
|
||||
const title = sourceNode.title || '';
|
||||
const searchTerms = extractSearchTerms(title);
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No searchable terms found in title',
|
||||
edgesCreated: 0,
|
||||
searchTerms: []
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Search for related nodes using each term
|
||||
const foundNodeIds = new Set<number>();
|
||||
const matchDetails: Array<{ nodeId: number; title: string; matchedTerm: string }> = [];
|
||||
|
||||
for (const term of searchTerms.slice(0, 3)) { // Max 3 search terms
|
||||
const results = await nodeService.getNodes({
|
||||
search: term,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
for (const node of results) {
|
||||
// Skip self and already found
|
||||
if (node.id === node_id || foundNodeIds.has(node.id)) continue;
|
||||
|
||||
foundNodeIds.add(node.id);
|
||||
matchDetails.push({
|
||||
nodeId: node.id,
|
||||
title: node.title || `Node ${node.id}`,
|
||||
matchedTerm: term
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Get existing edges to avoid duplicates
|
||||
const existingConnections = await edgeService.getNodeConnections(node_id);
|
||||
const existingTargets = new Set(existingConnections.map(c => c.connected_node.id));
|
||||
|
||||
// 5. Create edges for top matches (excluding existing)
|
||||
const edgesToCreate = matchDetails
|
||||
.filter(m => !existingTargets.has(m.nodeId))
|
||||
.slice(0, max_edges);
|
||||
|
||||
const createdEdges: Array<{ toNodeId: number; toTitle: string }> = [];
|
||||
|
||||
for (const match of edgesToCreate) {
|
||||
try {
|
||||
await edgeService.createEdge({
|
||||
from_node_id: node_id,
|
||||
to_node_id: match.nodeId,
|
||||
source: 'helper_name',
|
||||
context: { quickLink: true, matchedTerm: match.matchedTerm }
|
||||
});
|
||||
createdEdges.push({ toNodeId: match.nodeId, toTitle: match.title });
|
||||
} catch (err) {
|
||||
// Edge might already exist, continue
|
||||
console.warn(`[quickLink] Failed to create edge to ${match.nodeId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Return summary
|
||||
const skippedCount = matchDetails.filter(m => existingTargets.has(m.nodeId)).length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sourceNode: { id: node_id, title: sourceNode.title },
|
||||
searchTerms,
|
||||
edgesCreated: createdEdges.length,
|
||||
edges: createdEdges,
|
||||
skippedExisting: skippedCount,
|
||||
totalMatches: matchDetails.length,
|
||||
message: createdEdges.length > 0
|
||||
? `Linked to ${createdEdges.length} nodes: ${createdEdges.map(e => e.toTitle).join(', ')}`
|
||||
: skippedCount > 0
|
||||
? `Found ${skippedCount} matches but edges already exist`
|
||||
: `No related nodes found for: ${searchTerms.join(', ')}`
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Quick link failed',
|
||||
edgesCreated: 0
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract meaningful search terms from a title
|
||||
* Looks for: names, quoted phrases, capitalized words, etc.
|
||||
*/
|
||||
function extractSearchTerms(title: string): string[] {
|
||||
const terms: string[] = [];
|
||||
|
||||
// 1. Extract quoted phrases
|
||||
const quotedMatches = title.match(/["']([^"']+)["']/g);
|
||||
if (quotedMatches) {
|
||||
terms.push(...quotedMatches.map(m => m.replace(/["']/g, '').trim()));
|
||||
}
|
||||
|
||||
// 2. Extract potential names (2-3 capitalized words in sequence)
|
||||
const namePattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b/g;
|
||||
let match;
|
||||
while ((match = namePattern.exec(title)) !== null) {
|
||||
const name = match[1];
|
||||
// Skip common non-name phrases
|
||||
if (!isCommonPhrase(name)) {
|
||||
terms.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract standalone capitalized words (potential company/project names)
|
||||
const words = title.split(/\s+/);
|
||||
for (const word of words) {
|
||||
const cleaned = word.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (cleaned.length > 2 && /^[A-Z]/.test(cleaned) && !terms.includes(cleaned)) {
|
||||
terms.push(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe and limit
|
||||
const unique = [...new Set(terms)].filter(t => t.length > 2);
|
||||
return unique.slice(0, 5);
|
||||
}
|
||||
|
||||
function isCommonPhrase(phrase: string): boolean {
|
||||
const common = [
|
||||
'The', 'How', 'What', 'Why', 'When', 'Where', 'Which',
|
||||
'New', 'First', 'Last', 'Next', 'This', 'That',
|
||||
'Part', 'Chapter', 'Section', 'Episode'
|
||||
];
|
||||
return common.some(c => phrase.startsWith(c + ' ') || phrase === c);
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
queryNodes: 'core',
|
||||
getNodesById: 'core',
|
||||
queryEdge: 'core',
|
||||
queryDimensions: 'core',
|
||||
getDimension: 'core',
|
||||
queryDimensionNodes: 'core',
|
||||
searchContentEmbeddings: 'core',
|
||||
|
||||
// Orchestration: Delegation and reasoning (orchestrator only)
|
||||
@@ -47,12 +50,16 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
|
||||
delegateNodeComparison: 'orchestration',
|
||||
delegateToWiseRAH: 'orchestration',
|
||||
executeWorkflow: 'orchestration',
|
||||
listWorkflows: 'orchestration',
|
||||
getWorkflow: 'orchestration',
|
||||
editWorkflow: 'orchestration',
|
||||
|
||||
// Execution: Write operations and extraction (workers only)
|
||||
createNode: 'execution',
|
||||
updateNode: 'execution',
|
||||
createEdge: 'execution',
|
||||
updateEdge: 'execution',
|
||||
quickLink: 'execution',
|
||||
embedContent: 'execution',
|
||||
youtubeExtract: 'execution',
|
||||
websiteExtract: 'execution',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { updateNodeTool } from '../database/updateNode';
|
||||
import { createEdgeTool } from '../database/createEdge';
|
||||
import { queryEdgeTool } from '../database/queryEdge';
|
||||
import { updateEdgeTool } from '../database/updateEdge';
|
||||
import { quickLinkTool } from '../database/quickLink';
|
||||
import { createDimensionTool } from '../database/createDimension';
|
||||
import { updateDimensionTool } from '../database/updateDimension';
|
||||
import { lockDimensionTool } from '../database/lockDimension';
|
||||
@@ -13,6 +14,7 @@ import { unlockDimensionTool } from '../database/unlockDimension';
|
||||
import { deleteDimensionTool } from '../database/deleteDimension';
|
||||
import { queryDimensionsTool } from '../database/queryDimensions';
|
||||
import { getDimensionTool } from '../database/getDimension';
|
||||
import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
|
||||
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
|
||||
import { webSearchTool } from '../other/webSearch';
|
||||
import { thinkTool } from '../other/think';
|
||||
@@ -20,6 +22,9 @@ import { delegateToMiniRAHTool } from '../orchestration/delegateToMiniRAH';
|
||||
import { delegateNodeQuotesTool, delegateNodeComparisonTool } from '../orchestration/delegationHelpers';
|
||||
import { delegateToWiseRAHTool } from '../orchestration/delegateToWiseRAH';
|
||||
import { executeWorkflowTool } from '../orchestration/executeWorkflow';
|
||||
import { listWorkflowsTool } from '../orchestration/listWorkflows';
|
||||
import { getWorkflowTool } from '../orchestration/getWorkflow';
|
||||
import { editWorkflowTool } from '../orchestration/editWorkflow';
|
||||
import { youtubeExtractTool } from '../other/youtubeExtract';
|
||||
import { websiteExtractTool } from '../other/websiteExtract';
|
||||
import { paperExtractTool } from '../other/paperExtract';
|
||||
@@ -32,6 +37,7 @@ const CORE_TOOLS: Record<string, any> = {
|
||||
queryEdge: queryEdgeTool,
|
||||
queryDimensions: queryDimensionsTool,
|
||||
getDimension: getDimensionTool,
|
||||
queryDimensionNodes: queryDimensionNodesTool,
|
||||
searchContentEmbeddings: searchContentEmbeddingsTool,
|
||||
};
|
||||
|
||||
@@ -43,6 +49,9 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
|
||||
delegateNodeComparison: delegateNodeComparisonTool,
|
||||
delegateToWiseRAH: delegateToWiseRAHTool,
|
||||
executeWorkflow: executeWorkflowTool,
|
||||
listWorkflows: listWorkflowsTool,
|
||||
getWorkflow: getWorkflowTool,
|
||||
editWorkflow: editWorkflowTool,
|
||||
};
|
||||
|
||||
// Execution tools for worker agents (includes write operations)
|
||||
@@ -51,6 +60,7 @@ const EXECUTION_TOOLS: Record<string, any> = {
|
||||
updateNode: updateNodeTool,
|
||||
createEdge: createEdgeTool,
|
||||
updateEdge: updateEdgeTool,
|
||||
quickLink: quickLinkTool,
|
||||
createDimension: createDimensionTool,
|
||||
updateDimension: updateDimensionTool,
|
||||
lockDimension: lockDimensionTool,
|
||||
@@ -78,10 +88,14 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
|
||||
'webSearch',
|
||||
'think',
|
||||
'executeWorkflow',
|
||||
'listWorkflows',
|
||||
'getWorkflow',
|
||||
'editWorkflow',
|
||||
'createNode',
|
||||
'updateNode',
|
||||
'createEdge',
|
||||
'updateEdge',
|
||||
'quickLink',
|
||||
'createDimension',
|
||||
'updateDimension',
|
||||
'lockDimension',
|
||||
@@ -110,7 +124,9 @@ const PLANNER_TOOL_NAMES = [
|
||||
'think',
|
||||
'delegateToMiniRAH',
|
||||
'updateNode', // For workflow execution (integrate workflow needs direct write access)
|
||||
'createEdge', // For Quick Link workflow
|
||||
'createEdge', // For edge creation in workflows
|
||||
'quickLink', // Fast edge creation via text search
|
||||
'updateDimension', // For survey workflow (dimension analysis)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { saveWorkflow, loadUserWorkflow } from '@/services/workflows/workflowFileService';
|
||||
|
||||
export const editWorkflowTool = tool({
|
||||
description: 'Update a workflow definition. Use to refine instructions, update description, or toggle enabled state.',
|
||||
inputSchema: z.object({
|
||||
workflowKey: z.string().describe('The key of the workflow to edit'),
|
||||
updates: z.object({
|
||||
displayName: z.string().optional().describe('New display name'),
|
||||
description: z.string().optional().describe('New description'),
|
||||
instructions: z.string().optional().describe('New instructions (full replacement)'),
|
||||
enabled: z.boolean().optional().describe('Enable or disable the workflow'),
|
||||
requiresFocusedNode: z.boolean().optional().describe('Whether workflow requires a focused node'),
|
||||
}).describe('Fields to update'),
|
||||
}),
|
||||
execute: async ({ workflowKey, updates }) => {
|
||||
try {
|
||||
// Get existing workflow (from user file or bundled default)
|
||||
const existing = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' not found`,
|
||||
};
|
||||
}
|
||||
|
||||
// Merge updates with existing values
|
||||
const updated = {
|
||||
key: workflowKey,
|
||||
displayName: updates.displayName ?? existing.displayName,
|
||||
description: updates.description ?? existing.description,
|
||||
instructions: updates.instructions ?? existing.instructions,
|
||||
enabled: updates.enabled ?? existing.enabled,
|
||||
requiresFocusedNode: updates.requiresFocusedNode ?? existing.requiresFocusedNode,
|
||||
};
|
||||
|
||||
// Save to user workflow file (this will override bundled if same key)
|
||||
saveWorkflow(updated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Workflow '${workflowKey}' updated`,
|
||||
workflow: {
|
||||
key: updated.key,
|
||||
displayName: updated.displayName,
|
||||
description: updated.description,
|
||||
enabled: updated.enabled,
|
||||
requiresFocusedNode: updated.requiresFocusedNode,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('editWorkflow error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to edit workflow',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
|
||||
import { userWorkflowExists } from '@/services/workflows/workflowFileService';
|
||||
|
||||
export const getWorkflowTool = tool({
|
||||
description: 'Retrieve a workflow definition including its full instructions',
|
||||
inputSchema: z.object({
|
||||
workflowKey: z.string().describe('The key of the workflow to retrieve (e.g., "integrate", "prep")'),
|
||||
}),
|
||||
execute: async ({ workflowKey }) => {
|
||||
try {
|
||||
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
|
||||
|
||||
if (!workflow) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Workflow '${workflowKey}' not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflow: {
|
||||
key: workflow.key,
|
||||
displayName: workflow.displayName,
|
||||
description: workflow.description,
|
||||
instructions: workflow.instructions,
|
||||
enabled: workflow.enabled,
|
||||
requiresFocusedNode: workflow.requiresFocusedNode,
|
||||
isBundled: BUNDLED_WORKFLOW_KEYS.has(workflow.key),
|
||||
hasUserOverride: userWorkflowExists(workflow.key),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getWorkflow error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get workflow',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
|
||||
|
||||
export const listWorkflowsTool = tool({
|
||||
description: 'List all available workflows',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
const workflows = await WorkflowRegistry.getAllWorkflows();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflows: workflows.map(w => ({
|
||||
key: w.key,
|
||||
displayName: w.displayName,
|
||||
description: w.description,
|
||||
enabled: w.enabled,
|
||||
requiresFocusedNode: w.requiresFocusedNode,
|
||||
isBundled: BUNDLED_WORKFLOW_KEYS.has(w.key),
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('listWorkflows error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list workflows',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user