feat(rah-light): replace workflows with guides system

- Remove entire workflow system (API routes, services, tools, components)
- Add guides system from main ra-h repo (for external agent integration)
- Update pane types: 'workflows' → 'guides' throughout
- Update LeftToolbar to show guides icon instead of workflows
- Update SettingsModal with GuidesViewer tab
- Add GuidesPane for browsing guides in main UI
- Clean up prompts to remove workflow references
- Update tool registry to remove workflow tools
- Add gray-matter package for frontmatter parsing

Guides are essential for RA-H Light as they help external agents
(via MCP) understand the knowledge base context and usage patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-29 16:29:24 +11:00
co-authored by Claude Opus 4.5
parent f383d770ca
commit b31441b35f
46 changed files with 1126 additions and 2189 deletions
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { readGuide } from '@/services/guides/guideService';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const guide = readGuide(name);
if (!guide) {
return NextResponse.json(
{ success: false, error: `Guide "${name}" not found` },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: guide });
} catch (error) {
console.error('[API /guides/[name]] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to read guide' },
{ status: 500 }
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { listGuides } from '@/services/guides/guideService';
export const runtime = 'nodejs';
export async function GET() {
try {
const guides = listGuides();
return NextResponse.json({ success: true, data: guides });
} catch (error) {
console.error('[API /guides] error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to list guides' },
{ status: 500 }
);
}
}
-132
View File
@@ -1,132 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { saveWorkflow, deleteWorkflow, userWorkflowExists } from '@/services/workflows/workflowFileService';
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
// PUT /api/workflows/[key] - Create or update a workflow
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
const body = await request.json();
// Validate required fields
if (!body.displayName || typeof body.displayName !== 'string') {
return NextResponse.json(
{ success: false, error: 'displayName is required' },
{ status: 400 }
);
}
if (!body.instructions || typeof body.instructions !== 'string') {
return NextResponse.json(
{ success: false, error: 'instructions is required' },
{ status: 400 }
);
}
// Save the workflow
saveWorkflow({
key,
displayName: body.displayName.trim(),
description: body.description?.trim() || '',
instructions: body.instructions,
enabled: body.enabled !== false,
requiresFocusedNode: body.requiresFocusedNode !== false,
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error saving workflow:', error);
return NextResponse.json(
{ success: false, error: 'Failed to save workflow' },
{ status: 500 }
);
}
}
// DELETE /api/workflows/[key] - Delete a workflow (resets to default if bundled)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
// Check if user file exists
if (!userWorkflowExists(key)) {
// If it's a bundled workflow, nothing to delete
if (BUNDLED_WORKFLOW_KEYS.has(key)) {
return NextResponse.json(
{ success: false, error: 'Cannot delete bundled workflow (no user override exists)' },
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, error: 'Workflow not found' },
{ status: 404 }
);
}
const deleted = deleteWorkflow(key);
if (!deleted) {
return NextResponse.json(
{ success: false, error: 'Failed to delete workflow' },
{ status: 500 }
);
}
// If this was a bundled workflow, it will now fall back to default
const isBundled = BUNDLED_WORKFLOW_KEYS.has(key);
return NextResponse.json({
success: true,
resetToDefault: isBundled,
});
} catch (error) {
console.error('Error deleting workflow:', error);
return NextResponse.json(
{ success: false, error: 'Failed to delete workflow' },
{ status: 500 }
);
}
}
// GET /api/workflows/[key] - Get a single workflow
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
const workflow = await WorkflowRegistry.getWorkflowByKey(key);
if (!workflow) {
return NextResponse.json(
{ success: false, error: 'Workflow not found' },
{ status: 404 }
);
}
// Include metadata about whether it's user-modified
const hasUserOverride = userWorkflowExists(key);
const isBundled = BUNDLED_WORKFLOW_KEYS.has(key);
return NextResponse.json({
success: true,
data: {
...workflow,
isBundled,
hasUserOverride,
},
});
} catch (error) {
console.error('Error fetching workflow:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch workflow' },
{ status: 500 }
);
}
}
-146
View File
@@ -1,146 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { WorkflowRegistry } from '@/services/workflows/registry';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
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)'}`;
const sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try {
// Execute workflow synchronously
const result = await WorkflowExecutor.execute({
sessionId,
task,
context: contextLines,
expectedOutcome: workflow.expectedOutcome,
workflowKey,
workflowNodeId: nodeId,
});
return NextResponse.json({
success: true,
sessionId,
workflowKey,
nodeId: nodeId || null,
status: 'completed',
summary: result.summary,
message: `Workflow '${workflow.displayName}' completed`,
});
} catch (error) {
console.error('[/api/workflows/execute] Execution failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ success: false, error: `Workflow execution failed: ${errorMessage}` },
{ status: 500 }
);
}
} catch (error) {
console.error('Error executing workflow:', error);
return NextResponse.json(
{ success: false, error: 'Failed to execute workflow' },
{ status: 500 }
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import { NextResponse } from 'next/server';
import { WorkflowRegistry } from '@/services/workflows/registry';
export async function GET() {
try {
const workflows = await WorkflowRegistry.getAllWorkflows();
return NextResponse.json({
success: true,
data: workflows,
});
} catch (error) {
console.error('Error fetching workflows:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch workflows' },
{ status: 500 }
);
}
}
+109
View File
@@ -16,6 +16,7 @@
"ai": "^6.0.27",
"better-sqlite3": "^12.2.0",
"cheerio": "^1.1.2",
"gray-matter": "^4.0.3",
"lucide-react": "^0.468.0",
"next": "15.1.3",
"openai": "^4.103.0",
@@ -5056,6 +5057,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -5171,6 +5185,18 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5607,6 +5633,43 @@
"dev": true,
"license": "MIT"
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/gray-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -6100,6 +6163,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -6526,6 +6598,15 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/langsmith": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.5.tgz",
@@ -9131,6 +9212,19 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -9418,6 +9512,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -9620,6 +9720,15 @@
"node": ">=4"
}
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+1
View File
@@ -28,6 +28,7 @@
"ai": "^6.0.27",
"better-sqlite3": "^12.2.0",
"cheerio": "^1.1.2",
"gray-matter": "^4.0.3",
"lucide-react": "^0.468.0",
"next": "15.1.3",
"openai": "^4.103.0",
+4 -4
View File
@@ -7,7 +7,7 @@ import {
LayoutList,
Map,
Folder,
Workflow,
FileText,
Settings,
} from 'lucide-react';
import type { PaneType } from '../panes/types';
@@ -27,18 +27,18 @@ const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
views: LayoutList,
map: Map,
dimensions: Folder,
workflows: Workflow,
guides: FileText,
};
const PANE_TYPE_LABELS: Record<string, string> = {
views: 'Feed',
map: 'Map',
dimensions: 'Dimensions',
workflows: 'Workflows',
guides: 'Guides',
};
// Pane types shown in the toolbar (excludes 'node' which is opened via Feed, chat removed in rah-light)
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'workflows'];
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'guides'];
interface ToolbarButtonProps {
icon: typeof Search;
+5 -13
View File
@@ -26,7 +26,7 @@ import LeftToolbar from './LeftToolbar';
import SplitHandle from './SplitHandle';
// Pane components (ChatPane removed in rah-light)
import { NodePane, WorkflowsPane, DimensionsPane, MapPane, ViewsPane } from '../panes';
import { NodePane, GuidesPane, DimensionsPane, MapPane, ViewsPane } from '../panes';
import QuickAddInput from '../agents/QuickAddInput';
import type { PaneType, SlotState, PaneAction } from '../panes/types';
@@ -258,9 +258,9 @@ export default function ThreePanelLayout() {
// Delegation events ignored (delegation system removed in rah-light)
break;
case 'WORKFLOW_PROGRESS':
case 'GUIDE_UPDATED':
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('workflow:progress', { detail: data.data }));
window.dispatchEvent(new CustomEvent('guides:updated', { detail: data.data }));
}
break;
@@ -807,22 +807,14 @@ export default function ThreePanelLayout() {
// case 'chat' removed in rah-light
case 'workflows':
case 'guides':
return (
<WorkflowsPane
<GuidesPane
slot={slot}
isActive={isActive}
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
onCollapse={onCollapse}
onSwapPanes={slotB ? handleSwapPanes : undefined}
delegations={delegations}
onNodeClick={(nodeId) => {
handleNodeSelect(nodeId, false);
setActivePane(slot);
}}
openTabsData={openTabsData}
activeTabId={activeTab}
activeDimension={activeDimension}
/>
);
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useState, useEffect } from 'react';
import { ArrowLeft } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import PaneHeader from './PaneHeader';
import type { BasePaneProps } from './types';
interface GuideMeta {
name: string;
description: string;
}
interface Guide extends GuideMeta {
content: string;
}
export default function GuidesPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
}: BasePaneProps) {
const [guides, setGuides] = useState<GuideMeta[]>([]);
const [selectedGuide, setSelectedGuide] = useState<Guide | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchGuides();
const handleGuideUpdated = () => { fetchGuides(); };
window.addEventListener('guides:updated', handleGuideUpdated);
return () => window.removeEventListener('guides:updated', handleGuideUpdated);
}, []);
const fetchGuides = async () => {
try {
const res = await fetch('/api/guides');
const data = await res.json();
if (data.success) {
setGuides(data.data);
}
} catch (err) {
console.error('[GuidesPane] Failed to fetch guides:', err);
} finally {
setLoading(false);
}
};
const handleSelectGuide = async (name: string) => {
try {
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`);
const data = await res.json();
if (data.success) {
setSelectedGuide(data.data);
}
} catch (err) {
console.error('[GuidesPane] Failed to fetch guide:', err);
}
};
const handleBack = () => {
setSelectedGuide(null);
};
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
{selectedGuide && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={handleBack}
style={{
background: 'none',
border: 'none',
color: '#888',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '4px',
borderRadius: '4px',
}}
onMouseEnter={e => { e.currentTarget.style.color = '#ccc'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#888'; }}
>
<ArrowLeft size={16} />
</button>
<span style={{ color: '#ccc', fontSize: '13px', fontWeight: 500 }}>
{selectedGuide.name}
</span>
</div>
)}
</PaneHeader>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
{loading ? (
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
Loading...
</div>
) : selectedGuide ? (
<div className="guide-content" style={{ color: '#ccc', fontSize: '13px', lineHeight: '1.6' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 style={{ fontSize: '18px', fontWeight: 600, color: '#eee', margin: '0 0 16px 0' }}>{children}</h1>
),
h2: ({ children }) => (
<h2 style={{ fontSize: '15px', fontWeight: 600, color: '#ddd', margin: '20px 0 8px 0' }}>{children}</h2>
),
h3: ({ children }) => (
<h3 style={{ fontSize: '14px', fontWeight: 600, color: '#ccc', margin: '16px 0 6px 0' }}>{children}</h3>
),
p: ({ children }) => (
<p style={{ margin: '0 0 12px 0' }}>{children}</p>
),
ul: ({ children }) => (
<ul style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ol>
),
li: ({ children }) => (
<li style={{ margin: '0 0 4px 0' }}>{children}</li>
),
code: ({ className, children, ...props }) => {
const isInline = !className;
if (isInline) {
return (
<code style={{
background: '#1a1a1a',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
color: '#22c55e',
}} {...props}>{children}</code>
);
}
return (
<code style={{
display: 'block',
background: '#0d0d0d',
padding: '12px',
borderRadius: '6px',
fontSize: '12px',
overflowX: 'auto',
margin: '0 0 12px 0',
color: '#aaa',
whiteSpace: 'pre-wrap',
}} {...props}>{children}</code>
);
},
pre: ({ children }) => (
<pre style={{ margin: '0 0 12px 0' }}>{children}</pre>
),
strong: ({ children }) => (
<strong style={{ color: '#eee', fontWeight: 600 }}>{children}</strong>
),
hr: () => (
<hr style={{ border: 'none', borderTop: '1px solid #2a2a2a', margin: '16px 0' }} />
),
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: '3px solid #333',
paddingLeft: '12px',
margin: '0 0 12px 0',
color: '#999',
}}>{children}</blockquote>
),
}}
>
{selectedGuide.content}
</ReactMarkdown>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{guides.length === 0 ? (
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
No guides found
</div>
) : (
guides.map((guide) => (
<button
key={guide.name}
onClick={() => handleSelectGuide(guide.name)}
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
padding: '12px',
background: '#161616',
border: '1px solid #222',
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#333';
}}
onMouseLeave={e => {
e.currentTarget.style.background = '#161616';
e.currentTarget.style.borderColor = '#222';
}}
>
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 500 }}>
{guide.name}
</span>
<span style={{ color: '#777', fontSize: '12px', lineHeight: '1.4' }}>
{guide.description}
</span>
</button>
))
)}
</div>
)}
</div>
</div>
);
}
-93
View File
@@ -1,93 +0,0 @@
"use client";
import { useState } from 'react';
import PaneHeader from './PaneHeader';
import { WorkflowsPaneProps, AgentDelegation } from './types';
export default function WorkflowsPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
delegations,
onNodeClick,
openTabsData = [],
activeTabId = null,
activeDimension,
}: WorkflowsPaneProps) {
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<WorkflowsListView />
</div>
</div>
);
}
// Workflows list view - simplified for rah-light (delegation system removed)
function WorkflowsListView() {
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
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>
</div>
{/* Content */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '16px'
}}>
<div style={{
color: '#666',
fontSize: '12px',
textAlign: 'center',
padding: '40px 20px',
maxWidth: '300px'
}}>
<p style={{ marginBottom: '16px' }}>
Workflows are available via MCP server integration.
</p>
<p style={{ color: '#555', fontSize: '11px' }}>
Connect your coding agent (Claude Code, Cursor, etc.) via MCP to run workflows like Integrate, Extract, and more.
</p>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
export { default as NodePane } from './NodePane';
export { default as WorkflowsPane } from './WorkflowsPane';
export { default as GuidesPane } from './GuidesPane';
export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane';
+4 -11
View File
@@ -15,7 +15,7 @@ export type AgentDelegation = {
};
// The five pane types (chat removed in rah-light)
export type PaneType = 'node' | 'workflows' | 'dimensions' | 'map' | 'views';
export type PaneType = 'node' | 'guides' | 'dimensions' | 'map' | 'views';
// State for each slot
export interface SlotState {
@@ -68,14 +68,7 @@ export interface HighlightedPassage {
// ChatPaneProps removed in rah-light
// WorkflowsPane specific props
export interface WorkflowsPaneProps extends BasePaneProps {
delegations: AgentDelegation[];
onNodeClick?: (nodeId: number) => void;
openTabsData?: Node[];
activeTabId?: number | null;
activeDimension?: string | null;
}
// GuidesPaneProps - just uses BasePaneProps (guides are self-contained)
// DimensionsPane specific props
export interface DimensionsPaneProps extends BasePaneProps {
@@ -110,7 +103,7 @@ export interface PaneHeaderProps {
// Labels for pane types
export const PANE_LABELS: Record<PaneType, string> = {
node: 'Nodes',
workflows: 'Workflows',
guides: 'Guides',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',
@@ -124,5 +117,5 @@ export const DEFAULT_SLOT_A: SlotState = {
};
export const DEFAULT_SLOT_B: SlotState = {
type: 'workflows',
type: 'guides',
};
+1 -1
View File
@@ -73,7 +73,7 @@ export default function ContextViewer() {
return (
<div style={containerStyle}>
<p style={descStyle}>
Top 10 most-connected nodes are added to background context for workflow execution.
Top 10 most-connected nodes are added to background context for tool execution.
</p>
{/* Toggle */}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Save, X, FileText } from 'lucide-react';
interface GuideMeta {
name: string;
description: string;
}
interface Guide extends GuideMeta {
content: string;
}
export default function GuidesViewer() {
const [guides, setGuides] = useState<GuideMeta[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Guide | null>(null);
const [isNew, setIsNew] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchGuides();
}, []);
const fetchGuides = async () => {
try {
const res = await fetch('/api/guides');
const data = await res.json();
if (data.success) {
setGuides(data.data);
}
} catch (err) {
console.error('Failed to fetch guides:', err);
} finally {
setLoading(false);
}
};
const handleEdit = async (name: string) => {
try {
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`);
const data = await res.json();
if (data.success) {
setEditing(data.data);
setIsNew(false);
setError(null);
}
} catch (err) {
console.error('Failed to fetch guide:', err);
}
};
const handleNew = () => {
setEditing({
name: '',
description: '',
content: '# New Guide\n\nWrite your guide content here...',
});
setIsNew(true);
setError(null);
};
const handleSave = async () => {
if (!editing) return;
if (!editing.name.trim()) {
setError('Name is required');
return;
}
try {
const res = await fetch(`/api/guides/${encodeURIComponent(editing.name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: editing.content,
description: editing.description,
}),
});
const data = await res.json();
if (data.success) {
setEditing(null);
fetchGuides();
window.dispatchEvent(new Event('guides:updated'));
} else {
setError(data.error || 'Failed to save');
}
} catch (err) {
setError('Failed to save guide');
}
};
const handleDelete = async (name: string) => {
if (!confirm(`Delete guide "${name}"?`)) return;
try {
const res = await fetch(`/api/guides/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json();
if (data.success) {
fetchGuides();
window.dispatchEvent(new Event('guides:updated'));
}
} catch (err) {
console.error('Failed to delete guide:', err);
}
};
if (loading) {
return (
<div style={{ padding: '24px', color: '#666' }}>Loading guides...</div>
);
}
if (editing) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="Guide name"
disabled={!isNew}
style={{
flex: 1,
padding: '8px 12px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#fff',
fontSize: '14px',
}}
/>
<button
onClick={handleSave}
style={{
padding: '8px 16px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
color: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 500,
}}
>
<Save size={14} /> Save
</button>
<button
onClick={() => setEditing(null)}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #333',
borderRadius: '6px',
color: '#888',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<X size={14} /> Cancel
</button>
</div>
{error && (
<div style={{ color: '#ef4444', fontSize: '13px', marginBottom: '12px' }}>{error}</div>
)}
<input
type="text"
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
placeholder="Brief description"
style={{
padding: '8px 12px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#fff',
fontSize: '13px',
marginBottom: '16px',
}}
/>
<textarea
value={editing.content}
onChange={(e) => setEditing({ ...editing, content: e.target.value })}
placeholder="Guide content (markdown)"
style={{
flex: 1,
padding: '12px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#ccc',
fontSize: '13px',
fontFamily: 'monospace',
resize: 'none',
lineHeight: 1.5,
}}
/>
<p style={{ color: '#666', fontSize: '12px', marginTop: '12px' }}>
Guides are markdown files that external agents can read via MCP tools. Use them to provide context, instructions, or reference material.
</p>
</div>
);
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ color: '#888', fontSize: '13px', margin: 0 }}>
Guides provide context and instructions for external AI agents via MCP.
</p>
<button
onClick={handleNew}
style={{
padding: '8px 16px',
background: '#22c55e',
border: 'none',
borderRadius: '6px',
color: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 500,
}}
>
<Plus size={14} /> New Guide
</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{guides.length === 0 ? (
<div style={{ color: '#555', textAlign: 'center', paddingTop: '48px' }}>
<FileText size={48} style={{ marginBottom: '12px', opacity: 0.5 }} />
<p style={{ fontSize: '14px' }}>No guides yet</p>
<p style={{ fontSize: '12px', color: '#444' }}>Create guides to help external agents understand your knowledge base</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{guides.map((guide) => (
<div
key={guide.name}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: '#161616',
border: '1px solid #222',
borderRadius: '8px',
}}
>
<FileText size={18} style={{ color: '#22c55e', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#ddd', fontSize: '14px', fontWeight: 500 }}>{guide.name}</div>
<div style={{ color: '#666', fontSize: '12px', marginTop: '2px' }}>{guide.description}</div>
</div>
<button
onClick={() => handleEdit(guide.name)}
style={{
padding: '6px',
background: 'transparent',
border: 'none',
color: '#666',
cursor: 'pointer',
borderRadius: '4px',
}}
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(guide.name)}
style={{
padding: '6px',
background: 'transparent',
border: 'none',
color: '#666',
cursor: 'pointer',
borderRadius: '4px',
}}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+9 -9
View File
@@ -4,17 +4,17 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import LogsViewer from './LogsViewer';
import ToolsViewer from './ToolsViewer';
import WorkflowsViewer from './WorkflowsViewer';
import ApiKeysViewer from './ApiKeysViewer';
import DatabaseViewer from './DatabaseViewer';
import ExternalAgentsPanel from './ExternalAgentsPanel';
import ContextViewer from './ContextViewer';
import GuidesViewer from './GuidesViewer';
import { apiKeyService } from '@/services/storage/apiKeys';
export type SettingsTab =
| 'logs'
| 'tools'
| 'workflows'
| 'guides'
| 'apikeys'
| 'database'
| 'context'
@@ -140,18 +140,18 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
Tools
</div>
<div
onClick={() => setActiveTab('workflows')}
onClick={() => setActiveTab('guides')}
style={{
padding: '12px 24px',
fontSize: '14px',
color: activeTab === 'workflows' ? '#fff' : '#888',
background: activeTab === 'workflows' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'workflows' ? '3px solid #22c55e' : '3px solid transparent',
color: activeTab === 'guides' ? '#fff' : '#888',
background: activeTab === 'guides' ? '#1a3a2a' : 'transparent',
borderLeft: activeTab === 'guides' ? '3px solid #22c55e' : '3px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
Workflows
Guides
</div>
<div
onClick={() => setActiveTab('apikeys')}
@@ -295,7 +295,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
>
{activeTab === 'logs' && 'System Logs'}
{activeTab === 'tools' && 'Tools'}
{activeTab === 'workflows' && 'Workflows'}
{activeTab === 'guides' && 'Guides'}
{activeTab === 'apikeys' && 'API Keys'}
{activeTab === 'database' && 'Knowledge Database'}
{activeTab === 'context' && 'Auto-Context'}
@@ -329,7 +329,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
<div style={{ flex: 1, overflow: 'hidden' }}>
{activeTab === 'logs' && <LogsViewer key={isOpen ? 'open' : 'closed'} />}
{activeTab === 'tools' && <ToolsViewer />}
{activeTab === 'workflows' && <WorkflowsViewer />}
{activeTab === 'guides' && <GuidesViewer />}
{activeTab === 'apikeys' && <ApiKeysViewer />}
{activeTab === 'database' && <DatabaseViewer />}
{activeTab === 'context' && <ContextViewer />}
-464
View File
@@ -1,464 +0,0 @@
"use client";
import { useState, useEffect, type CSSProperties } from 'react';
interface WorkflowDefinition {
id: number;
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
primaryActor: 'oracle' | 'main';
expectedOutcome?: string;
isBundled?: boolean;
hasUserOverride?: boolean;
}
interface EditingWorkflow {
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
isNew: boolean;
}
export default function WorkflowsViewer() {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<EditingWorkflow | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadWorkflows = async () => {
try {
const res = await fetch('/api/workflows');
const result = await res.json();
if (result.success) {
// Fetch additional metadata for each workflow
const enriched = await Promise.all(
result.data.map(async (w: WorkflowDefinition) => {
try {
const detailRes = await fetch(`/api/workflows/${w.key}`);
const detail = await detailRes.json();
return detail.success ? detail.data : w;
} catch {
return w;
}
})
);
setWorkflows(enriched);
}
} catch (e) {
console.error('Failed to load workflows:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadWorkflows();
}, []);
const handleEdit = (workflow: WorkflowDefinition) => {
setEditing({
key: workflow.key,
displayName: workflow.displayName,
description: workflow.description,
instructions: workflow.instructions,
enabled: workflow.enabled,
requiresFocusedNode: workflow.requiresFocusedNode,
isNew: false,
});
setError(null);
};
const handleNewWorkflow = () => {
setEditing({
key: '',
displayName: '',
description: '',
instructions: '',
enabled: true,
requiresFocusedNode: true,
isNew: true,
});
setError(null);
};
const handleCancel = () => {
setEditing(null);
setError(null);
};
const generateKey = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
const handleSave = async () => {
if (!editing) return;
const key = editing.isNew ? generateKey(editing.displayName) : editing.key;
if (!key) {
setError('Please enter a workflow name');
return;
}
if (!editing.instructions.trim()) {
setError('Please enter instructions');
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/workflows/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: editing.displayName,
description: editing.description,
instructions: editing.instructions,
enabled: editing.enabled,
requiresFocusedNode: editing.requiresFocusedNode,
}),
});
const result = await res.json();
if (result.success) {
setEditing(null);
await loadWorkflows();
} else {
setError(result.error || 'Failed to save workflow');
}
} catch (e) {
setError('Failed to save workflow');
console.error('Save error:', e);
} finally {
setSaving(false);
}
};
const handleDelete = async (workflow: WorkflowDefinition) => {
const action = workflow.isBundled ? 'reset to default' : 'delete';
if (!confirm(`Are you sure you want to ${action} "${workflow.displayName}"?`)) {
return;
}
try {
const res = await fetch(`/api/workflows/${workflow.key}`, {
method: 'DELETE',
});
const result = await res.json();
if (result.success) {
await loadWorkflows();
} else {
alert(result.error || 'Failed to delete workflow');
}
} catch (e) {
alert('Failed to delete workflow');
console.error('Delete error:', e);
}
};
if (loading) {
return <div style={loadingStyle}>Loading...</div>;
}
// Editing view
if (editing) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<span style={{ fontSize: 14, fontWeight: 500 }}>
{editing.isNew ? 'New Workflow' : `Edit: ${editing.displayName}`}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleCancel} style={buttonStyle} disabled={saving}>
Cancel
</button>
<button onClick={handleSave} style={primaryButtonStyle} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{error && <div style={errorStyle}>{error}</div>}
<div style={formStyle}>
<div style={fieldStyle}>
<label style={labelStyle}>Name</label>
<input
type="text"
value={editing.displayName}
onChange={(e) => setEditing({ ...editing, displayName: e.target.value })}
placeholder="My Workflow"
style={inputStyle}
disabled={saving}
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Description (shown to agent)</label>
<input
type="text"
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
placeholder="Brief description of what this workflow does"
style={inputStyle}
disabled={saving}
/>
<span style={hintStyle}>Keep this short it tells the agent when to use this workflow</span>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Instructions</label>
<textarea
value={editing.instructions}
onChange={(e) => setEditing({ ...editing, instructions: e.target.value })}
placeholder="Enter the workflow instructions..."
style={textareaStyle}
disabled={saving}
/>
</div>
<div style={checkboxRowStyle}>
<label style={checkboxLabelStyle}>
<input
type="checkbox"
checked={editing.enabled}
onChange={(e) => setEditing({ ...editing, enabled: e.target.checked })}
disabled={saving}
/>
Enabled
</label>
<label style={checkboxLabelStyle}>
<input
type="checkbox"
checked={editing.requiresFocusedNode}
onChange={(e) => setEditing({ ...editing, requiresFocusedNode: e.target.checked })}
disabled={saving}
/>
Requires focused node
</label>
</div>
</div>
</div>
);
}
// List view
return (
<div style={containerStyle}>
<div style={headerStyle}>
<p style={descStyle}>Workflows available to the agent.</p>
<button onClick={handleNewWorkflow} style={primaryButtonStyle}>
+ New Workflow
</button>
</div>
{workflows.length === 0 ? (
<div style={emptyStyle}>No workflows defined.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{workflows.map((w) => (
<div key={w.key} style={cardStyle}>
<div style={cardContentStyle}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={titleStyle}>{w.displayName}</span>
<span style={keyStyle}>{w.key}</span>
{w.hasUserOverride && w.isBundled && (
<span style={modifiedBadgeStyle}>modified</span>
)}
{!w.enabled && (
<span style={disabledBadgeStyle}>disabled</span>
)}
</div>
<div style={descRowStyle}>{w.description}</div>
</div>
<div style={actionsStyle}>
<button onClick={() => handleEdit(w)} style={buttonStyle}>
Edit
</button>
{/* Show delete for user-created, or reset for modified bundled */}
{(!w.isBundled || w.hasUserOverride) && (
<button
onClick={() => handleDelete(w)}
style={w.isBundled ? resetButtonStyle : deleteButtonStyle}
>
{w.isBundled ? 'Reset' : 'Delete'}
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
// Styles
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
const loadingStyle: CSSProperties = { padding: 24, color: '#6b7280' };
const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', margin: 0 };
const emptyStyle: CSSProperties = { fontSize: 13, color: '#6b7280', textAlign: 'center', padding: 32 };
const headerStyle: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
};
const cardStyle: CSSProperties = {
background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid rgba(255, 255, 255, 0.06)',
borderRadius: 8,
overflow: 'hidden',
};
const cardContentStyle: CSSProperties = {
padding: '14px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 16,
};
const titleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb' };
const keyStyle: CSSProperties = { fontSize: 11, fontFamily: 'monospace', color: '#6b7280' };
const descRowStyle: CSSProperties = { fontSize: 12, color: '#9ca3af', lineHeight: 1.5 };
const actionsStyle: CSSProperties = {
display: 'flex',
gap: 8,
flexShrink: 0,
};
const buttonStyle: CSSProperties = {
padding: '6px 12px',
fontSize: 12,
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 6,
color: '#e5e7eb',
cursor: 'pointer',
};
const primaryButtonStyle: CSSProperties = {
...buttonStyle,
background: 'rgba(34, 197, 94, 0.2)',
borderColor: 'rgba(34, 197, 94, 0.3)',
color: '#22c55e',
};
const deleteButtonStyle: CSSProperties = {
...buttonStyle,
background: 'rgba(239, 68, 68, 0.1)',
borderColor: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
};
const resetButtonStyle: CSSProperties = {
...buttonStyle,
background: 'rgba(251, 191, 36, 0.1)',
borderColor: 'rgba(251, 191, 36, 0.2)',
color: '#fbbf24',
};
const modifiedBadgeStyle: CSSProperties = {
fontSize: 10,
padding: '2px 6px',
background: 'rgba(251, 191, 36, 0.15)',
color: '#fbbf24',
borderRadius: 4,
};
const disabledBadgeStyle: CSSProperties = {
fontSize: 10,
padding: '2px 6px',
background: 'rgba(107, 114, 128, 0.2)',
color: '#6b7280',
borderRadius: 4,
};
const formStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 16,
};
const fieldStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
};
const labelStyle: CSSProperties = {
fontSize: 12,
fontWeight: 500,
color: '#9ca3af',
};
const hintStyle: CSSProperties = {
fontSize: 11,
color: '#6b7280',
};
const inputStyle: CSSProperties = {
padding: '10px 12px',
fontSize: 13,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 6,
color: '#e5e7eb',
outline: 'none',
};
const textareaStyle: CSSProperties = {
...inputStyle,
minHeight: 300,
fontFamily: 'monospace',
fontSize: 12,
lineHeight: 1.6,
resize: 'vertical',
};
const checkboxRowStyle: CSSProperties = {
display: 'flex',
gap: 24,
};
const checkboxLabelStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: '#e5e7eb',
cursor: 'pointer',
};
const errorStyle: CSSProperties = {
padding: '10px 12px',
marginBottom: 16,
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: 6,
color: '#ef4444',
fontSize: 13,
};
+42
View File
@@ -0,0 +1,42 @@
---
name: Connect
description: Quick link — find explicitly related nodes and create edges between them.
---
# Connect
Quick link: find explicitly related nodes and create edges.
## Steps
1. **Read Node**
Call `getNodesById` for the focused node. Extract the main topic/subject from the title.
2. **Quick Search**
Call `queryNodes` with the main topic from the node title.
- `search`: the key term from the title (e.g., if title mentions "Nietzsche", search "Nietzsche")
- `limit`: 10
- Do NOT add dimensions filter — search across all nodes
3. **Create Edges**
From results, pick 2-4 clearly related nodes.
Call `createEdge` for each:
- `from_node_id`: focused node ID
- `to_node_id`: related node ID
- `explanation`: "brief reason for this connection"
Direction rule: write the explanation so it reads FROM → TO.
Examples:
- Episode → Podcast: "Episode of this podcast"
- Book → Author: "Written by"
4. **Done**
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
## Rules
- Total tool calls ≤ 5
- Search the MAIN TOPIC from the title, not random names from content
- NO dimensions filter in `queryNodes` — search everything
- Only link nodes with clear relationships
- Skip if no matches found
+71
View File
@@ -0,0 +1,71 @@
---
name: Integrate
description: Full analysis — find connections across the knowledge graph, create edges, and document an integration analysis.
---
# Integrate
Find meaningful connections across the knowledge graph, create edges, and append an Integration Analysis.
## Steps
1. **Retrieve & Understand**
- Call `getNodesById` for the focused node
- 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 for Connections**
Search the database using entities from step 1:
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
- Look for shared themes, complementary ideas, contradictions
Target: 3-5 strong connections (quality over quantity)
3. **Create Edges**
For each connection found, call `createEdge`:
- `from_node_id`: the focused node ID
- `to_node_id`: the connected node ID
- `explanation`: "why this connection matters"
Direction rule: write the explanation so it reads FROM → TO.
Examples:
- Insight → Source: "Came from / inspired by"
- Episode → Podcast: "Episode of this podcast"
The tool handles duplicates gracefully — if edge exists, it returns an error and you continue.
Create 3-5 edges total.
4. **Document in Content**
Call `updateNode` ONCE using the `content` field with ONLY this new section:
```
---
## Integration Analysis
[2-3 sentences: what this is, why it matters, core insight]
**Connections:**
- [NODE:123:"Title"] — [why connected]
- [NODE:456:"Title"] — [why connected]
```
Use `updates.content` (NOT chunk). Send ONLY the new section. The tool appends automatically.
5. **Return Summary**
Reply with: Task / Actions / Result / Nodes / Follow-up (≤100 words)
## Rules
- Keep total tool calls ≤ 12
- Create edges BEFORE documenting (step 3 before step 4)
- Call `updateNode` exactly once
- Adapt to any node type
- Use `think` at any point if you need to plan your approach
+39
View File
@@ -0,0 +1,39 @@
---
name: Prep
description: Quick summary brief — extract the gist to help decide if content is worth deeper engagement.
---
# Prep
Quick summary to help the user decide if content is worth deeper engagement.
## 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 using the `content` field 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]
```
Use `updates.content` (NOT chunk). 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
+48
View File
@@ -0,0 +1,48 @@
---
name: Research
description: Deep research — conduct background web research on a topic and append findings to the node.
---
# Research
Conduct background research on the topic/person/concept and append findings to the node.
## 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 using the `content` field with ONLY this section:
```
---
## Research Notes
**Background:** [2-3 sentences of context]
**Key Findings:**
- [Finding 1]
- [Finding 2]
- [Finding 3]
**Sources:** [Brief attribution]
```
Use `updates.content` (NOT chunk). 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
@@ -1,47 +1,53 @@
export const SURVEY_WORKFLOW_INSTRUCTIONS = `You are executing the SURVEY workflow for the currently active dimension.
---
name: Survey
description: Survey a dimension — analyze patterns, themes, gaps, and insights across all nodes within it.
---
# Survey
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.
**Prerequisite:** This guide requires an active dimension. Check the ACTIVE DIMENSION section in context.
WORKFLOW STEPS
## Steps
1. RETRIEVE DIMENSION NODES
- Call queryDimensionNodes with the active dimension name
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
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:
3. **Document Findings**
Call `updateDimension` to update the dimension description with:
**Survey Summary** (append to existing description)
```
**Survey Summary**
**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]
- [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
4. **Return Summary**
Reply with: "Surveyed [dimension] — [X nodes, key insight in <15 words]"
RULES
## 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.`;
- Reference specific nodes using `[NODE:id:"title"]` format
- If no active dimension is set, inform the user to open a dimension folder first
+1 -2
View File
@@ -6,9 +6,8 @@ Mission:
3. Ask for clarification only when tool usage would fail without it.
Operating principles:
- Handle analysis, planning, and writes yourself; do not delegate.
- Handle analysis, planning, and writes yourself.
- Use createNode, updateNode, createEdge, and updateEdge when the change is unambiguous.
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
- 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.
- Treat phrases like "this conversation/paper/video" as the active focused node unless the user specifies otherwise.
+1 -6
View File
@@ -10,18 +10,13 @@ When to ask the user:
- If the request is ambiguous and guessing would waste effort or cause errors.
Execution approach:
- Handle planning, analysis, and writes directly—do not delegate to mini ra-h during normal conversations.
- Handle planning, analysis, and writes directly.
- Call createNode, updateNode, createEdge, updateEdge, and extraction tools yourself when the change is clear.
- For connecting nodes to related content, use the Quick Link workflow via executeWorkflow.
- 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.
- When creating synthesis nodes, createEdge to all source nodes.
- Before running an extraction tool, call getNodesById on the target node; if chunk_status is 'chunked' (or embeddings are available) reuse the stored content instead of re-extracting.
Workflows:
- User can trigger predefined workflows (e.g., "run integrate workflow").
- executeWorkflow hands coordination to wise ra-h; any mini ra-h work happens inside that workflow only.
Tool strategy:
- Use tools directly—you already have everything you need.
- queryNodes for titles, searchContentEmbeddings for content, queryEdge for connections.
-12
View File
@@ -1,12 +0,0 @@
/**
* Minimal system prompt for workflow execution.
* All specific instructions come from the workflow definition itself.
*/
export const WORKFLOW_EXECUTOR_SYSTEM_PROMPT = `You are a workflow executor. Follow the workflow instructions exactly as written.
RULES:
- Use only the tools provided
- Do not deviate from the instructions
- Complete the workflow efficiently
- Reference nodes as [NODE:id:"title"] (e.g., [NODE:123:"My Node Title"])
- Return a brief summary when done`;
-36
View File
@@ -1,36 +0,0 @@
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.
WORKFLOW STEPS
1. READ NODE
Call getNodesById for the focused node. Extract the main topic/subject from the title.
2. QUICK SEARCH
Call queryNodes with the main topic from the node title.
- search: the key term from the title (e.g., if title mentions "Nietzsche", search "Nietzsche")
- limit: 10
- DO NOT add dimensions filter - search across all nodes
3. CREATE EDGES
From results, pick 2-4 clearly related nodes.
Call createEdge for each:
- from_node_id: focused node ID
- to_node_id: related node ID
- explanation: "brief reason for this connection"
Direction rule: write the explanation so it reads FROM → TO.
Examples:
- Episode → Podcast: "Episode of this podcast"
- Book → Author: "Written by"
4. DONE
Reply: "Linked [NODE:id:title] → [list of connected nodes as NODE:id:title]"
RULES
- Total tool calls ≤ 5
- Search the MAIN TOPIC from the title, not random names from content
- NO dimensions filter in queryNodes - search everything
- Only link nodes with clear relationships
- Skip if no matches found`;
-67
View File
@@ -1,67 +0,0 @@
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, create edges, and append an Integration Analysis.
YOU HAVE DIRECT WRITE ACCESS via updateNode (appends only) and createEdge (creates graph connections).
WORKFLOW STEPS
1. RETRIEVE & UNDERSTAND
- Call getNodesById for the focused node
- 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 FOR CONNECTIONS
Search the database using entities from step 1:
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
- Look for shared themes, complementary ideas, contradictions
Target: 3-5 strong connections (quality over quantity)
3. CREATE EDGES
For each connection found, call createEdge:
- from_node_id: the focused node ID
- to_node_id: the connected node ID
- explanation: "why this connection matters"
Direction rule: write the explanation so it reads FROM → TO.
Examples:
- Insight → Source: "Came from / inspired by"
- Episode → Podcast: "Episode of this podcast"
The tool handles duplicates gracefully - if edge exists, it returns an error and you continue.
Create 3-5 edges total.
4. DOCUMENT IN CONTENT
Call updateNode ONCE with ONLY this new section:
---
## Integration Analysis
[2-3 sentences: what this is, why it matters, core insight]
**Connections:**
- [NODE:123:"Title"] — [why connected]
- [NODE:456:"Title"] — [why connected]
[list all edges created in step 3]
CRITICAL: Send ONLY the new section. The tool appends automatically.
5. RETURN SUMMARY
Reply with: Task / Actions / Result / Nodes / Follow-up (≤100 words)
RULES
- Keep total tool calls ≤ 12
- Create edges BEFORE documenting (step 3 before step 4)
- Call updateNode exactly once
- Adapt to any node type
OPTIONAL: Call think at any point if you need to plan your approach.`;
-32
View File
@@ -1,32 +0,0 @@
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`;
-41
View File
@@ -1,41 +0,0 @@
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`;
-471
View File
@@ -1,471 +0,0 @@
import { streamText, ModelMessage } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import type { LanguageModelV2ToolResultOutput } from '@ai-sdk/provider';
import { WORKFLOW_EXECUTOR_SYSTEM_PROMPT } from '@/config/prompts/workflow-executor';
import { getToolsByNames } from '@/tools/infrastructure/registry';
import { WorkflowRegistry } from '@/services/workflows/registry';
import { ChatLoggingMiddleware } from '@/services/chat/middleware';
import { calculateCost } from '@/services/analytics/pricing';
import { UsageData } from '@/types/analytics';
import { summarizeToolExecution } from '@/services/agents/toolResultUtils';
import { edgeService } from '@/services/database/edges';
import { RequestContext } from '@/services/context/requestContext';
export interface WorkflowExecutionInput {
sessionId: string;
task: string;
context: string[];
expectedOutcome?: string | null;
traceId?: string;
parentChatId?: number;
workflowKey?: string;
workflowNodeId?: number;
}
export class WorkflowExecutor {
static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: WorkflowExecutionInput) {
console.log('🧙 [WorkflowExecutor] Starting execution', { sessionId, task: task.substring(0, 100) });
try {
const requestContext = RequestContext.get();
const workflowApiKey =
requestContext.apiKeys?.openai ||
process.env.RAH_WISE_RAH_OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (!workflowApiKey) {
throw new Error('OPENAI_API_KEY is not set for workflow execution.');
}
console.log('✅ [WorkflowExecutor] Starting workflow execution');
// Get workflow definition if available
const workflow = workflowKey ? await WorkflowRegistry.getWorkflowByKey(workflowKey) : null;
const maxIterationsLimit = workflow?.maxIterations ?? 10;
// Build the user prompt - just the task (which includes workflow instructions)
const promptSections = [
task,
context.length ? `Context:\n- ${context.join('\n- ')}` : undefined,
expectedOutcome ? `Expected outcome: ${expectedOutcome}` : undefined,
].filter(Boolean);
const openaiProvider = createOpenAI({ apiKey: workflowApiKey });
console.log('🔧 [WorkflowExecutor] OpenAI provider created');
// Use workflow-specified tools if available, otherwise fall back to safe default set
// IMPORTANT: Workflows should NEVER have access to delegateToMiniRAH - they are one-shot executors
const workflowTools = workflow?.tools;
const SAFE_WORKFLOW_DEFAULT_TOOLS = [
'getNodesById', 'queryNodes', 'queryDimensionNodes', 'searchContentEmbeddings',
'webSearch', 'updateNode', 'createEdge'
];
const tools = workflowTools?.length
? getToolsByNames(workflowTools)
: getToolsByNames(SAFE_WORKFLOW_DEFAULT_TOOLS);
console.log('🛠️ [WorkflowExecutor] Tools for workflow:', Object.keys(tools));
const toolsUsedInSession: string[] = [];
const delegatedEdgeKeys = new Set<string>();
// Workflow progress is now streamed directly to delegation tabs via delegationStreamBroadcaster
const wrappedTools = Object.fromEntries(
Object.entries(tools).map(([name, tool]) => {
const wrapped = {
...tool,
async execute(params: any, context: any) {
if (!toolsUsedInSession.includes(name)) {
toolsUsedInSession.push(name);
}
if (name === 'delegateToMiniRAH') {
const extractEdgeKey = () => {
if (!params) return null;
const tryFromTask = () => {
if (typeof params.task !== 'string') return null;
const matches = [...params.task.matchAll(/\[NODE:(\d+)/g)];
if (matches.length >= 2) {
const fromId = Number(matches[0][1]);
const toId = Number(matches[1][1]);
if (Number.isFinite(fromId) && Number.isFinite(toId)) {
return `${fromId}->${toId}`;
}
}
return null;
};
const tryFromContext = () => {
if (!Array.isArray(params.context)) return null;
let fromId: number | null = null;
let toId: number | null = null;
for (const entry of params.context) {
if (typeof entry === 'string') {
const fromMatch = entry.match(/from_node_id\D+(\d+)/i);
const toMatch = entry.match(/to_node_id\D+(\d+)/i);
if (fromMatch && Number.isFinite(Number(fromMatch[1]))) {
fromId = Number(fromMatch[1]);
}
if (toMatch && Number.isFinite(Number(toMatch[1]))) {
toId = Number(toMatch[1]);
}
}
}
if (Number.isFinite(fromId as number) && Number.isFinite(toId as number)) {
return `${fromId}->${toId}`;
}
return null;
};
return tryFromTask() || tryFromContext();
};
const edgeKey = extractEdgeKey();
if (edgeKey) {
if (delegatedEdgeKeys.has(edgeKey)) {
const [from, to] = edgeKey.split('->');
const message = `Skipped duplicate edge delegation for nodes ${from}${to}.`;
workerSummaries.push(message);
return message;
}
delegatedEdgeKeys.add(edgeKey);
const [from, to] = edgeKey.split('->').map(Number);
if (Number.isFinite(from) && Number.isFinite(to)) {
const exists = await edgeService.edgeExists(from, to);
if (exists) {
const message = `Edge ${from}${to} already exists; delegation skipped.`;
workerSummaries.push(message);
return message;
}
}
}
}
return await tool.execute(params, context);
}
};
return [name, wrapped];
})
);
console.log('📝 [WorkflowExecutor] Starting execution loop...');
const messages: ModelMessage[] = [
{ role: 'system', content: WORKFLOW_EXECUTOR_SYSTEM_PROMPT },
{ role: 'user', content: promptSections.join('\n\n') }
];
let finalText = '';
const totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
const maxIterations = maxIterationsLimit;
const seenToolResults = new Map<string, { output: LanguageModelV2ToolResultOutput; summary: string }>();
const workerSummaries: string[] = [];
const ensureString = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
// Logging helpers (no-op for lite version without delegation streaming)
const emitToolStart = (toolCallId: string, toolName: string, input: unknown) => {
console.log(`🔧 [WorkflowExecutor] Tool start: ${toolName}`);
};
const emitToolCompletion = (
toolCallId: string,
toolName: string,
rawResult: unknown,
summary: string,
status: 'complete' | 'error' = 'complete',
errorMessage?: string
) => {
console.log(`✅ [WorkflowExecutor] Tool complete: ${toolName} - ${status}`);
};
const buildToolOutput = (toolName: string, summary: string, rawResult: any): LanguageModelV2ToolResultOutput => {
const trimmedSummary = summary.trim();
if (rawResult && typeof rawResult === 'object' && rawResult.success === false) {
const message = trimmedSummary || ensureString(rawResult.error) || `${toolName} failed.`;
return { type: 'error-text', value: message };
}
if (typeof rawResult === 'string') {
const value = rawResult.trim() || trimmedSummary || `${toolName} completed.`;
return { type: 'text', value };
}
if (trimmedSummary) {
return { type: 'text', value: trimmedSummary };
}
return { type: 'text', value: `${toolName} completed.` };
};
const requestFinalSummary = async (instruction: string) => {
messages.push({
role: 'user',
content: instruction,
});
const finalStreamResult = await streamText({
model: openaiProvider('gpt-5-mini'),
messages,
tools: {},
maxOutputTokens: 500,
});
// Collect the complete response
const finalChunks: string[] = [];
for await (const chunk of finalStreamResult.textStream) {
finalChunks.push(chunk);
}
const finalResponse = {
text: finalChunks.join(''),
usage: await finalStreamResult.usage,
};
totalUsage.inputTokens += finalResponse.usage?.inputTokens || 0;
totalUsage.outputTokens += finalResponse.usage?.outputTokens || 0;
totalUsage.totalTokens += finalResponse.usage?.totalTokens || 0;
return finalResponse.text ?? '';
};
const normaliseForSignature = (toolName: string, input: any) => {
if (!input || typeof input !== 'object') {
return input;
}
if (toolName === 'webSearch' && 'query' in input) {
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
return { ...input, query };
}
if (toolName === 'searchContentEmbeddings' && 'query' in input) {
const query = ensureString(input.query).toLowerCase().replace(/\s+/g, ' ').trim();
return { ...input, query };
}
return input;
};
for (let i = 0; i < maxIterations; i++) {
console.log(`🔄 [WorkflowExecutor] Iteration ${i + 1}/${maxIterations}`);
const streamResult = await streamText({
model: openaiProvider('gpt-5-mini'),
messages,
tools: wrappedTools,
});
// Collect the complete response
const chunks: string[] = [];
for await (const chunk of streamResult.textStream) {
chunks.push(chunk);
}
const response = {
text: chunks.join(''),
finishReason: await streamResult.finishReason,
usage: await streamResult.usage,
toolCalls: await streamResult.toolCalls,
};
totalUsage.inputTokens += response.usage?.inputTokens || 0;
totalUsage.outputTokens += response.usage?.outputTokens || 0;
totalUsage.totalTokens += response.usage?.totalTokens || 0;
console.log(`📊 [WorkflowExecutor] Step ${i + 1} finishReason:`, response.finishReason);
// Log text response
if (response.text && response.text.trim()) {
console.log(`📝 [WorkflowExecutor] Response text: ${response.text.substring(0, 100)}...`);
}
if (response.finishReason !== 'tool-calls') {
finalText = response.text;
console.log('✅ [WorkflowExecutor] Got final text');
break;
}
const toolCalls = response.toolCalls || [];
console.log(`🔧 [WorkflowExecutor] Executing ${toolCalls.length} tool calls`);
// Log tool calls
if (toolCalls.length > 0) {
console.log(`🔧 [WorkflowExecutor] Processing ${toolCalls.length} tool calls`);
}
messages.push({
role: 'assistant',
content: toolCalls.map(call => ({
type: 'tool-call' as const,
toolCallId: call.toolCallId,
toolName: call.toolName,
input: (call as any).input ?? (call as any).args,
})),
});
const toolResults: Array<{
type: 'tool-result';
toolCallId: string;
toolName: string;
output: LanguageModelV2ToolResultOutput;
}> = [];
for (const call of toolCalls) {
let callInputRaw = (call as any).input ?? (call as any).args;
const signatureInput = normaliseForSignature(call.toolName, callInputRaw);
const signature = JSON.stringify({ tool: call.toolName, input: signatureInput });
// Broadcast tool call to delegation stream
emitToolStart(call.toolCallId, call.toolName, callInputRaw);
// Skip duplicate tool calls (except think which can be called multiple times)
if (call.toolName !== 'think' && seenToolResults.has(signature)) {
const cached = seenToolResults.get(signature)!;
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: cached.output,
});
// Broadcast cached result
emitToolCompletion(call.toolCallId, call.toolName, cached.summary || 'Cached result', cached.summary || 'Cached result');
continue;
}
const tool = wrappedTools[call.toolName];
if (!tool) {
const warning = `Tool ${call.toolName} is not available for this workflow.`;
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'error-text', value: warning },
});
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, warning, 'error', warning);
continue;
}
try {
const rawResult = await tool.execute(callInputRaw, {});
const summary = summarizeToolExecution(call.toolName, callInputRaw, rawResult);
const output = buildToolOutput(call.toolName, summary, rawResult);
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output,
});
emitToolCompletion(call.toolCallId, call.toolName, rawResult, summary, 'complete');
// Cache result (except think which can be called multiple times)
if (call.toolName !== 'think') {
seenToolResults.set(signature, { output, summary });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Tool execution failed';
toolResults.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: { type: 'error-text', value: message },
});
emitToolCompletion(call.toolCallId, call.toolName, { success: false }, message, 'error', message);
}
}
messages.push({
role: 'tool',
content: toolResults,
});
}
// If we hit max iterations without a final response, request one
if (!finalText) {
console.warn('⚠️ [WorkflowExecutor] Max iterations hit with no summary. Requesting final response without tools.');
finalText = await requestFinalSummary('Provide a brief summary of what was accomplished. Do not call any tools.');
console.log('✅ [WorkflowExecutor] Final summary obtained after tool cutoff.');
}
const usage = totalUsage;
let summary = typeof finalText === 'string' ? finalText.trim() : '';
if (summary.length > 2000) {
console.log('⚠️ [WorkflowExecutor] Summary too long, requesting concise version.');
summary = (await requestFinalSummary('Condense the findings into ≤300 tokens using the Task/Actions/Result/Nodes/Follow-up format. Focus on the most salient insights and reference key nodes. Do not call any tools.')).trim();
}
if (summary.length > 1000) {
summary = `${summary.slice(0, 997)}`;
}
console.log('📄 [WorkflowExecutor] Summary after trim:', summary);
console.log('📏 [WorkflowExecutor] Summary length:', summary.length);
if (!summary) {
console.warn('[WorkflowExecutor] Empty summary received');
throw new Error('Workflow executor returned empty summary');
}
console.log('[WorkflowExecutor] summary:', summary);
// Calculate cost and log to chats table
if (usage) {
const inputTokens = (usage as any).promptTokens || usage.inputTokens || 0;
const outputTokens = (usage as any).completionTokens || usage.outputTokens || 0;
const totalTokens = inputTokens + outputTokens;
const costResult = calculateCost({
inputTokens,
outputTokens,
modelId: 'gpt-5-mini',
});
const usageData: UsageData = {
inputTokens,
outputTokens,
totalTokens,
estimatedCostUsd: costResult.totalCostUsd,
modelUsed: 'gpt-5-mini',
provider: 'openai',
toolsUsed: toolsUsedInSession.length > 0 ? toolsUsedInSession : undefined,
toolCallsCount: toolsUsedInSession.length > 0 ? toolsUsedInSession.length : undefined,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
};
await ChatLoggingMiddleware.logChatInteraction(
task,
summary,
{
helperName: 'workflow-agent',
agentType: 'planner',
delegationId: null,
sessionId,
usageData,
traceId,
parentChatId,
workflowKey,
workflowNodeId,
systemMessage: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
},
[]
);
console.log(`💰 [WorkflowExecutor] Cost: $${costResult.totalCostUsd.toFixed(6)} (${totalTokens} tokens)`);
}
console.log('✅ [WorkflowExecutor] Workflow execution complete');
return { sessionId, summary, status: 'completed' as const };
} catch (error) {
console.error('❌ [WorkflowExecutor] Error during execution:', error);
console.error('❌ [WorkflowExecutor] Error stack:', error instanceof Error ? error.stack : 'No stack');
const message = error instanceof Error ? error.message : 'Unknown workflow error';
throw error;
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface DatabaseEvent {
| 'AGENT_UPDATED'
| 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED'
| 'WORKFLOW_PROGRESS'
| 'GUIDE_UPDATED'
| 'CONNECTION_ESTABLISHED';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
+93
View File
@@ -0,0 +1,93 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import matter from 'gray-matter';
export interface GuideMeta {
name: string;
description: string;
}
export interface Guide extends GuideMeta {
content: string;
}
const GUIDES_DIR = path.join(
os.homedir(),
'Library/Application Support/RA-H/guides'
);
const BUNDLED_GUIDES_DIR = path.join(
process.cwd(),
'src/config/guides'
);
function ensureGuidesDir(): void {
if (!fs.existsSync(GUIDES_DIR)) {
fs.mkdirSync(GUIDES_DIR, { recursive: true });
}
}
function seedDefaultGuides(): void {
if (!fs.existsSync(BUNDLED_GUIDES_DIR)) return;
const bundled = fs.readdirSync(BUNDLED_GUIDES_DIR).filter(f => f.endsWith('.md'));
for (const file of bundled) {
const dest = path.join(GUIDES_DIR, file);
if (!fs.existsSync(dest)) {
fs.copyFileSync(path.join(BUNDLED_GUIDES_DIR, file), dest);
}
}
}
function init(): void {
ensureGuidesDir();
const existing = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
if (existing.length === 0) {
seedDefaultGuides();
}
}
export function listGuides(): GuideMeta[] {
init();
const files = fs.readdirSync(GUIDES_DIR).filter(f => f.endsWith('.md'));
return files.map(file => {
const raw = fs.readFileSync(path.join(GUIDES_DIR, file), 'utf-8');
const { data } = matter(raw);
return {
name: data.name || file.replace('.md', ''),
description: data.description || '',
};
});
}
export function readGuide(name: string): Guide | null {
init();
// Try exact filename first, then lowercase
const candidates = [
`${name}.md`,
`${name.toLowerCase()}.md`,
];
for (const filename of candidates) {
const filepath = path.join(GUIDES_DIR, filename);
if (fs.existsSync(filepath)) {
const raw = fs.readFileSync(filepath, 'utf-8');
const { data, content } = matter(raw);
return {
name: data.name || name,
description: data.description || '',
content: content.trim(),
};
}
}
return null;
}
export function writeGuide(name: string, content: string): void {
init();
const filename = `${name.toLowerCase()}.md`;
const filepath = path.join(GUIDES_DIR, filename);
fs.writeFileSync(filepath, content, 'utf-8');
}
-139
View File
@@ -1,139 +0,0 @@
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> = {
'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',
tools: ['getNodesById', 'updateNode'],
maxIterations: 3,
},
'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',
tools: ['getNodesById', 'webSearch', 'updateNode'],
maxIterations: 5,
},
'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',
tools: ['getNodesById', 'queryNodes', 'createEdge'],
maxIterations: 6,
},
'integrate': {
id: 4,
key: 'integrate',
displayName: 'Integrate',
description: 'Full analysis, connection discovery, and documentation',
instructions: INTEGRATE_WORKFLOW_INSTRUCTIONS,
enabled: true,
requiresFocusedNode: true,
primaryActor: 'oracle',
expectedOutcome: 'Integration analysis appended; 3-5 edges created',
tools: ['getNodesById', 'queryNodes', 'searchContentEmbeddings', 'createEdge', 'updateNode'],
maxIterations: 12,
},
'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',
tools: ['queryDimensionNodes', 'searchContentEmbeddings', 'updateDimension'],
maxIterations: 5,
},
};
// Set of bundled workflow keys (for UI to know which can be "reset to default")
export const BUNDLED_WORKFLOW_KEYS = new Set(Object.keys(BUNDLED_WORKFLOWS));
function userWorkflowToDefinition(uw: ReturnType<typeof loadUserWorkflow>, id: number): WorkflowDefinition {
if (!uw) throw new Error('Cannot convert null workflow');
return {
id,
key: uw.key,
displayName: uw.displayName,
description: uw.description,
instructions: uw.instructions,
enabled: uw.enabled,
requiresFocusedNode: uw.requiresFocusedNode,
primaryActor: 'oracle',
expectedOutcome: undefined,
tools: uw.tools,
maxIterations: uw.maxIterations,
};
}
export class WorkflowRegistry {
static async getWorkflowByKey(key: string): Promise<WorkflowDefinition | null> {
// Try user file first
const userWorkflow = loadUserWorkflow(key);
if (userWorkflow) {
return userWorkflowToDefinition(userWorkflow, BUNDLED_WORKFLOWS[key]?.id || 100);
}
// Fall back to bundled
return BUNDLED_WORKFLOWS[key] || null;
}
static async getEnabledWorkflows(): Promise<WorkflowDefinition[]> {
const all = await this.getAllWorkflows();
return all.filter(w => w.enabled);
}
static async getAllWorkflows(): Promise<WorkflowDefinition[]> {
// Start with bundled defaults
const result: Record<string, WorkflowDefinition> = {};
for (const [key, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
result[key] = { ...workflow };
}
// Load user workflows (overwrite bundled if same key, add new ones)
const userWorkflows = listUserWorkflows();
let nextId = 100;
for (const uw of userWorkflows) {
const existingId = result[uw.key]?.id || nextId++;
result[uw.key] = userWorkflowToDefinition(uw, existingId);
}
return Object.values(result);
}
// Check if a workflow key is a bundled default
static isBundledWorkflow(key: string): boolean {
return BUNDLED_WORKFLOW_KEYS.has(key);
}
}
-15
View File
@@ -1,15 +0,0 @@
export interface WorkflowDefinition {
id: number;
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
primaryActor: 'oracle' | 'main';
expectedOutcome?: string;
/** Tools this workflow is allowed to use. If not specified, uses default set. */
tools?: string[];
/** Maximum iterations for this workflow. Defaults to 10. */
maxIterations?: number;
}
@@ -1,154 +0,0 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
export interface UserWorkflow {
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
tools?: string[];
maxIterations?: number;
}
function resolveBaseConfigDir(): string {
const override = process.env.RAH_CONFIG_DIR;
if (override && override.trim().length > 0) {
return override;
}
const home = os.homedir();
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'RA-H');
}
if (process.platform === 'win32') {
const roaming = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
return path.join(roaming, 'RA-H');
}
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
return path.join(xdgConfig, 'ra-h');
}
export function getWorkflowsDir(): string {
return path.join(resolveBaseConfigDir(), 'workflows');
}
function ensureWorkflowsDirExists(): void {
const dir = getWorkflowsDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function listUserWorkflows(): UserWorkflow[] {
const dir = getWorkflowsDir();
if (!fs.existsSync(dir)) {
return [];
}
const workflows: UserWorkflow[] = [];
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = path.join(dir, file);
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
// Validate required fields
if (parsed.key && parsed.displayName && parsed.instructions) {
workflows.push({
key: parsed.key,
displayName: parsed.displayName,
description: parsed.description || '',
instructions: parsed.instructions,
enabled: parsed.enabled !== false,
requiresFocusedNode: parsed.requiresFocusedNode !== false,
tools: Array.isArray(parsed.tools) ? parsed.tools : undefined,
maxIterations: typeof parsed.maxIterations === 'number' ? parsed.maxIterations : undefined,
});
}
} catch (error) {
console.warn(`Failed to load workflow file ${file}:`, error);
}
}
return workflows;
}
export function loadUserWorkflow(key: string): UserWorkflow | null {
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed.key || !parsed.displayName || !parsed.instructions) {
console.warn(`Invalid workflow file for key ${key}`);
return null;
}
return {
key: parsed.key,
displayName: parsed.displayName,
description: parsed.description || '',
instructions: parsed.instructions,
enabled: parsed.enabled !== false,
requiresFocusedNode: parsed.requiresFocusedNode !== false,
tools: Array.isArray(parsed.tools) ? parsed.tools : undefined,
maxIterations: typeof parsed.maxIterations === 'number' ? parsed.maxIterations : undefined,
};
} catch (error) {
console.warn(`Failed to load workflow ${key}:`, error);
return null;
}
}
export function saveWorkflow(workflow: UserWorkflow): void {
ensureWorkflowsDirExists();
const filePath = path.join(getWorkflowsDir(), `${workflow.key}.json`);
const data: Record<string, unknown> = {
key: workflow.key,
displayName: workflow.displayName,
description: workflow.description,
instructions: workflow.instructions,
enabled: workflow.enabled,
requiresFocusedNode: workflow.requiresFocusedNode,
};
// Only include tools/maxIterations if defined
if (workflow.tools) data.tools = workflow.tools;
if (workflow.maxIterations) data.maxIterations = workflow.maxIterations;
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
export function deleteWorkflow(key: string): boolean {
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
if (!fs.existsSync(filePath)) {
return false;
}
try {
fs.unlinkSync(filePath);
return true;
} catch (error) {
console.warn(`Failed to delete workflow ${key}:`, error);
return false;
}
}
export function userWorkflowExists(key: string): boolean {
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
return fs.existsSync(filePath);
}
+25
View File
@@ -0,0 +1,25 @@
import { tool } from 'ai';
import { z } from 'zod';
import { listGuides } from '@/services/guides/guideService';
export const listGuidesTool = tool({
description: 'List all available guides with their names and descriptions.',
inputSchema: z.object({}),
execute: async () => {
try {
const guides = listGuides();
return {
success: true,
data: guides,
message: `Found ${guides.length} guides`,
};
} catch (error) {
console.error('[listGuides] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to list guides',
data: [],
};
}
},
});
+34
View File
@@ -0,0 +1,34 @@
import { tool } from 'ai';
import { z } from 'zod';
import { readGuide } from '@/services/guides/guideService';
export const readGuideTool = tool({
description: 'Read a guide by name. Returns the full markdown content with instructions.',
inputSchema: z.object({
name: z.string().describe('The name of the guide to read'),
}),
execute: async ({ name }) => {
try {
const guide = readGuide(name);
if (!guide) {
return {
success: false,
error: `Guide "${name}" not found`,
data: null,
};
}
return {
success: true,
data: guide,
message: `Loaded guide: ${guide.name}`,
};
} catch (error) {
console.error('[readGuide] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to read guide',
data: null,
};
}
},
});
+30
View File
@@ -0,0 +1,30 @@
import { tool } from 'ai';
import { z } from 'zod';
import { writeGuide } from '@/services/guides/guideService';
import { eventBroadcaster } from '@/services/events';
export const writeGuideTool = tool({
description: 'Write or update a guide. Content should be full markdown with YAML frontmatter (name, description).',
inputSchema: z.object({
name: z.string().describe('The name of the guide to write'),
content: z.string().describe('Full markdown content including YAML frontmatter'),
}),
execute: async ({ name, content }) => {
try {
writeGuide(name, content);
eventBroadcaster.broadcast({ type: 'GUIDE_UPDATED', data: { name } });
return {
success: true,
data: { name },
message: `Guide "${name}" saved`,
};
} catch (error) {
console.error('[writeGuide] error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to write guide',
data: null,
};
}
},
});
+2 -6
View File
@@ -18,7 +18,7 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
orchestration: {
id: 'orchestration',
name: 'Orchestration',
description: 'Workflows, web search, and reasoning (orchestrator only)',
description: 'Web search and reasoning tools',
icon: '●',
color: '#8b5cf6'
},
@@ -42,13 +42,9 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
queryDimensionNodes: 'core',
searchContentEmbeddings: 'core',
// Orchestration: Workflows and reasoning (orchestrator only)
// Orchestration: Web search and reasoning
webSearch: 'orchestration',
think: 'orchestration',
executeWorkflow: 'orchestration',
listWorkflows: 'orchestration',
getWorkflow: 'orchestration',
editWorkflow: 'orchestration',
// Execution: Write operations and extraction (workers only)
createNode: 'execution',
+1 -15
View File
@@ -16,10 +16,6 @@ import { queryDimensionNodesTool } from '../database/queryDimensionNodes';
import { searchContentEmbeddingsTool } from '../other/searchContentEmbeddings';
import { webSearchTool } from '../other/webSearch';
import { thinkTool } from '../other/think';
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';
@@ -41,10 +37,6 @@ const CORE_TOOLS: Record<string, any> = {
const ORCHESTRATION_TOOLS: Record<string, any> = {
webSearch: webSearchTool,
think: thinkTool,
executeWorkflow: executeWorkflowTool,
listWorkflows: listWorkflowsTool,
getWorkflow: getWorkflowTool,
editWorkflow: editWorkflowTool,
};
// Execution tools for worker agents (includes write operations)
@@ -77,10 +69,6 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
...Object.keys(CORE_TOOLS),
'webSearch',
'think',
'executeWorkflow',
'listWorkflows',
'getWorkflow',
'editWorkflow',
'createNode',
'updateNode',
'createEdge',
@@ -95,9 +83,7 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
const EXECUTOR_TOOL_NAMES = [
...Object.keys(CORE_TOOLS),
...Object.keys(ORCHESTRATION_TOOLS).filter(name =>
name !== 'executeWorkflow'
),
...Object.keys(ORCHESTRATION_TOOLS),
...Object.keys(EXECUTION_TOOLS),
];
-62
View File
@@ -1,62 +0,0 @@
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',
};
}
},
});
-127
View File
@@ -1,127 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import { WorkflowRegistry } from '@/services/workflows/registry';
import { getSQLiteClient } from '@/services/database/sqlite-client';
import { WorkflowExecutor } from '@/services/agents/workflowExecutor';
import { RequestContext } from '@/services/context/requestContext';
import { getAutoContextSummaries } from '@/services/context/autoContext';
export const executeWorkflowTool = tool({
description: 'Execute predefined workflow via wise ra-h',
inputSchema: z.object({
workflowKey: z.string().describe('Key of workflow to execute (e.g., "integrate")'),
nodeId: z.number().describe('ID of node to run workflow on (usually focused node)'),
userContext: z.string().optional().describe('Optional: Additional context or instructions from user'),
}),
execute: async ({ workflowKey, nodeId, userContext }) => {
// 1. Fetch workflow definition
const workflow = await WorkflowRegistry.getWorkflowByKey(workflowKey);
if (!workflow) {
return { success: false, error: `Workflow '${workflowKey}' not found` };
}
if (!workflow.enabled) {
return { success: false, error: `Workflow '${workflowKey}' is disabled` };
}
// 2. Validate node requirement
if (workflow.requiresFocusedNode && !nodeId) {
return { success: false, error: `Workflow '${workflowKey}' requires a focused node` };
}
// 2.5. 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 {
success: false,
error: `Workflow '${workflowKey}' already ran on node ${nodeId} within the last hour. Check the node content - the Integration Analysis section should already be there.`
};
}
}
// 3. Validate node exists and fetch full context (if node provided)
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 { success: false, error: `Node ${nodeId} not found` };
}
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[];
}
// Removed conflicting guardrail - wise-rah has updateNode access and should use it
if (userContext) {
contextLines.push(`User Context: ${userContext}`);
}
// 4. Build task with workflow instructions
RequestContext.set({ workflowKey, workflowNodeId: nodeId });
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)'}`;
// 5. Execute workflow directly
const requestContext = RequestContext.get();
console.log('[executeWorkflowTool] Current traceId:', requestContext.traceId);
const sessionId = `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try {
const result = await WorkflowExecutor.execute({
sessionId,
task,
context: contextLines,
expectedOutcome: workflow.expectedOutcome,
traceId: requestContext.traceId,
parentChatId: requestContext.parentChatId,
workflowKey,
workflowNodeId: nodeId,
});
RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined });
const workflowLabel = workflow.displayName || workflowKey;
return {
success: true,
message: `Workflow **${workflowLabel}** completed.`,
summary: result.summary,
};
} catch (error) {
console.error('[executeWorkflowTool] Workflow execution failed', error);
RequestContext.set({ workflowKey: undefined, workflowNodeId: undefined });
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: `Workflow execution failed: ${errorMessage}` };
}
},
});
-43
View File
@@ -1,43 +0,0 @@
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',
};
}
},
});
-31
View File
@@ -1,31 +0,0 @@
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',
};
}
},
});
-2
View File
@@ -2,7 +2,6 @@ import { scenario as simpleQuery } from './simple-query';
import { scenario as searchEmbeddings } from './search-embeddings';
import { scenario as createNode } from './create-node';
import { scenario as hardModeQuery } from './hard-mode-query';
import { scenario as workflowIntegrate } from './workflow-integrate';
import { scenario as updateNode } from './update-node';
import { scenario as createEdge } from './create-edge';
import { scenario as queryDimensions } from './query-dimensions';
@@ -22,7 +21,6 @@ export const scenarios = [
getDimension,
dimensionLifecycle,
hardModeQuery,
workflowIntegrate,
youtubeExtract,
websiteExtract,
paperExtract,
@@ -1,17 +0,0 @@
import { Scenario } from '../types';
export const scenario: Scenario = {
id: 'workflow-integrate',
name: 'Integrate workflow on focused node',
description: 'Execute integrate workflow on a real node.',
tools: ['executeWorkflow'],
input: {
message: 'Integrate this.',
focusedNodeQuery: { titleContains: 'Markdown vs database backends for PKM' },
mode: 'hard',
},
expect: {
toolsCalledSoft: ['executeWorkflow'],
responseContainsSoft: ['integrate', 'updated'],
},
};