feat(rah-light): clean up unused agent services
- Delete src/services/ai.ts (not imported anywhere) - Delete src/services/agents/registry.ts (only used by contextBuilder) - Delete src/services/helpers/ directory (contextBuilder.ts, logger.ts - unused) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
cc5397e9ba
commit
8a3b6df609
@@ -1,103 +0,0 @@
|
|||||||
import { getDefaultToolNamesForRole } from '@/tools/infrastructure/registry';
|
|
||||||
import { RAH_MAIN_SYSTEM_PROMPT } from '@/config/prompts/rah-main';
|
|
||||||
import { RAH_EASY_SYSTEM_PROMPT } from '@/config/prompts/rah-easy';
|
|
||||||
import { WORKFLOW_EXECUTOR_SYSTEM_PROMPT } from '@/config/prompts/workflow-executor';
|
|
||||||
import type { AgentDefinition } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Code-first agent registry (opinionated, not database-driven)
|
|
||||||
* Agents are defined in code and cannot be modified by users
|
|
||||||
*/
|
|
||||||
export class AgentRegistry {
|
|
||||||
// Deterministic agent definitions baked into code
|
|
||||||
private static readonly AGENTS: Record<string, AgentDefinition> = {
|
|
||||||
'ra-h': {
|
|
||||||
id: 1,
|
|
||||||
key: 'ra-h',
|
|
||||||
displayName: 'ra-h (hard)',
|
|
||||||
description: 'Opinionated orchestrator agent',
|
|
||||||
model: 'anthropic/claude-sonnet-4.5',
|
|
||||||
role: 'orchestrator',
|
|
||||||
systemPrompt: RAH_MAIN_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
},
|
|
||||||
'ra-h-easy': {
|
|
||||||
id: 4,
|
|
||||||
key: 'ra-h-easy',
|
|
||||||
displayName: 'ra-h (easy)',
|
|
||||||
description: 'Fast, low-latency orchestrator',
|
|
||||||
model: 'openai/gpt-5-mini',
|
|
||||||
role: 'orchestrator',
|
|
||||||
systemPrompt: RAH_EASY_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('orchestrator'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
},
|
|
||||||
'workflow': {
|
|
||||||
id: 3,
|
|
||||||
key: 'workflow',
|
|
||||||
displayName: 'workflow agent',
|
|
||||||
description: 'Workflow executor (uses same model as easy mode)',
|
|
||||||
model: 'openai/gpt-5-mini',
|
|
||||||
role: 'planner',
|
|
||||||
systemPrompt: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('planner'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
},
|
|
||||||
// Alias for backwards compatibility
|
|
||||||
'wise-rah': {
|
|
||||||
id: 3,
|
|
||||||
key: 'workflow',
|
|
||||||
displayName: 'workflow agent',
|
|
||||||
description: 'Workflow executor (uses same model as easy mode)',
|
|
||||||
model: 'openai/gpt-5-mini',
|
|
||||||
role: 'planner',
|
|
||||||
systemPrompt: WORKFLOW_EXECUTOR_SYSTEM_PROMPT,
|
|
||||||
availableTools: getDefaultToolNamesForRole('planner'),
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
memory: null,
|
|
||||||
prompts: undefined
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static async getAgentByKey(key: string): Promise<AgentDefinition | null> {
|
|
||||||
return this.AGENTS[key] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getAgentById(id: number): Promise<AgentDefinition | null> {
|
|
||||||
return Object.values(this.AGENTS).find(a => a.id === id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getEnabledAgents(): Promise<AgentDefinition[]> {
|
|
||||||
return Object.values(this.AGENTS).filter(a => a.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async orchestrator(): Promise<AgentDefinition> {
|
|
||||||
return this.AGENTS['ra-h'];
|
|
||||||
}
|
|
||||||
|
|
||||||
static async orchestratorForMode(mode: 'easy' | 'hard' = 'easy'): Promise<AgentDefinition> {
|
|
||||||
if (mode === 'hard') {
|
|
||||||
return this.AGENTS['ra-h'];
|
|
||||||
}
|
|
||||||
return this.AGENTS['ra-h-easy'];
|
|
||||||
}
|
|
||||||
|
|
||||||
static async planner(): Promise<AgentDefinition> {
|
|
||||||
return this.AGENTS['workflow'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import OpenAI from 'openai';
|
|
||||||
import type { AgentDefinition } from './agents/types';
|
|
||||||
import { Node } from '@/types/database';
|
|
||||||
import { getToolSchemas, executeTool } from '../tools/infrastructure/registry';
|
|
||||||
import { getSQLiteClient } from './database/sqlite-client';
|
|
||||||
import { apiKeyService } from './storage/apiKeys';
|
|
||||||
|
|
||||||
// Initialize OpenAI client with dynamic API key support
|
|
||||||
function getOpenAiClient(): OpenAI {
|
|
||||||
const apiKey = apiKeyService.getOpenAiKey();
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('OpenAI API key required. Please:\n1. Click the Settings icon (⚙️) in the bottom left\n2. Go to API Keys tab\n3. Add your OpenAI API key\n\nGet your key at: https://platform.openai.com/api-keys');
|
|
||||||
}
|
|
||||||
return new OpenAI({ apiKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
role: 'system' | 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolCall {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
params: any;
|
|
||||||
result: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatResponse {
|
|
||||||
response: string;
|
|
||||||
toolCalls?: ToolCall[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AIService {
|
|
||||||
/**
|
|
||||||
* Chat with a helper using GPT-4o-mini with function calling
|
|
||||||
*/
|
|
||||||
static async chatWithHelper(
|
|
||||||
helper: AgentDefinition,
|
|
||||||
message: string,
|
|
||||||
selectedNodeIds: number[] = [],
|
|
||||||
messageHistory: ChatMessage[] = []
|
|
||||||
): Promise<ChatResponse> {
|
|
||||||
try {
|
|
||||||
// Get selected nodes details
|
|
||||||
const selectedNodes = await this.getSelectedNodes(selectedNodeIds);
|
|
||||||
|
|
||||||
// Build context for tools
|
|
||||||
const toolContext = {
|
|
||||||
selectedNodes,
|
|
||||||
database: getSQLiteClient()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build messages array
|
|
||||||
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: this.buildSystemPrompt(helper, selectedNodes)
|
|
||||||
},
|
|
||||||
// Add message history
|
|
||||||
...messageHistory.map(msg => ({
|
|
||||||
role: msg.role as 'user' | 'assistant',
|
|
||||||
content: msg.content
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: message
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Get tool schemas for this helper
|
|
||||||
const toolSchemas = getToolSchemas(helper.availableTools);
|
|
||||||
|
|
||||||
// Make OpenAI API call
|
|
||||||
const openai = getOpenAiClient();
|
|
||||||
const completion = await openai.chat.completions.create({
|
|
||||||
model: helper.model,
|
|
||||||
messages,
|
|
||||||
tools: toolSchemas.length > 0 ? toolSchemas : undefined,
|
|
||||||
tool_choice: toolSchemas.length > 0 ? 'auto' : undefined,
|
|
||||||
temperature: 0.7,
|
|
||||||
max_tokens: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantMessage = completion.choices[0]?.message;
|
|
||||||
|
|
||||||
if (!assistantMessage) {
|
|
||||||
throw new Error('No response from OpenAI');
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = assistantMessage.content || '';
|
|
||||||
const toolCalls: ToolCall[] = [];
|
|
||||||
|
|
||||||
// Handle tool calls if present
|
|
||||||
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
|
||||||
console.log(`Agent ${helper.key} is calling ${assistantMessage.tool_calls.length} tools`);
|
|
||||||
|
|
||||||
for (const toolCall of assistantMessage.tool_calls) {
|
|
||||||
try {
|
|
||||||
const toolName = toolCall.function.name;
|
|
||||||
const toolParams = JSON.parse(toolCall.function.arguments);
|
|
||||||
|
|
||||||
console.log(`Executing tool: ${toolName}`, toolParams);
|
|
||||||
|
|
||||||
// Execute the tool
|
|
||||||
const result = await executeTool(toolName, toolParams, toolContext);
|
|
||||||
|
|
||||||
toolCalls.push({
|
|
||||||
id: toolCall.id,
|
|
||||||
name: toolName,
|
|
||||||
params: toolParams,
|
|
||||||
result
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Tool ${toolName} completed:`, result.success ? 'success' : 'failed');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error executing tool ${toolCall.function.name}:`, error);
|
|
||||||
toolCalls.push({
|
|
||||||
id: toolCall.id,
|
|
||||||
name: toolCall.function.name,
|
|
||||||
params: {},
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Tool execution failed'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have tool results, make another call to get the final response
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
const toolMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: assistantMessage.content,
|
|
||||||
tool_calls: assistantMessage.tool_calls
|
|
||||||
},
|
|
||||||
...toolCalls.map(toolCall => ({
|
|
||||||
role: 'tool' as const,
|
|
||||||
tool_call_id: toolCall.id,
|
|
||||||
content: JSON.stringify(toolCall.result)
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
const finalCompletion = await getOpenAiClient().chat.completions.create({
|
|
||||||
model: 'gpt-5-mini',
|
|
||||||
messages: toolMessages,
|
|
||||||
temperature: 0.7,
|
|
||||||
max_tokens: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
response = finalCompletion.choices[0]?.message?.content || response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
response,
|
|
||||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in chatWithHelper:', error);
|
|
||||||
throw new Error(
|
|
||||||
error instanceof Error ? error.message : 'Failed to process chat message'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get selected nodes with their details
|
|
||||||
*/
|
|
||||||
private static async getSelectedNodes(nodeIds: number[]): Promise<Node[]> {
|
|
||||||
if (nodeIds.length === 0) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sqlite = getSQLiteClient();
|
|
||||||
const placeholders = nodeIds.map(() => '?').join(', ');
|
|
||||||
const result = sqlite.query<any>(
|
|
||||||
`SELECT n.id, n.title, n.content, n.link, n.metadata, n.chunk,
|
|
||||||
n.chunk_status, n.embedding_updated_at, n.embedding_text,
|
|
||||||
n.created_at, n.updated_at,
|
|
||||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
|
||||||
FROM nodes n
|
|
||||||
WHERE n.id IN (${placeholders})
|
|
||||||
ORDER BY n.created_at DESC`,
|
|
||||||
nodeIds
|
|
||||||
);
|
|
||||||
return result.rows.map((row: any) => ({
|
|
||||||
...row,
|
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]')
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching selected nodes:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build system prompt with context
|
|
||||||
*/
|
|
||||||
private static buildSystemPrompt(helper: AgentDefinition, selectedNodes: Node[]): string {
|
|
||||||
let systemPrompt = helper.systemPrompt;
|
|
||||||
|
|
||||||
// Universal rule: ensure clickable node labels in UI
|
|
||||||
systemPrompt += '\n\nNode references: Always format nodes as [NODE:id:"title"] so the UI renders clickable labels.';
|
|
||||||
|
|
||||||
// Add context about selected nodes
|
|
||||||
if (selectedNodes.length > 0) {
|
|
||||||
systemPrompt += '\n\n## Selected Nodes Context\n';
|
|
||||||
systemPrompt += `You have ${selectedNodes.length} node(s) selected:\n\n`;
|
|
||||||
|
|
||||||
selectedNodes.forEach((node, index) => {
|
|
||||||
systemPrompt += `${index + 1}. **${node.title || 'Untitled'}**\n`;
|
|
||||||
if (node.dimensions?.length > 0) {
|
|
||||||
systemPrompt += ` - Dimensions: ${node.dimensions.join(', ')}\n`;
|
|
||||||
}
|
|
||||||
if (node.link) {
|
|
||||||
systemPrompt += ` - URL: ${node.link}\n`;
|
|
||||||
}
|
|
||||||
systemPrompt += '\n';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
systemPrompt += '\n\n## Context\nNo nodes are currently selected. You can still help with general queries and web searches.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add available tools information
|
|
||||||
if (helper.availableTools.length > 0) {
|
|
||||||
systemPrompt += '\n\n## Available Tools\n';
|
|
||||||
systemPrompt += `You have access to the following tools: ${helper.availableTools.join(', ')}\n`;
|
|
||||||
systemPrompt += 'Use these tools when they would be helpful to answer the user\'s question.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtle, hard-coded intent capture across all helpers (non-intrusive)
|
|
||||||
systemPrompt += '\n\n## Subtle Intent Capture\n';
|
|
||||||
systemPrompt += [
|
|
||||||
'When it would clearly help you serve better, briefly ask for the user\'s intent (their "why").',
|
|
||||||
'Keep it lightweight and optional (one short sentence, once per topic).',
|
|
||||||
'Examples: "What are you hoping to do with this?", "What\'s the goal behind adding this?"',
|
|
||||||
'If the user answers, incorporate the rationale into your next steps and outputs.'
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
return systemPrompt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
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';
|
|
||||||
// buildAutoContextBlock removed - agent queries top nodes on-demand via sqliteQuery
|
|
||||||
|
|
||||||
export interface NodeContext {
|
|
||||||
nodes: Node[];
|
|
||||||
activeNodeId: number | null;
|
|
||||||
activeDimension?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DimensionContext {
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
isPriority: boolean;
|
|
||||||
nodeCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextBuilderOptions {
|
|
||||||
maxPrimaryContent?: number; // Default: 500 chars
|
|
||||||
maxSecondaryContent?: number; // Default: 200 chars
|
|
||||||
includeMetadata?: boolean; // Include created/updated timestamps
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_CONTEXT = `=== RA-H BASE CONTEXT ===
|
|
||||||
You have access to the RA-H knowledge graph via two approaches:
|
|
||||||
|
|
||||||
**sqliteQuery tool** — Use for flexible read operations:
|
|
||||||
- Ad-hoc queries, exploration, complex JOINs, aggregations
|
|
||||||
- Any query pattern not covered by structured tools
|
|
||||||
- Example: SELECT n.title, COUNT(e.id) FROM nodes n LEFT JOIN edges e ON n.id = e.from_node_id GROUP BY n.id
|
|
||||||
|
|
||||||
**Structured tools** — Use for these specific cases:
|
|
||||||
- Writes: createNode, updateNode, createEdge (need validation, embeddings, hooks)
|
|
||||||
- Semantic search: searchContentEmbeddings (needs vector DB)
|
|
||||||
- External APIs: webSearch, youtubeExtract, websiteExtract, paperExtract
|
|
||||||
|
|
||||||
**Schema Quick Reference:**
|
|
||||||
- nodes: id, title, content, chunk, dimensions (JSON array), url, created_at
|
|
||||||
- edges: id, from_node_id, to_node_id, context (JSON), source, explanation
|
|
||||||
- dimensions: id, name, description, is_locked
|
|
||||||
|
|
||||||
**Other Context:**
|
|
||||||
- Node references: use [NODE:id:"title"] format for clickable UI labels
|
|
||||||
- Focused nodes show truncated content; query for full detail
|
|
||||||
- "this conversation/paper/video" refers to the primary focused node
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const parsed = JSON.parse(metadata);
|
|
||||||
if (parsed && typeof parsed === 'object') {
|
|
||||||
return parsed as Record<string, any>;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to parse node metadata JSON:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof metadata === 'object') {
|
|
||||||
if (metadata === null) return {};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches dimension context from API or database
|
|
||||||
*/
|
|
||||||
async function fetchDimensionContext(dimensionName: string): Promise<DimensionContext | null> {
|
|
||||||
try {
|
|
||||||
// In server context, we can call the API internally or query directly
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/dimensions/${encodeURIComponent(dimensionName)}/context`);
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const data = await response.json();
|
|
||||||
return data.success ? data.data : null;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch dimension context:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the dimension context block for the system prompt
|
|
||||||
*/
|
|
||||||
function buildDimensionContextBlock(dimension: DimensionContext | null): string {
|
|
||||||
if (!dimension) return '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
=== ACTIVE DIMENSION ===
|
|
||||||
Dimension: "${dimension.name}"
|
|
||||||
Description: ${dimension.description || 'No description'}
|
|
||||||
Node Count: ${dimension.nodeCount} nodes
|
|
||||||
Priority: ${dimension.isPriority ? 'Yes (locked)' : 'No'}
|
|
||||||
|
|
||||||
Note: Use queryDimensionNodes tool to retrieve actual nodes in this dimension.
|
|
||||||
===================================
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds system prompt as cacheable blocks (Anthropic prompt caching)
|
|
||||||
*/
|
|
||||||
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 } : {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMOVED: autoContextBlock (top 10 nodes)
|
|
||||||
// Agent can now query this on-demand via sqliteQuery:
|
|
||||||
// SELECT id, title, COUNT(e.id) as edges FROM nodes n LEFT JOIN edges e ON n.id = e.from_node_id OR n.id = e.to_node_id GROUP BY n.id ORDER BY edges DESC LIMIT 10
|
|
||||||
|
|
||||||
// Add dimension context if an active dimension is provided
|
|
||||||
if (nodeContext.activeDimension) {
|
|
||||||
const dimensionContext = await fetchDimensionContext(nodeContext.activeDimension);
|
|
||||||
const dimensionBlock = buildDimensionContextBlock(dimensionContext);
|
|
||||||
if (dimensionBlock.trim().length > 0) {
|
|
||||||
blocks.push({ type: 'text', text: dimensionBlock });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusBlock = buildFocusedNodesBlock(nodeContext, options);
|
|
||||||
blocks.push({ type: 'text', text: focusBlock });
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
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