Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { Node } from '@/types/database';
|
||||
import { AgentRegistry } from '@/services/agents/registry';
|
||||
import { WorkflowRegistry } from '@/services/workflows/registry';
|
||||
import { getHelperTools, getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
||||
import type { CacheableBlock, SystemPromptResult } from '@/types/prompts';
|
||||
import { buildAutoContextBlock } from '@/services/context/autoContext';
|
||||
|
||||
export interface NodeContext {
|
||||
nodes: Node[];
|
||||
activeNodeId: number | null;
|
||||
}
|
||||
|
||||
export interface ContextBuilderOptions {
|
||||
maxPrimaryContent?: number; // Default: 500 chars
|
||||
maxSecondaryContent?: number; // Default: 200 chars
|
||||
includeMetadata?: boolean; // Include created/updated timestamps
|
||||
}
|
||||
|
||||
const BASE_CONTEXT = `=== RA-H BASE CONTEXT ===
|
||||
- Nodes store content (title, content, dimensions, metadata, link, chunk)
|
||||
- Edges capture directed relationships between nodes
|
||||
- When auto-context is enabled you'll see BACKGROUND CONTEXT with the 10 most-connected nodes (ID + title only)
|
||||
- Focused nodes show truncated content; use queryNodes, searchContentEmbeddings, or queryEdge when you need full detail
|
||||
- Node references must use [NODE:id:"title"] so the UI renders clickable labels
|
||||
- Pronouns or phrases like "this conversation/paper/video" refer to the primary focused node unless clarified
|
||||
`;
|
||||
|
||||
function buildStaticBaseContext(): string {
|
||||
return BASE_CONTEXT;
|
||||
}
|
||||
|
||||
function buildAgentInstructionsBlock(helperKey: string, systemPrompt: string): string {
|
||||
return `=== AGENT INSTRUCTIONS (${helperKey}) ===\n${systemPrompt}\n`;
|
||||
}
|
||||
|
||||
function isPrimaryOrchestrator(helperKey: string): boolean {
|
||||
return helperKey === 'ra-h' || helperKey === 'ra-h-easy';
|
||||
}
|
||||
|
||||
function buildToolDefinitionsBlock(toolNames: string[]): string {
|
||||
if (!Array.isArray(toolNames) || toolNames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tools = getHelperTools(toolNames);
|
||||
const lines: string[] = ['=== AVAILABLE TOOLS ==='];
|
||||
|
||||
Object.entries(tools).forEach(([name, tool]) => {
|
||||
if (tool?.description) {
|
||||
lines.push(`\n## ${name}\n${tool.description.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function buildWorkflowDefinitionsBlock(helperKey: string): Promise<string | null> {
|
||||
// Only include for primary orchestrator variants
|
||||
if (!isPrimaryOrchestrator(helperKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const workflows = await WorkflowRegistry.getEnabledWorkflows();
|
||||
if (workflows.length === 0) return null;
|
||||
|
||||
const lines: string[] = ['=== AVAILABLE WORKFLOWS ==='];
|
||||
|
||||
for (const workflow of workflows) {
|
||||
lines.push(`\n## ${workflow.displayName} (${workflow.key})`);
|
||||
lines.push(workflow.description);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
} catch (error) {
|
||||
console.warn('Workflow definitions load failed (contextBuilder):', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateToWords(text: string, maxWords: number): string {
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words.length <= maxWords) return text;
|
||||
return words.slice(0, maxWords).join(' ') + '…';
|
||||
}
|
||||
|
||||
function parseMetadata(metadata: unknown): Record<string, any> {
|
||||
if (!metadata) return {};
|
||||
if (typeof metadata === 'string') {
|
||||
try {
|
||||
return JSON.parse(metadata);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse node metadata JSON:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof metadata === 'object') {
|
||||
return metadata as Record<string, any>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function describeChunkStatus(node: Node): string {
|
||||
const status = node.chunk_status || 'unknown';
|
||||
const chunkLength = typeof node.chunk === 'string' ? node.chunk.length : 0;
|
||||
const approxChars = chunkLength > 0 ? ` (~${Math.max(1, Math.round(chunkLength / 1000))}k chars)` : '';
|
||||
const metadata = parseMetadata(node.metadata);
|
||||
const transcriptLength = typeof metadata.transcript_length === 'number'
|
||||
? metadata.transcript_length
|
||||
: undefined;
|
||||
let transcriptLabel = '';
|
||||
if (transcriptLength && transcriptLength > 0) {
|
||||
transcriptLabel = `, transcript ≈${Math.max(1, Math.round(transcriptLength / 1000))}k chars`;
|
||||
}
|
||||
const embeddingsAvailable = status === 'chunked' || chunkLength > 0;
|
||||
return `Chunks: ${status}${approxChars}${transcriptLabel}; Embeddings: ${embeddingsAvailable ? 'available' : 'missing'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dynamic focused nodes context section
|
||||
*/
|
||||
export function buildFocusedNodesBlock(
|
||||
context: NodeContext,
|
||||
options: ContextBuilderOptions = {}
|
||||
): string {
|
||||
if (!context.nodes || context.nodes.length === 0) {
|
||||
return '\n=== CURRENT FOCUS ===\nNo nodes currently in focus.';
|
||||
}
|
||||
|
||||
let contextString = '=== CURRENT FOCUS ===';
|
||||
contextString += '\n25-word previews; use queryNodes/searchContentEmbeddings for full detail\n';
|
||||
|
||||
const validNodes = context.nodes.filter(n => n != null);
|
||||
const activeNode = validNodes.find(n => n.id === context.activeNodeId);
|
||||
const otherNodes = validNodes.filter(n => n.id !== context.activeNodeId);
|
||||
|
||||
if (activeNode) {
|
||||
contextString += `\n[PRIMARY - Tab 1]\nID: ${activeNode.id} | "${activeNode.title || 'Untitled'}"\n${truncateToWords(activeNode.content || 'No content', 25)}\nLink: ${activeNode.link || 'No link'}`;
|
||||
contextString += `\n${describeChunkStatus(activeNode)}`;
|
||||
}
|
||||
|
||||
if (otherNodes.length > 0) {
|
||||
otherNodes.forEach((node, index) => {
|
||||
contextString += `\n\n[Tab ${index + 2}]\nID: ${node.id} | "${node.title || 'Untitled'}"\n${truncateToWords(node.content || 'No content', 25)}\nLink: ${node.link || 'No link'}\n${describeChunkStatus(node)}`;
|
||||
});
|
||||
}
|
||||
|
||||
contextString += '\n===================================';
|
||||
return contextString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds system prompt as cacheable blocks (Anthropic prompt caching)
|
||||
*/
|
||||
export async function buildSystemPromptBlocks(
|
||||
nodeContext: NodeContext,
|
||||
helperComponentKey: string,
|
||||
options?: ContextBuilderOptions
|
||||
): Promise<SystemPromptResult> {
|
||||
const helper = await AgentRegistry.getAgentByKey(helperComponentKey);
|
||||
const isAnthropic = helper?.model?.startsWith('anthropic/');
|
||||
const cacheControl = isAnthropic ? { type: 'ephemeral' as const } : undefined;
|
||||
const blocks: CacheableBlock[] = [];
|
||||
|
||||
const baseContext = buildStaticBaseContext().trim();
|
||||
const helperPrompt = helper?.systemPrompt || 'No instructions provided.';
|
||||
const instructionsBlock = buildAgentInstructionsBlock(helperComponentKey, helperPrompt).trim();
|
||||
|
||||
const combinedInstructions = [baseContext, instructionsBlock]
|
||||
.filter(section => section.length > 0)
|
||||
.join('\n\n');
|
||||
|
||||
if (combinedInstructions.length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: combinedInstructions,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const availableToolNames = helper?.availableTools?.length
|
||||
? helper.availableTools
|
||||
: getDefaultToolNamesForRole(helper?.role === 'executor' ? 'executor' : 'orchestrator');
|
||||
const toolBlock = buildToolDefinitionsBlock(availableToolNames);
|
||||
if (toolBlock.trim().length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: toolBlock,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const workflowBlock = await buildWorkflowDefinitionsBlock(helperComponentKey);
|
||||
if (workflowBlock && workflowBlock.trim().length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: workflowBlock,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const autoContextBlock = isPrimaryOrchestrator(helperComponentKey)
|
||||
? buildAutoContextBlock()
|
||||
: null;
|
||||
if (autoContextBlock && autoContextBlock.trim().length > 0) {
|
||||
blocks.push({
|
||||
type: 'text',
|
||||
text: autoContextBlock,
|
||||
...(cacheControl ? { cache_control: cacheControl } : {})
|
||||
});
|
||||
}
|
||||
|
||||
const focusBlock = buildFocusedNodesBlock(nodeContext, options);
|
||||
blocks.push({ type: 'text', text: focusBlock });
|
||||
|
||||
return { blocks, cacheHit: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy string-based system prompt (for backward compatibility)
|
||||
* @deprecated Use buildSystemPromptBlocks for Anthropic caching support
|
||||
*/
|
||||
export async function buildSystemPrompt(
|
||||
nodeContext: NodeContext,
|
||||
helperComponentKey: string,
|
||||
options?: ContextBuilderOptions
|
||||
): Promise<{ systemPrompt: string; cacheHit: boolean }> {
|
||||
// Convert blocks to string for legacy callers
|
||||
const result = await buildSystemPromptBlocks(nodeContext, helperComponentKey, options);
|
||||
const systemPrompt = result.blocks.map(b => b.text).join('');
|
||||
return { systemPrompt, cacheHit: result.cacheHit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads helper instructions from JSON file
|
||||
*/
|
||||
// DB-backed; no-op placeholder kept for API stability if imported elsewhere
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
helper: string;
|
||||
type: 'USER_MESSAGE' | 'SYSTEM_PROMPT' | 'TOOL_CALL' | 'TOOL_RESULT' | 'ASSISTANT_RESPONSE' | 'ERROR';
|
||||
content: any;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const LOG_DIR = path.join(process.cwd(), 'logs');
|
||||
const LOG_FILE = path.join(LOG_DIR, 'helper-interactions.log');
|
||||
|
||||
class HelperLogger {
|
||||
private sessionId: string;
|
||||
private logDirEnsured = false;
|
||||
|
||||
constructor() {
|
||||
this.sessionId = Date.now().toString();
|
||||
}
|
||||
|
||||
private async ensureLogDir() {
|
||||
if (this.logDirEnsured) return;
|
||||
try {
|
||||
await fs.mkdir(LOG_DIR, { recursive: true });
|
||||
this.logDirEnsured = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to create log directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async writeLog(entry: LogEntry) {
|
||||
try {
|
||||
await this.ensureLogDir();
|
||||
const logLine = JSON.stringify(entry) + '\n';
|
||||
await fs.appendFile(LOG_FILE, logLine);
|
||||
} catch (error) {
|
||||
console.error('Failed to write log:', error);
|
||||
}
|
||||
}
|
||||
|
||||
logUserMessage(helper: string, messages: any[], openTabs: any[], activeTabId: any) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'USER_MESSAGE',
|
||||
content: {
|
||||
lastMessage: messages[messages.length - 1],
|
||||
messageCount: messages.length,
|
||||
openTabIds: openTabs.map(t => t.id),
|
||||
activeTabId
|
||||
},
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
console.log(`✓ [${helper}] Request received`);
|
||||
}
|
||||
|
||||
logSystemPrompt(helper: string, systemPrompt: string, cacheHit: boolean) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'SYSTEM_PROMPT',
|
||||
content: {
|
||||
systemPrompt,
|
||||
cacheHit
|
||||
},
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logToolCall(helper: string, toolName: string, parameters: any) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'TOOL_CALL',
|
||||
content: { toolName, parameters },
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logToolResult(helper: string, toolName: string, result: any) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'TOOL_RESULT',
|
||||
content: {
|
||||
toolName,
|
||||
result
|
||||
},
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logAssistantResponse(helper: string, response: string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'ASSISTANT_RESPONSE',
|
||||
content: { response },
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
}
|
||||
|
||||
logError(helper: string, error: Error | string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
helper,
|
||||
type: 'ERROR',
|
||||
content: error instanceof Error ? {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
} : { message: error },
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.writeLog(entry);
|
||||
console.error(`❌ [${helper}] Error occurred`);
|
||||
}
|
||||
}
|
||||
|
||||
export const helperLogger = new HelperLogger();
|
||||
Reference in New Issue
Block a user