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
-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);
}